Интерфейс Java Runnable
Определяйте единицы работы для потоков с помощью функционального интерфейса Runnable — предпочтительный способ для потоков, executor и виртуальных потоков.
Runnable — это интерфейс с единственным методом, возможно, самый важный в java.lang. Всё, что «выполняется в потоке» в Java, в конечном счёте является Runnable: конструктор Thread принимает его, ExecutorService.execute принимает его, обработчики завершения работы JVM принимают его. Причина, по которой предыдущая глава рекомендовала «передавать Runnable в конструктор Thread» вместо «расширять Thread», состоит в том, что Runnable разделяет что выполняется и что это выполняет. Именно это разделение позволяет одной и той же задаче работать на платформенном потоке, пуле потоков или виртуальном потоке без изменения кода.
Структура
Всё определение укладывается в три строки:
@FunctionalInterface
public interface Runnable {
void run();
}Вот и всё. Из этих трёх строк следует два вывода:
- Это функциональный интерфейс. Любая лямбда или ссылка на метод с сигнатурой без аргументов и типом возврата void реализует его:
() -> System.out.println("hi"),this::flush,Foo::staticMethod. - Метод возвращает void и не выбрасывает проверяемых исключений. Это предел того, что можно выразить. Если вам нужен результат или нужно выбросить проверяемое исключение, необходим
Callable(через одну-две главы).
Три способа написать Runnable
// 1. Lambda — the modern default
Runnable r1 = () -> System.out.println("hello");
// 2. Method reference — when an existing method has the right signature
Runnable r2 = System.out::flush;
// 3. Anonymous class — pre-Java-8 form, occasionally useful when the body needs fields
Runnable r3 = new Runnable() {
@Override public void run() {
System.out.println("hello");
}
};Все три варианта создают объект типа Runnable. Лямбда-форма предпочтительна начиная с Java 8; форма анонимного класса полезна только тогда, когда вам нужны собственные поля (что обычно не требуется — вместо этого захватывайте локальные переменные).
Как используется Runnable
Три основных API, принимающих Runnable:
new Thread(runnable).start(); // platform thread, dedicated
executor.execute(runnable); // thread pool or virtual thread
Runtime.getRuntime().addShutdownHook(new Thread(runnable)); // JVM shutdownОдин и тот же экземпляр Runnable работает во всех трёх контекстах. В этом и заключается замысел: что (работа) и где (поток) ортогональны. Вы можете написать код, выполняющий работу, а кто-то другой решит, на чём его запустить.
Контраст с формой подкласса Thread делает это наглядным:
// Coupled: this work can only run on its own dedicated platform thread.
class ImageResizer extends Thread {
@Override public void run() { resize(); }
}
new ImageResizer().start();
// Decoupled: the same body runs anywhere.
Runnable resize = this::resize;
new Thread(resize).start(); // dedicated thread
executor.execute(resize); // pool
virtualExecutor.execute(resize); // virtual threadИменно поэтому в продакшн-коде на Java полно Runnable (и Callable) и почти никогда нет классов, расширяющих Thread.
Захватываемые переменные должны быть effectively final
Лямбда, становящаяся Runnable, может читать локальные переменные объемлющего метода, но только те, которые компилятор может признать effectively final — присвоенными ровно один раз и никогда не переприсваиваемыми:
String name = "alice";
int n = 3;
Runnable r = () -> {
for (int i = 0; i < n; i++) {
System.out.println(name + " " + i);
}
};
// n = 4; // would break the lambda above — compile errorЕсли вам нужно разделяемое изменяемое состояние, захваченная локальная переменная не подойдёт — нужно поле, AtomicInteger, элемент массива или другой объект с изменяемым внутренним состоянием. Ограничение намеренное: лямбды захватывают значения, а не псевдонимы, и запрет переприсваивания — простейшее правило, делающее это последовательным.
Наиболее распространённый обходной путь — массив из одного элемента:
int[] counter = {0};
Runnable r = () -> counter[0]++; // works; the array reference is final, the int inside isn'tНо для потокобезопасных разделяемых счётчиков AtomicInteger — правильный инструмент; через несколько глав мы разберём почему.
Обработка исключений: нечего перехватывать, нечего восстанавливать
run() не выбрасывает проверяемых исключений. Если ваш обработчик может завершиться с проверяемым исключением, его нужно перехватить внутри run():
Runnable parseFile = () -> {
try {
Files.readAllLines(path);
} catch (IOException e) {
log.error("parse failed", e); // you HAVE to handle it here
}
};С непроверяемыми исключениями ситуация хуже: в вызывающем коде их никто не перехватывает. Если ваш Runnable выбросит NullPointerException в отдельном потоке, исключение попадёт в обработчик непойманных исключений этого потока и поток завершится. Главный поток ничего не узнает.
Два способа справиться с этим:
- Перехватывайте всё внутри
run()и логируйте самостоятельно. Грубо, но надёжно. - Используйте
CallableиFuture.get().Futureповторно выбрасывает исключение в потоке, вызвавшемget(). Именно это даёт вам executor framework.
Для разовой работы подходит вариант 1; если задача должна вернуть результат вызывающей стороне, правильный ответ — вариант 2.
Runnable vs. Callable
Сравнение двух интерфейсов задач — с Callable вы познакомитесь позже, но контраст полезен уже сейчас:
Runnable | Callable<V> | |
|---|---|---|
| Метод | void run() | V call() throws Exception |
| Возвращаемое значение | Нет | Типизированный результат V |
| Проверяемые исключения | Не может выбрасывать | Может выбрасывать любое Exception |
| Принимается | new Thread, Executor.execute, shutdown hooks | ExecutorService.submit |
| Дескриптор результата | Нет (fire and forget) | Future<V> |
Если вам нужно либо возвращаемое значение, либо возможность выбрасывать проверяемые исключения — переходите на Callable. Для работы с чистыми побочными эффектами — сброс буфера, логирование, планирование — Runnable является более лёгким инструментом.
Практический пример: один Runnable, три исполнителя
Программа ниже определяет один Runnable, выполняющий небольшую работу, а затем запускает тот же экземпляр (а) в новом платформенном потоке, (б) в ExecutorService и (в) в вызывающем потоке напрямую через .run(). Одно и то же тело выполняется во всех трёх контекстах; меняется только исполнитель.
Что можно извлечь из этого запуска:
- Первые три блока запустили один и тот же экземпляр
greetв трёх разных исполнителях — прямой вызов, выделенный поток, пул потоков. Имя потока, которое печатаетgreet, каждый раз менялось:main,dedicated-worker,pool-1-thread-1. В этом и заключается главная причина предпочитатьRunnableподклассуThread: работа переиспользуется, исполнитель подключаемый. RuntimeExceptionпотокаcrashyне убилmain. Он завершился в своём потоке, и обработчик непойманных исключений сообщил о нём. Без обработчика JVM печатает стек-трейс в stderr, а остальная программа продолжает работу — что зачастую хуже, потому что работа, которую должен был выполнить поток, тихо не была сделана.- Лямбда
shoutзахватилаnameиnиз локальных переменныхmain. Они effectively final — присвоены однажды, никогда не переприсваиваются. Добавьтеn = 4;в любом месте после определения лямбды — и файл перестанет компилироваться. Это ограничение делает захват в лямбдах безопасным между потоками. - В примере
bumpиспользовалсяAtomicInteger, потому что два потока инкрементировали один и тот же счётчик. С обычным полемintитоговое значение оказалось бы где-то между1000и2000— потерянные обновления из-за неатомарногоi++.incrementAndGet()— простейшее исправление, и мы вернёмся к этому в главе об атомарных переменных. - Единственный разделяемый экземпляр
Runnableбыл передан вnew Thread(bump, "a")иnew Thread(bump, "b")— одна и та же лямбда выполнялась в двух потоках одновременно. Лямбда не имеет собственных полей; всё, к чему она обращается, живёт вне её. Такова форма каждого безопасного параллельногоRunnable: как можно меньше внутреннего состояния, а состояние выносится в потокобезопасный объект, разделяемый потоками.
Что дальше
В следующей главе, Жизненный цикл потока Java, рассматриваются шесть значений Thread.State — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED — и показывается, как читать дамп потоков, раскрывающий их.