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

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

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

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

Runnable предпочтителен: класс не «тратит» единственное наследование на Thread, а задача остаётся отделена от механизма её запуска.

start() против run(): распространённая ошибка

Это один из самых частых промахов у новичков.

Thread t = new Thread(() -> System.out.println("привет"));

t.run();   // НЕПРАВИЛЬНО — выполняется в ТЕКУЩЕМ потоке, не в новом
t.start(); // ПРАВИЛЬНО   — JVM создаёт новый поток и вызывает run() в нём

Вызов run() напрямую — это обычный вызов метода. Никакого нового потока не создаётся. start() просит JVM создать системный поток и вызвать run() в нём.

Ожидание завершения: join()

Если главный поток должен дождаться результата другого потока, используется join():

Thread t = new Thread(() -> {
    // долгая работа
    computeResult();
});
t.start();

// главный поток продолжает что-то делать...
doOtherWork();

t.join(); // ждём, пока поток t завершится
System.out.println("готово"); // выполнится только после завершения t

join() блокирует вызывающий поток до тех пор, пока указанный поток не завершит выполнение. Можно передать тайм-аут: t.join(5000) — подождать не более 5 секунд.

Потоки-демоны

Демон (daemon thread) — фоновый поток, который JVM не ждёт при завершении программы. Когда все не-демонные потоки завершились, JVM завершается сама, обрывая все демоны.

Thread daemon = new Thread(() -> {
    while (true) {
        cleanupExpiredCache(); // фоновая задача
        Thread.sleep(60_000);
    }
});
daemon.setDaemon(true); // установить ДО start()
daemon.start();

Типичный пример демона — сборщик мусора, поток мониторинга, периодическая очистка кэша. Обычные потоки (не-демоны) — это пользовательская работа: HTTP-запросы, транзакции.

Состояния потока

У каждого потока есть жизненный цикл. Его можно получить через t.getState():

diagram
  • NEW — поток создан, start() ещё не вызван.
  • RUNNABLE — выполняется или готов к выполнению (планировщик ОС решает, когда дать ему процессор).
  • BLOCKED / WAITING / TIMED_WAITING — поток ждёт чего-то (монитора, уведомления, таймаута).
  • TERMINATEDrun() завершился.

Стоимость потока ОС

Создание потока — недешёвая операция. Каждый поток ОС требует:

  • выделения стека (обычно 512 КБ — 1 МБ по умолчанию);
  • системного вызова для создания потока;
  • затрат планировщика на переключение контекста.

На практике это означает: не создавайте потоки в цикле под каждый запрос. Для управления пулом потоков используют ExecutorService — об этом в статье про исполнители.

Поток vs задача

Поток — это механизм ОС. Задача (Runnable, Callable) — это то, что вы хотите выполнить. Это разные вещи, и смешивать их не стоит.

ПотокЗадача
Что этоРесурс ОСЛогика работы
Созданиеnew Thread(...)лямбда, класс
Кто управляетJVM + ОСпрограммист / пул
Стоимостьвысокаянизкая

Хорошая практика — описывать задачи (Runnable / Callable) и передавать их пулу (ExecutorService), а не управлять потоками вручную. Ручное управление потоками остаётся актуальным только для понимания основ и специфических случаев.

Виртуальные потоки (Java 21)

Java 21 принесла виртуальные потоки — легковесные потоки, которыми управляет JVM, а не ОС. Их можно создавать миллионами без риска исчерпать системные ресурсы:

Thread vt = Thread.ofVirtual().start(() -> {
    System.out.println("виртуальный поток");
});

Виртуальные потоки идеальны для I/O-нагруженных задач. Для CPU-интенсивных вычислений по-прежнему используют обычные потоки. Подробнее — в статье про виртуальные потоки.

Коротко

  • Процесс — изолированная запущенная программа; поток — единица выполнения внутри процесса с общей памятью.
  • Многопоточность нужна для отзывчивости (не ждать I/O) и загрузки всех ядер процессора.
  • Runnable с new Thread(task) предпочтительнее наследования от Thread.
  • start() создаёт новый поток; run() — просто вызов метода в текущем потоке.
  • join() блокирует вызывающий поток до завершения другого.
  • Демон-поток не удерживает JVM от завершения.
  • Создание потока ОС дорого; под реальную нагрузку используйте ExecutorService.
  • Java 21 даёт виртуальные потоки — легковесную альтернативу для I/O-сценариев.

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

  • Модель памяти Java — как потоки видят изменения друг друга и что такое happens-before.
  • Гонки данных — что происходит, когда два потока читают и пишут одну переменную без синхронизации.
  • Исполнители и пулы потоков — как управлять задачами через ExecutorService вместо ручного создания потоков.