← назад к разделу

Когда программа выполняется, операционная система даёт ей ресурсы: память, доступ к файлам, процессорное время. Чтобы делать несколько вещей одновременно, нужно понять, как устроены процессы и потоки.

Процесс и поток: в чём разница

Процесс — это запущенная программа. У каждого процесса своя изолированная память: один процесс не может случайно затронуть данные другого. Когда вы запускаете 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(). Код выглядит рабочим, ошибок нет, просто второго потока не появляется.

t.run() нового потока нет main run() main стоит, пока идёт работа t.start() работа уходит в свой поток main join() run() worker-1 работает, main не ждёт

Слева: 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, WAITING и TIMED_WAITING с возвратом обратно, а по завершении run() в TERMINATED

  • 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 держит захваченные мониторы; вычислительную нагрузку виртуальные потоки не ускоряют, потому что планирование кооперативное.

Что почитать дальше