Когда программа выполняется, операционная система даёт ей ресурсы: память, доступ к файлам, процессорное время. Чтобы делать несколько вещей одновременно, нужно понять, как устроены процессы и потоки.
Процесс и поток: в чём разница
Процесс — это запущенная программа. У каждого процесса своя изолированная память: один процесс не может случайно затронуть данные другого. Когда вы запускаете Java-приложение командой java -jar app.jar, операционная система создаёт один процесс.
Поток (thread) — это единица выполнения внутри процесса. Процесс может содержать один поток или много. Все потоки одного процесса разделяют одну и ту же область памяти: кучу (heap), статические поля, открытые файлы. Это и делает потоки мощным инструментом — и источником ошибок, если ими пользоваться неаккуратно.
Короткая формула: процесс — контейнер ресурсов, поток — единица работы внутри этого контейнера.
Зачем нужна многопоточность
Представьте серверное приложение, которое обрабатывает HTTP-запросы. Если оно однопоточное, второй запрос ждёт, пока полностью закончится первый — даже когда первый большую часть времени просто ждёт ответа от базы данных.
Многопоточность решает две задачи:
- Отзывчивость. Пока один поток ждёт I/O (сеть, диск, база данных), другой поток продолжает работу. Программа не «замирает».
- Загрузка ядер. Современные процессоры имеют 4, 8, 16 и более ядер. Однопоточная программа использует одно ядро. Потоки позволяют задействовать все доступные ядра для параллельных вычислений.
Как запустить поток: Thread и Runnable
В Java есть два базовых способа описать задачу для потока, и разница в том, что вы описываете: сам поток или работу для него. Второй отделяет работу от механизма, поэтому он и предпочтительный; первый нужно знать, чтобы читать старый код.
Способ 1 — наследование от Thread:
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Работает поток: " + Thread.currentThread().getName());
}
}
Thread t = new MyThread();
t.start(); // запускает новый поток; run() выполнится в нём
Способ 2 — реализация Runnable:
Runnable task = () -> {
System.out.println("Работает поток: " + Thread.currentThread().getName());
};
Thread t = new Thread(task);
t.start();
В примерах ниже «долгую работу» изображает Thread.sleep(ms). У него есть свойство, важное дальше по фазе: sleep не отпускает захваченные замки. Поток, заснувший внутри synchronized-блока, продолжает держать монитор, и все ожидающие стоят у закрытой двери всё это время. Отпускает монитор только wait(), о чём статья про synchronized.
Runnable предпочтителен: класс не «тратит» единственное наследование на Thread, а задача остаётся отделена от механизма её запуска.
start() против run(): распространённая ошибка
Частая ошибка — вызвать run() вместо start(). Код выглядит рабочим, ошибок нет, просто второго потока не появляется.
Слева: run() — обычный вызов метода, работа выполняется прямо в main, и main стоит, пока она идёт. Справа: start() — JVM заводит второй поток, main проходит дальше не дожидаясь, а join() сводит линии обратно.
Разницу видно по имени потока, в котором выполнился код:
живой пример
public class Demo {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> System.out.println("работаю в потоке " + Thread.currentThread().getName());
Thread t1 = new Thread(task, "worker-1");
t1.run(); // так не надо: обычный вызов метода
Thread t2 = new Thread(task, "worker-2");
t2.start(); // так надо: JVM заводит поток и вызывает run() в нём
t2.join();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатается main, потом worker-2. Первая строка и есть доказательство: t1.run() — обычный вызов метода, имя потока не изменилось. start() просит JVM создать системный поток и вызвать run() уже в нём.
Есть и вторая ловушка, в которую упираются, усвоив «поток — это объект»: у одного объекта Thread метод start() срабатывает ровно один раз. Второй вызов — даже когда поток давно отработал и завершился — бросает IllegalThreadStateException. Объект потока не переиспользуют: нужен ещё один прогон — заводят новый Thread.
И третья, из-за которой сбои в фоновых потоках теряются. Исключение, вылетевшее из run(), никуда не всплывает: вызывающего кода у потока нет, join() его не перебросит, и main о нём не узнает. Поток просто умирает, а стек-трейс печатает обработчик по умолчанию — в System.err, мимо вашего логгера, поэтому в проде такая ошибка часто не видна вовсе.
Thread worker = new Thread(() -> { throw new IllegalStateException("упало"); });
worker.setUncaughtExceptionHandler((t, e) -> log.error("поток {} умер", t.getName(), e));
worker.start();
worker.join();
System.out.println("main об ошибке ничего не знает");
Обработчик ставят либо на поток, либо на всех сразу (Thread.setDefaultUncaughtExceptionHandler), либо задают через ThreadFactory у пула. В ExecutorService правила другие и ещё коварнее: задача, отданная через submit, прячет исключение в Future, и без get() его не увидит никто — об этом в статье про пулы.
Ожидание завершения: join()
start() возвращает управление немедленно — запущенный поток живёт сам по себе. Если нужен его результат, вызывающий поток должен дождаться завершения явно, через join().
живой пример
public class JoinDemo {
public static void main(String[] args) throws InterruptedException {
int[] result = new int[1];
Thread worker = new Thread(() -> {
try {
Thread.sleep(100); // долгая работа
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
result[0] = 42;
});
worker.start();
System.out.println("сразу после start(): " + result[0]);
worker.join();
System.out.println("после join(): " + result[0]);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка печатает 0 — работа ещё не закончилась. Вторая 42: join() заблокировал главный поток до завершения worker.
Две детали про join, которые пригодятся дальше. Первая: он даёт не только ожидание, но и видимость — всё, что успел записать завершившийся поток, гарантированно видно тому, кто его дождался (это одно из правил happens-before из статьи про модель памяти). Поэтому запись в result[0] выше безопасна без всякой синхронизации, хотя массив общий: join провёл границу.
Вторая: у join есть вариант с таймаутом, worker.join(5000), и он ничего не сообщает об исходе. Метод просто возвращает управление — и когда поток закончил, и когда пять секунд истекли впустую. Различить это можно только отдельной проверкой:
worker.join(5000);
if (worker.isAlive()) {
log.warn("не дождались, поток ещё работает");
worker.interrupt();
}
Прерывание потока: InterruptedException и флаг
Потоку нельзя приказать остановиться: Thread.stop() убран из Java именно потому, что обрывал поток посреди операции с половиной данных. Вместо приказа есть просьба: thread.interrupt() ставит потоку флаг прерывания, а что с ним делать, решает сам поток.
Флаг проверяют двумя способами. Блокирующие методы (sleep, wait, join, BlockingQueue.take, Future.get) проверяют его сами: если флаг стоит или его поставили во время ожидания, они бросают InterruptedException и сбрасывают флаг. Код без блокировок, длинный цикл, проверяет флаг вручную: Thread.currentThread().isInterrupted().
while (!Thread.currentThread().isInterrupted()) {
Order order = queue.take(); // бросит InterruptedException, если поток прервали в ожидании
process(order);
}
Самая частая ошибка выглядит безобидно: catch (InterruptedException e) {}. Исключение сбросило флаг, пустой блок проглотил просьбу, и поток, который просили остановиться, продолжает работать, будто ничего не было; пул при остановке будет ждать его вечно. Правильных ответов два. Либо выйти из метода, пробросив исключение выше, либо, если пробросить нельзя, восстановить флаг: Thread.currentThread().interrupt() в catch, чтобы следующий блокирующий вызов или проверка в цикле увидели просьбу. Эта строка встречается в статьях фазы десяток раз, и вот почему она там стоит.
Откуда прерывания приходят в реальном коде: Future.cancel(true) прерывает поток, который выполняет задачу, ExecutorService.shutdownNow() прерывает все потоки пула, а shutdown() не прерывает никого и лишь перестаёт принимать новые задачи. Поэтому задача в пуле обязана уважать прерывание: иначе shutdownNow не остановит ничего, и приложение будет висеть на выходе.
Потоки-демоны
Демон (daemon thread) — фоновый поток, который JVM не ждёт при завершении программы. Когда все не-демонные потоки завершились, JVM завершается сама, обрывая все демоны.
живой пример
public class DaemonDemo {
public static void main(String[] args) throws InterruptedException {
Thread cleaner = new Thread(() -> {
while (true) {
System.out.println("чищу кэш");
try {
Thread.sleep(150);
} catch (InterruptedException e) {
return;
}
}
});
cleaner.setDaemon(true); // установить ДО start()
cleaner.start();
Thread.sleep(400);
System.out.println("главный поток закончил — JVM выходит и обрывает демона");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Цикл печатает «чищу кэш» несколько раз, но как только main доходит до конца, JVM выходит и обрывает демона на середине. Без setDaemon(true) программа не закончилась бы никогда. Поэтому демонами делают сборку мусора, мониторинг, очистку кэша — то, что можно оборвать в любой момент; пользовательскую работу (HTTP-запросы, транзакции) держат в обычных потоках.
Состояния потока
У каждого потока есть жизненный цикл. Его можно получить через t.getState():
NEW— поток создан,start()ещё не вызван.RUNNABLE— выполняется или готов к выполнению (планировщик ОС решает, когда дать ему процессор).BLOCKED— поток стоит на входе вsynchronizedи ждёт, пока монитор отпустит кто-то другой. Других причин у этого состояния нет.WAITING— поток ждёт чужого действия без ограничения по времени:wait(),join()без тайм-аута,LockSupport.park().TIMED_WAITING— то же самое, но со сроком:sleep(...),wait(200),join(5000).TERMINATED—run()завершился.
Одна стрелка на схеме выглядит неожиданно: после notify() поток попадает не сразу в RUNNABLE, а в BLOCKED. Причина простая: чтобы уснуть на wait(), поток отпустил монитор, и продолжить работу он не может, пока не заберёт его обратно. А монитор в этот момент занят тем, кто как раз позвал notify(). Поэтому разбуженный поток ещё стоит в очереди за монитором. Это заметно при чтении дампа потоков: «разбуженный, но ждущий» выглядит именно как BLOCKED.
Стоимость потока ОС
Создание потока — недешёвая операция. Каждый поток ОС требует:
- собственного стека. JVM резервирует под него кусок адресного пространства — на 64-битной машине обычно 1 МБ, на macOS с ARM-процессором 2 МБ, — но настоящая память отдаётся страницами по мере того, как стек растёт, так что занято под тысячу простаивающих потоков намного меньше, чем гигабайт;
- системного вызова для создания потока: на обычной машине это несколько десятков микросекунд;
- затрат планировщика на переключение контекста.
На практике это означает: не создавайте потоки в цикле под каждый запрос. Для управления пулом потоков используют ExecutorService — об этом в статье про пулы потоков.
Поток vs задача
Поток — это механизм ОС: стек в сотни килобайт, системный вызов на создание, переключение контекста ядром. Задача (Runnable, Callable) — это то, что вы хотите выполнить: лямбда или класс, обычный объект в куче, который стоит столько же, сколько любой другой. Поэтому задачи дешёвые, а потоки дорогие, и хорошая практика — описывать задачи и передавать их пулу (ExecutorService), который переиспользует немногие потоки, а не управлять потоками вручную. Ручное управление остаётся для понимания основ и специфических случаев.
Виртуальные потоки (Java 21)
Java 21 принесла виртуальные потоки — легковесные потоки, которыми управляет JVM, а не ОС. Их можно создавать миллионами без риска исчерпать системные ресурсы:
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("виртуальный поток");
});
Виртуальные потоки идеальны для I/O-нагруженных задач. Для вычислений, которые нагружают процессор, по-прежнему берут обычные потоки, и причина в устройстве: виртуальный поток отцепляется от несущего только там, где сам решил подождать (запрос к сети, блокирующий вызов из библиотеки JDK). Счётный цикл ждать не собирается, планировщик снять его не может, и тысяча виртуальных потоков на восьми ядрах просто встанет в очередь к тем же восьми несущим — выигрыша нет, а накладные расходы есть. Это называют кооперативным планированием, подробности в статье про виртуальные потоки. Подробнее — в статье про виртуальные потоки.
Коротко
- Процесс — изолированная запущенная программа; поток — единица выполнения внутри процесса с общей памятью.
- Многопоточность нужна для отзывчивости (не ждать I/O) и загрузки всех ядер процессора.
start()создаёт новый поток,run()— обычный вызов метода в текущем;Runnableпредпочтительнее наследования отThread.join()блокирует вызывающий поток до завершения другого; демона JVM при выходе не ждёт.- Создание потока ОС дорого — стек, системный вызов, переключение контекста; под нагрузкой берут
ExecutorService. - Java 21 даёт виртуальные потоки — легковесную альтернативу для I/O-сценариев.
- Остановка потока это просьба:
interrupt()ставит флаг, блокирующие вызовы бросаютInterruptedException; вcatchлибо выйти, либо восстановить флаг, пустойcatchломаетshutdownNow. joinдаёт не только ожидание, но и видимость записей завершившегося потока;join(5000)не говорит, дождался он или истёк, — проверяютisAlive().- Исключение из
run()наружу не всплывает: поток умирает, стек печатает обработчик по умолчанию мимо логгера — ставятUncaughtExceptionHandler. sleepдержит захваченные мониторы; вычислительную нагрузку виртуальные потоки не ускоряют, потому что планирование кооперативное.
Что почитать дальше
- Модель памяти Java — как потоки видят изменения друг друга и что такое
happens-before. - Гонки данных — что происходит, когда два потока читают и пишут одну переменную без синхронизации.
- Пулы потоков: ExecutorService — как управлять задачами через пул вместо ручного создания потоков.
- Виртуальные потоки в Java 21 — чем они отличаются от потоков ОС и когда их берут.