Когда программа выполняется, операционная система даёт ей ресурсы: память, доступ к файлам, процессорное время. Чтобы делать несколько вещей одновременно, нужно понять, как устроены процессы и потоки.
Процесс и поток: в чём разница
Процесс — это запущенная программа. У каждого процесса своя изолированная память: один процесс не может случайно затронуть данные другого. Когда вы запускаете 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():
NEW— поток создан,start()ещё не вызван.RUNNABLE— выполняется или готов к выполнению (планировщик ОС решает, когда дать ему процессор).BLOCKED/WAITING/TIMED_WAITING— поток ждёт чего-то (монитора, уведомления, таймаута).TERMINATED—run()завершился.
Стоимость потока ОС
Создание потока — недешёвая операция. Каждый поток ОС требует:
- выделения стека (обычно 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вместо ручного создания потоков.