Java Concurrency: как работают потоки в Java и как ими управлять на практике

Java Concurrency: как работают потоки в Java и как ими управлять на практике Полезное

Поток (thread) — независимая последовательность выполнения кода внутри процесса Java-приложения, способная работать параллельно с другими потоками того же процесса. Разберем: чем поток отличается от процесса, из каких состояний состоит его жизненный цикл, тремя способами его создают, зачем нужна синхронизация и как правильно останавливать поток через interrupt — и что изменилось с приходом виртуальных потоков в Java 21.

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

ОС запускает JVM как отдельный процесс со своим адресным пространством. Потоки внутри него работают в рамках памяти этого процесса, а не получают собственную копию.

Критерий Процесс Поток
Память Свое адресное пространство Общая память процесса: куча, статические поля
Создание Дороже — ОС выделяет отдельные ресурсы Дешевле — использует ресурсы своего процесса
Обмен данными Через межпроцессное взаимодействие (сокеты, файлы) Напрямую через общие переменные и объекты
Сбой Падение одного процесса не задевает другие Необработанное исключение обычно завершает только этот поток

Граница последней строки: если необработанное исключение вылетает из главного потока (main) и других non-daemon потоков не осталось, JVM-процесс завершается. В дочернем потоке необработанное исключение по умолчанию завершает только его.

Жизненный цикл потока: 6 состояний Thread.State

В Java состояние потока описывает перечисление Thread.State (метод getState()), и в нем ровно шесть значений:

Состояние Когда наступает
NEW Объект создан, start() еще не вызван
RUNNABLE После start(): выполняется либо ждет кванта времени от планировщика ОС
BLOCKED Ждет освобождения монитора synchronized, занятого другим потоком
WAITING Ждет неограниченно долго: wait()/join() без таймаута, LockSupport.park()
TIMED_WAITING То же, но с ограничением по времени: sleep(long), wait(long), join(long)
TERMINATED run() завершился — обычно или через исключение

suspend(), resume() и stop() в этой модели не участвуют. Все три deprecated с JDK 1.2: stop() останавливал поток немедленно и мог оставить объект в неконсистентном состоянии, suspend() легко приводил к дедлоку, если поток держал монитор. С JDK 20 stop() дополнительно ре-специфицирован и безусловно бросает UnsupportedOperationException — то есть на актуальных LTS-версиях (21, 25) он уже не останавливает поток, а гарантированно падает с исключением. Правильный способ остановить поток — кооперативный, через interrupt() (ниже).

Три способа создать поток

Наследование Thread

public class ThreadClassDemo {
    static class Worker extends Thread {
        Worker(String name) {
            super(name);
        }

        @Override
        public void run() {
            System.out.println("Поток " + getName() + " выполняет задачу");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Worker worker = new Worker("worker-1");
        worker.start();
        worker.join();
        System.out.println("Главный поток завершен");
    }
}

Результат:

Поток worker-1 выполняет задачу
Главный поток завершен

Порядок строк гарантирован вызовом join(): он заставляет главный поток дождаться worker. Без join() порядок вывода между независимыми потоками не гарантируется.

Реализация Runnable

Способ предпочтительнее наследования: Java допускает только одиночное наследование класса, а Runnable оставляет extends свободным для другой иерархии.

public class RunnableDemo {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> System.out.println(
                "Поток " + Thread.currentThread().getName() + " выполняет задачу");
        Thread worker = new Thread(task, "worker-2");
        worker.start();
        worker.join();
        System.out.println("Главный поток завершен");
    }
}

Результат:

Поток worker-2 выполняет задачу
Главный поток завершен

Callable + ExecutorService, когда нужен результат

У Runnable.run() тип возврата void, и он не бросает проверяемых исключений. Если из потока нужно получить значение или допустимо checked-исключение, используют Callable<V> с пулом ExecutorService.

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class CallableDemo {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        Callable<Integer> task = () -> {
            Thread.sleep(50);
            return 6 * 7;
        };

        Future<Integer> future = executor.submit(task);
        System.out.println("Ждем результат вычисления...");
        Integer result = future.get();
        System.out.println("Результат: " + result);

        executor.shutdown();
    }
}

Результат:

Ждем результат вычисления...
Результат: 42

future.get() блокирует вызывающий поток до готовности результата, поэтому вывод детерминирован.

Синхронизация: как избежать состояния гонки

Состояние гонки возникает, когда несколько потоков без координации читают и изменяют одни и те же данные. Разберем на счетчике.

Неверный вариант:

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
public class RaceDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Итог: " + counter.getCount());
    }
}

Ожидаем 200000. Но count++ — три отдельные операции (чтение, инкремент, запись), и без синхронизации потоки перезаписывают результат друг друга. Фактический итог меньше ожидаемого и меняется от запуска к запуску; иногда счетчик по совпадению доходит и до 200000, но это не доказывает корректность кода.

Исправление — пометить методы synchronized:

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

Результат:

Итог: 200000

Теперь результат одинаков на каждом запуске: synchronized гарантирует, что increment() выполняет только один поток одновременно — он держит монитор объекта counter, остальные ждут в состоянии BLOCKED. Граница: блокируются только другие синхронизированные методы/блоки на ТОМ ЖЕ объекте-мониторе — несинхронизированный метод того же объекта выполнится параллельно.

Как останавливать поток: interrupt()

Способ показать потоку, что пора завершиться, — установить флаг прерывания через interrupt(). Поведение дальше зависит от того, что делает поток в этот момент.

Если поток работает в цикле без блокирующих вызовов, он должен сам проверять флаг:

public class InterruptLoopDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                // полезная работа без блокирующих вызовов
            }
            System.out.println("Поток остановлен по флагу прерывания");
        });

        worker.start();
        Thread.sleep(50);
        worker.interrupt();
        worker.join();
        System.out.println("Главный поток завершен");
    }
}

Результат:

Поток остановлен по флагу прерывания
Главный поток завершен

Если поток стоит в блокирующем вызове (sleep, wait, join), тот сразу бросает InterruptedException, а флаг прерывания при этом сбрасывается в false:

public class InterruptSleepDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            try {
                Thread.sleep(10_000);
            } catch (InterruptedException e) {
                System.out.println("Поток прерван во время sleep()");
            }
        });

        worker.start();
        Thread.sleep(50);
        worker.interrupt();
        worker.join();
        System.out.println("Главный поток завершен");
    }
}

Результат:

Поток прерван во время sleep()
Главный поток завершен

Легко перепутать два похожих метода: isInterrupted() (метод экземпляра) только читает флаг; Thread.interrupted() (статический) читает флаг И сбрасывает его в false. Путаница — частый источник багов, когда цикл проверки перестает видеть прерывание.

Пакет java.util.concurrent: что внутри

Ручное управление потоками через new Thread() плохо масштабируется, поэтому на практике используют пакет java.util.concurrent:

  • Executor framework (ExecutorService, Executors, ScheduledExecutorService) — пулы потоков вместо создания потока на каждую задачу.
  • Concurrent-коллекции (ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue) — потокобезопасны без блокировки всей структуры целиком.
  • Синхронизаторы (CountDownLatch, CyclicBarrier, Semaphore) — координация группы потоков между собой.
  • Блокирующие очереди (BlockingQueue, ArrayBlockingQueue, LinkedBlockingQueue) — основа схемы producer-consumer.
  • Locks (ReentrantLock, ReadWriteLock из java.util.concurrent.locks) — гибче synchronized: явные lock()/unlock(), tryLock() с таймаутом.
  • Atomic-классы (AtomicInteger, AtomicLong, AtomicReference) — атомарные операции поверх compare-and-swap без блокировки.

Atomic хорошо решает гонку для одного счетчика, но не заменяет Lock/synchronized, если нужно согласованно менять сразу несколько полей объекта.

Виртуальные потоки в Java 21+: что изменилось к 2026 году

Java 21 (LTS, сентябрь 2023) добавила виртуальные потоки (JEP 444) — легковесные потоки под управлением JVM, а не ОС; создаются через Thread.ofVirtual(). Тысячи виртуальных потоков обслуживает пул carrier-потоков ОС: по умолчанию его размер равен числу доступных ядер CPU (настраивается свойством jdk.virtualThreadScheduler.parallelism), а не фиксированным «один-два» вне зависимости от железа. API тот же Thread (те же состояния, interrupt(), join()), отличие — в цене создания и планировании: годятся для массовой конкурентности в I/O, а не для ускорения CPU-вычислений. В Java 21-23 виртуальный поток дополнительно «пришпиливался» к своему carrier на время выполнения synchronized-блока/метода; с JDK 24 (JEP 491, релиз вне LTS-линейки, март 2025) это ограничение сняли. Вторая действующая LTS-линейка после 21 — Java 25 (вышла 16 сентября 2025); на дату публикации актуальны обе.

Выводы

  • Поток — единица выполнения внутри процесса; потоки одного процесса делят его память (кучу, статические поля), в отличие от процессов.
  • У потока 6 состояний по Thread.State: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED.
  • Три способа создать поток: extends Thread, implements Runnable (предпочтительно), Callable + ExecutorService (когда нужен результат или проверяемое исключение).
  • Без синхронизации совместный доступ к изменяемым данным дает состояние гонки; synchronized, Lock и Atomic-классы решают ее на разной глубине гибкости.
  • Останавливать поток нужно кооперативно через interrupt() и проверку флага, а не через deprecated Thread.stop().

Где применяется / связь с практикой

Освойте тему на практике

Тема потоков и Concurrency — частый блок на технических собеседованиях Java-разработчика, а на практике она нужна везде, где приложение обслуживает несколько запросов одновременно: веб-бэкенды, очереди сообщений, параллельная агрегация данных. Разобрать механику потоков, пулы ExecutorService и актуальные подходы, включая виртуальные потоки, можно на курсе Java Developer. Basic — там многопоточность разбирают на практических задачах с разбором типичных ошибок.

Освойте тему на практике

По темам многопоточности и параллельного программирования регулярно проходят открытые уроки — можно оценить формат занятий еще до записи на курс.

Смежные темы: События в Java

FAQ

Чем классический поток отличается от виртуального с точки зрения ОС?
Классический поток (platform thread) — обертка над потоком ОС: создание и переключение контекста дороже, число таких потоков ограничено тысячами. Виртуальный поток — объект JVM, и тысячи таких объектов может обслуживать относительно небольшой пул carrier-потоков ОС (по умолчанию размером с число ядер CPU).

Может ли поток создать другой поток?
Да, любой поток может внутри своего run() или call() создать и запустить новый Thread. Иерархии родитель-потомок здесь нет: все потоки одного процесса равноправны и делят его память.

Что будет, если вызвать start() у одного и того же объекта Thread дважды?
Второй вызов бросит IllegalThreadStateException: поток уже вышел из состояния NEW, повторно запустить тот же объект нельзя — нужен новый экземпляр.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)