new Thread(task).start() не означает, что task.run() начнёт выполняться немедленно. Новый поток уже существует, но момент,
когда он получит процессорное время, и порядок его работы относительно других потоков определяет планировщик.
В серверном Java-приложении одновременно могут обрабатываться запросы, обновляться данные и отправляться уведомления. Независимые задачи не обязаны ждать друг друга: на многоядерном процессоре они могут выполняться параллельно, а на одном ядре процессор будет быстро переключаться между ними, создавая впечатление одновременной работы.
За этим различием между «запустили» и «выполняется» стоит обычная граница процесса и потока.
Процесс и поток
Когда мы запускаем Java-приложение, операционная система создаёт отдельный процесс. Внутри него работает JVM: загружает классы, управляет памятью и выполняет код приложения.
У процесса есть собственное адресное пространство и системные ресурсы: открытые файлы, сетевые соединения и дескрипторы. Внутри одного процесса может работать несколько потоков.
Java-процесс
├── поток main
├── поток worker-1
└── поток worker-2
Поток — это отдельный путь выполнения кода внутри процесса. Потоки одного Java-приложения используют общую heap-память, поэтому могут обращаться к одним объектам. При этом у каждого потока есть собственный stack с кадрами вызовов, параметрами и локальными переменными.
Общая память делает взаимодействие потоков быстрым, но создаёт другую проблему: несколько потоков могут одновременно изменять одно состояние. Отсюда появляются гонки данных, потерянные обновления и проблемы видимости.
Здесь нам пока достаточно общей модели. Устройство памяти JVM, процессорные кэши и Java Memory Model я отдельно разбираю в статье “Процесс, поток и память JVM”.
Создаём первый поток
В Java поток представлен классом Thread. Самый прямолинейный способ создать собственный поток — унаследоваться от
этого класса и переопределить метод run().
public class BackgroundTaskThread extends Thread {
@Override
public void run() {
System.out.println("Фоновая задача выполняется");
}
public static void main(String[] args) {
BackgroundTaskThread worker = new BackgroundTaskThread();
worker.start();
}
}
Метод run() содержит работу, которую должен выполнить поток. Однако запускается новый поток не вызовом run(), а
вызовом start().
Наследование от Thread часто показывают первым, потому что этот пример легко объяснить. В обычном коде мне такой
вариант нравится меньше: задача оказывается намертво связана с самим потоком.
Задача не обязана быть потоком. Сегодня она выполняется в отдельном Thread, завтра — в пуле
потоков, а послезавтра её вообще запустят как виртуальный поток.
Во всех примерах ниже new Thread() создаёт platform thread. Virtual threads появились в Java 21: они тоже представлены классом
Thread, но иначе планируются JVM и имеют другую стоимость blocking. Здесь они нужны только как причина не связывать задачу с
конкретным способом выполнения.
Поэтому чаще используют Runnable.
public class BackgroundTask implements Runnable {
@Override
public void run() {
System.out.println("Фоновая задача выполняется");
}
public static void main(String[] args) {
Runnable task = new BackgroundTask();
Thread workerThread = new Thread(task, "worker-1");
workerThread.start();
}
}
Здесь отдельно существует задача:
Runnable task
и отдельно поток, который её выполняет:
Thread workerThread
Разница может показаться чисто косметической, пока примеры состоят из одного println. В реальном приложении она быстро
становится полезной: одну и ту же задачу можно передавать разным исполнителям, тестировать отдельно и не привязывать к
конкретному способу запуска.
Создавать много потоков через new Thread() в рабочем приложении обычно не стоит. Для этого существуют
ExecutorService, пулы потоков и другие инструменты из java.util.concurrent. Но начинать всё равно приходится с
обычного Thread, иначе дальше будет непонятно, чем именно управляют более высокоуровневые конструкции.
start() и run() выглядят похоже, но делают разное
Допустим, задача должна выполняться в отдельном потоке:
Thread workerThread = new Thread(
() -> System.out.println(
"Задача выполняется в потоке "
+ Thread.currentThread().getName()
),
"worker-1"
);
Правильный запуск выглядит так:
workerThread.start();
После вызова start() JVM создаёт новый путь выполнения. Метод run() будет вызван внутри потока worker-1.
А теперь вызовем его напрямую:
workerThread.run();
Никакого нового потока здесь не появится. Это будет самый обычный вызов метода в текущем потоке.
Проверим:
public class StartAndRunExample {
public static void main(String[] args) {
Runnable task = () -> System.out.println(
"Код выполняется в потоке "
+ Thread.currentThread().getName()
);
Thread firstThread = new Thread(task, "worker-1");
firstThread.start();
Thread secondThread = new Thread(task, "worker-2");
secondThread.run();
}
}
Результат будет примерно таким:
Код выполняется в потоке main
Код выполняется в потоке worker-1
Порядок строк может поменяться, но суть останется той же:
firstThread.start()запускает код в новом потокеworker-1;secondThread.run()выполняет код в уже существующем потокеmain.
Это одна из тех ошибок, которые легко не заметить. Код работает, исключений нет, сообщения печатаются — только никакой многопоточности на самом деле не появилось.
Есть ещё один момент: объект Thread можно запустить только один раз.
Thread worker = new Thread(
() -> System.out.println("Задача началась")
);
worker.start();
worker.start();
Второй вызов закончится IllegalThreadStateException. Повторно использовать можно задачу, но не уже запущенный объект
Thread.
Порядок выполнения не гарантирован
Создадим два потока:
public class RaceExample {
public static void main(String[] args) {
Thread workerOne = new Thread(
() -> System.out.println("Задача worker-1 завершена"),
"worker-1"
);
Thread workerTwo = new Thread(
() -> System.out.println("Задача worker-2 завершена"),
"worker-2"
);
workerOne.start();
workerTwo.start();
}
}
Мы сначала запустили worker-1, поэтому может показаться, что его строка обязательно появится первой.
Но нет.
Сегодня вывод будет таким:
Задача worker-1 завершена
Задача worker-2 завершена
После следующего запуска:
Задача worker-2 завершена
Задача worker-1 завершена
А иногда worker-1 действительно будет первым несколько десятков запусков подряд, после чего порядок внезапно поменяется
именно тогда, когда кто-нибудь решит, что он уже “стабильный”.
Вызов start() сообщает, что поток готов к выполнению. Он не означает: “немедленно остановить всё остальное и выполнить
этот поток до конца”.
Дальше в дело вступает планировщик. Для обычных платформенных потоков значительная часть планирования выполняется операционной системой. Она учитывает доступные ядра, нагрузку и множество других факторов, на которые Java-программа напрямую не влияет.
У потоков есть приоритеты:
workerOne.setPriority(Thread.MAX_PRIORITY);
workerTwo.setPriority(Thread.MIN_PRIORITY);
Но использовать их для построения порядка выполнения — плохая идея. Приоритет является скорее подсказкой планировщику, а не гарантией.
Если программе нужно, чтобы один поток обязательно начал действовать после другого, это должно быть явно описано средствами синхронизации. Надеяться на удачный порядок запуска нельзя.
interrupt() — запрос на остановку потока
С прерыванием потоков часто возникает неправильная картинка: будто вызов interrupt() мгновенно останавливает поток в
любой точке.
На самом деле Java действует мягче.
workerThread.interrupt();
Этот вызов сообщает потоку: “тебя хотят прервать”. Что делать с этим сообщением, решает код самого потока.
Например, задача может периодически проверять флаг прерывания:
public class BackgroundTask {
public static void main(String[] args)
throws InterruptedException {
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("Задача продолжает выполняться");
}
System.out.println("Задача остановлена");
}, "worker-thread");
worker.start();
Thread.sleep(10);
worker.interrupt();
}
}
Метод isInterrupted() проверяет флаг, но не сбрасывает его.
Есть похожий статический метод:
Thread.interrupted();
Он работает только с текущим потоком и после проверки сбрасывает флаг. Из-за похожих названий эти два метода легко перепутать.
Сложнее становится, когда поток не выполняет активную работу, а спит или ждёт другой поток.
try {
Thread.sleep(5_000);
} catch (InterruptedException exception) {
// Поток разбудили прерыванием
}
Если поток находится в sleep(), join() или wait(), прерывание приводит к InterruptedException. При этом флаг
прерывания сбрасывается.
Поэтому пустой catch обычно скрывает важную информацию:
try {
Thread.sleep(5_000);
} catch (InterruptedException exception) {
// Ничего не делаем
}
Поток получил запрос на остановку, но код перехватил InterruptedException и потерял эту информацию. После выхода из
catch задача продолжит выполняться как ни в чём не бывало.
Если метод не умеет нормально обработать прерывание, распространённый вариант — восстановить флаг и завершить текущую операцию:
try {
Thread.sleep(5_000);
} catch (InterruptedException exception){
Thread.currentThread().interrupt();
return;
}
Иногда InterruptedException лучше просто пробросить выше. Всё зависит от того, на каком уровне программы принимается
решение о завершении задачи.
Главная мысль здесь простая: interrupt() не убивает поток. Он запускает механизм кооперативной остановки.
sleep() приостанавливает текущий поток
Метод Thread.sleep() приостанавливает текущий поток на указанный период.
Thread.sleep(1_000);
Число передаётся в миллисекундах, поэтому здесь поток спит примерно одну секунду.
Именно примерно. Через секунду поток становится готов продолжить выполнение, но это не означает, что процессор будет немедленно ему отдан.
Небольшой пример:
public class RetryExample {
public static void main(String[] args)
throws InterruptedException {
for (int attempt = 1; attempt <= 3; attempt++) {
System.out.printf(
"Попытка подключения: %d%n",
attempt
);
Thread.sleep(1_000);
}
}
}
Здесь sleep() подходит: мы хотим сделать паузу между попытками.
А вот такой код подозрителен:
worker.start();
Thread.sleep(1_000);
// Наверное, задача уже завершилась
System.out.println("Можно продолжать");
“Наверное” — плохое основание для синхронизации. На быстром компьютере секунды окажется слишком много, на загруженном сервере её может не хватить.
Кроме того, sleep() не освобождает уже захваченные мониторные блокировки. Если поток уснул внутри блока
synchronized, другие потоки не получат эту блокировку только потому, что текущий решил отдохнуть.
join() ждёт завершения другого потока
Когда одному потоку действительно нужно дождаться другого, для этого есть метод join().
public class JoinExample {
public static void main(String[] args)
throws InterruptedException {
Thread reportGenerator = new Thread(() -> {
System.out.println("Формирование отчёта началось");
try {
Thread.sleep(2_000);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return;
}
System.out.println("Отчёт сформирован");
}, "report-generator");
reportGenerator.start();
System.out.println("Ожидаем завершения задачи");
reportGenerator.join();
System.out.println("Отправляем готовый отчёт");
}
}
После вызова:
reportGenerator.join();
текущий поток, в данном случае main, остановится и будет ждать завершения reportGenerator.
Здесь уже нет предположения, что двух секунд “скорее всего достаточно”. Выполнение продолжится только после завершения нужного потока.
Можно ограничить время ожидания:
reportGenerator.join(1_000);
Теперь текущий поток будет ждать максимум одну секунду. Если задача не закончится за это время, программа всё равно продолжит работу.
join(), как и sleep(), реагирует на прерывание и может выбросить InterruptedException.
Где всё это используется в настоящем приложении
В обычном Spring-приложении мы редко пишем:
new Thread(task).start();
Потоки там уже существуют и управляются инфраструктурой:
- серверные worker threads обрабатывают HTTP-запросы;
- планировщик запускает задачи;
- Kafka consumer или listener container читает сообщения в выделенном ему потоке;
- пул соединений выдаёт worker threads соединения с базой;
ExecutorServiceвыполняет переданные задачи;- реактивный код переходит на другой scheduler только там, где это явно настроено, например через
publishOnилиsubscribeOn.
За пятнадцать лет работы с Java я редко видел проблемы из-за того, что разработчик не знал синтаксис создания Thread.
Гораздо чаще код терял прерывание, рассчитывал на случайный порядок выполнения или создавал очередной executor, не
учитывая уже существующие пулы приложения.
Особенно долго живут ошибки с пустым catch (InterruptedException). В обычной работе сервис выглядит исправным, а
проблема проявляется только при остановке приложения, передеплое или отмене долгой задачи. Поток получил сигнал
завершиться, но обработчик исключения сделал вид, что ничего не произошло.
Низкоуровневые правила никуда не исчезают. Если перепутать start() и run(), задача останется в текущем потоке. Если
проигнорировать прерывание, сервис может плохо завершаться. Если рассчитывать на порядок выполнения, который никто не
гарантировал, ошибка будет появляться редко и в самые неудобные моменты.
Thread — низкоуровневый инструмент, но его правила продолжают действовать и внутри Spring, серверных пулов, Kafka
consumers и ExecutorService. Задача может выполняться в другом потоке, завершиться позже ожидаемого или получить
запрос на остановку. Если порядок действий имеет значение, его нужно задавать явно.
На этом уровне мы управляли самими потоками: запускали их, останавливали и ожидали завершения. Следующий вопрос сложнее: что произойдёт, когда несколько потоков начнут читать и изменять один объект.
Для ответа придётся спуститься ниже — к heap, стекам потоков, процессорным кэшам и Java Memory Model. Это продолжение находится в статье “Процесс, поток и память JVM”.