Класс Thread в Java
Создание и управление потоками в Java через наследование Thread или передачу Runnable — и компромиссы между этими подходами.
java.lang.Thread — это объект, который вы держите в руках, когда хотите запустить, именовать, дождаться завершения, прервать или опросить поток выполнения. Предыдущая глава ввела потоки на концептуальном уровне; эта — обзор API. Всё в java.util.concurrent — исполнители, futures, виртуальные потоки — построено поверх Thread, поэтому стоит знать исходный класс, даже если в рабочем коде вы обычно будете тянуться за высокоуровневыми обёртками.
Два способа создать поток
Thread — это Runnable, обёрнутый в управляющий объект. Есть два способа передать ему Runnable:
// 1. Pass a Runnable to the constructor (the modern, preferred form)
Thread a = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()));
// 2. Extend Thread and override run()
class HelloThread extends Thread {
@Override public void run() {
System.out.println("hello from " + getName());
}
}
Thread b = new HelloThread();Оба работают; оба запускают ваш код в новом потоке. Первая форма — то, что использует практически весь современный код, по трём причинам:
- Класс может наследоваться только от одного другого класса. Если вы наследуете
Thread, вы не можете наследовать больше ничего — а та часть вашего кода, которая является работой, почти никогда не имеет веской причины быть потоком в смысле ООП. ПередачаRunnableоставляет ваш бизнес-класс свободным. - Лямбды делают из формы с
Runnableоднострочник. НаследованиеThreadтребует именованного класса для того же кода. Runnable, который вы передаёте, можно также позже передать вExecutorService. ПодклассThreadпривязан к запуску в собственном выделенном потоке.
Наследуйте Thread только тогда, когда вам действительно нужно добавить состояние или методы к самому потоку (редкость). Во всех остальных случаях — передавайте Runnable.
Запуск и ожидание
Два метода, которые вы будете использовать практически с каждым потоком:
Thread t = new Thread(() -> doWork(), "worker");
t.start(); // schedule it; return immediately
t.join(); // block the caller until the thread finishesНесколько ошибок, которые допускают новички:
start()— это то, что создаёт поток ОС. Прямой вызовrun()выполняет тело в текущем потоке, синхронно — новый поток не создаётся. Это самая распространённая ошибка в многопоточном программировании для новичков. Если вы не видитеstart(), параллелизма не произошло.start()можно вызвать только один раз.Thread— объект одноразового использования. Повторный вызовstart()бросаетIllegalThreadStateException. Чтобы снова запустить ту же задачу, создайте новыйThreadили используйтеExecutorService.join()может броситьInterruptedException. Это блокирующий вызов. Если кто-то вызываетinterrupt()на потоке, ожидающем вjoin(), ожидание завершается с исключением. Вы обязаны его обработать или передать выше.
join(millis) ждёт не более указанного числа миллисекунд перед возвратом — независимо от того, завершился ли поток. Используйте его, когда хотите дать рабочему потоку ограниченный шанс завершиться корректно, прежде чем принять более жёсткие меры.
Конструкторы, которые важны
У Thread много конструкторов; на практике важны четыре:
| Конструктор | Когда использовать |
|---|---|
new Thread(Runnable) | Базовый случай. Анонимный рабочий поток. |
new Thread(Runnable, String name) | Почти всегда предпочтительнее — имена видны в логах, профайлерах, дампах потоков. |
new Thread(ThreadGroup, Runnable, String) | Когда нужна явная группа (редкость; группы в основном устарели). |
new Thread(ThreadGroup, Runnable, String, long stackSize) | Когда стек по умолчанию (~1 МБ) не подходит — например, при глубокой рекурсии или давлении на память. |
Пустой конструктор new Thread() существует и запускает пустой run(), который ничего не делает. Использовать его нет смысла.
Всегда давайте потокам имена. "worker-1", "http-3", "flush-loop" — что угодно, отражающее роль. Дамп потоков, полный Thread-7, Thread-12, Thread-19 — это дамп, который невозможно прочитать.
Свойства экземпляра Thread
Несколько полей и геттеров, с которыми вы реально будете работать:
t.setName("scanner-2"); // any time before or after start()
String name = t.getName();
t.setDaemon(true); // BEFORE start(); else IllegalThreadStateException
boolean d = t.isDaemon();
t.setPriority(Thread.NORM_PRIORITY); // 1..10; mostly advisory, see chapter 6
int p = t.getPriority();
Thread.State s = t.getState(); // NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
boolean alive = t.isAlive(); // true between start() and run() returning
long id = t.threadId(); // Java 19+; old name: getId()Два из них важнее всего:
setDaemon(true)определяет, удерживает ли поток JVM в живых. Смотрите предыдущую главу — демоны умирают вместе с программой; не-демоны держат её работающей, пока не вернутся.getState()— то, на что вы смотрите в дампе потоков, чтобы диагностировать «почему поток завис».BLOCKEDозначает ожидание внутреннего замка;WAITING/TIMED_WAITINGозначает, что поток припаркован вwait(),join(),sleep(),LockSupport.park()и т. д.
Статические вспомогательные методы Thread
Несколько статических методов, которые вы будете вызывать изнутри рабочего потока:
Thread.currentThread(); // the thread that's executing this code
Thread.sleep(2000); // pause this thread for ~2000 ms
Thread.yield(); // hint to the scheduler "go ahead and run someone else"
Thread.interrupted(); // returns and CLEARS the interrupt flag of currentThreadThread.sleep — самый распространённый; он бросает InterruptedException, поэтому вызывающий код должен его обработать или передать выше. Thread.yield почти никогда не является правильным инструментом — это расплывчатая подсказка, которую JVM и ОС могут проигнорировать. Если нужна координация, используйте настоящий примитив синхронизации.
Thread.interrupted() возвращает true, если текущий поток был прерван, и сбрасывает флаг. t.isInterrupted() (метод экземпляра, на другом потоке) возвращает флаг без сброса. Путаница между ними — распространённый источник застрявших прерываний.
Прерывание: как попросить поток остановиться
Безопасного t.stop() не существует (метод есть, но он устарел с версии 1.1, потому что оставляет замки захваченными, а состояние — испорченным). Кооперативный протокол завершения таков:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
doOneUnitOfWork();
}
}, "worker");
worker.start();
// ... later, from somewhere else:
worker.interrupt();
worker.join();interrupt() устанавливает флаг прерывания рабочего потока. Предполагается, что рабочий поток проверяет флаг в безопасных точках и выходит. Если рабочий поток заблокирован в sleep, wait, join или многих вызовах java.nio, блокирующий вызов немедленно бросает InterruptedException, чтобы поток мог отреагировать.
Если вы перехватываете InterruptedException и не хотите передавать его выше, принято повторно устанавливать флаг, чтобы вызывающий код выше по стеку всё ещё видел прерывание:
try { Thread.sleep(1000); }
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // re-arm the flag
return; // and give up cooperatively
}Проглотить прерывание без повторной установки флага — это баг. Флаг — это то, как остальная программа знает, что поток попросили остановиться.
Развёрнутый пример: полный жизненный цикл в одной программе
Программа ниже создаёт двух рабочих разными способами (Runnable, подкласс), наблюдает их переходы состояний, дожидается их завершения и демонстрирует протокол прерывания на третьем рабочем потоке.
Что следует вынести из запуска:
- Переходы состояний соответствуют контракту. До
start()оба потока были в состоянииNEW. Послеstart()—RUNNABLE(илиTERMINATED, если работа была крошечной и завершилась до вывода). Послеjoin()оба перешли вTERMINATED. Именно это описываетThread.State. - Строка "t3 ran on thread: main" — это баг, который нужно запомнить навсегда.
t3.run()выполнил тело — в вызывающем потоке, синхронно. Новый поток создан не был. После этогоt3.isAlive()вернулfalse, потому чтоstart()никогда не вызывался. Если вы отлаживаете «ничто, кажется, не выполняется параллельно», проверьте, написали ли выstart()илиrun(). - Цикл с прерыванием не использовал
Thread.sleepкак основное ожидание — он просто опрашивал флаг в цикле, с редкими короткими усыплениями, чтобы прерывание могло завершить сон досрочно. Контракт одинаков в обоих случаях:isInterrupted()— это то, что опрашивает рабочий поток;interrupt()— то, что вызывает запрашивающий. - Повторная установка флага внутри
catch(строкаThread.currentThread().interrupt()) сохранила сигнал для любого кода выше по стеку вызовов. Без этой строки перехваченное и проигнорированное прерывание бесследно исчезнет — это один из самых простых способов написать поток, который не завершится корректно. - Демон в конце был готов спать 60 секунд; вместо этого JVM завершилась, как только
mainвернулся, убив его на полуслове. Потоки-демоны могут владеть любыми ресурсами — но их также могут прервать в любой момент, поэтому не стоит поручать им работу, требующую фиксации результатов.
Что дальше
Следующая глава, Интерфейс Runnable в Java, подробно рассматривает Runnable — что это на самом деле, почему поверх него были добавлены Callable и Future, и как лямбды изменили эргономику передачи работы потоку.