Представим обычную серию «Ну, погоди!». Волк строит очередной план, Заяц куда-то бежит, а вокруг параллельно происходят десятки других событий: едет транспорт, играет музыка, кто-то катается на коньках и обязательно что-нибудь падает.
Если бы всё это было Java-приложением, Волк и Заяц вполне могли бы работать в разных потоках. Пока Заяц убегает, Волк не обязан стоять на месте и ждать — он может одновременно готовить ловушку.
Правда, слово «одновременно» здесь немного коварное. На многоядерном процессоре потоки действительно могут выполняться параллельно. На одном ядре процессор будет быстро переключаться между ними, создавая впечатление одновременной работы.
Но сначала разберёмся, где вообще живут эти потоки.
Процесс и поток
Когда мы запускаем Java-приложение, операционная система создаёт для него отдельный процесс. Внутри этого процесса запускается JVM, загружаются классы, выделяется память и начинается выполнение программы.
Процесс можно представить как отдельную съёмочную площадку. У неё есть свои декорации, оборудование, реквизит и сотрудники. Другая съёмочная площадка не может просто так забрать её камеру или переставить декорации.
У процесса есть собственное адресное пространство и набор ресурсов:
- память;
- открытые файлы;
- сетевые соединения;
- системные дескрипторы;
- один или несколько потоков.
Поток — это уже конкретный участник происходящего внутри процесса.
Например, в нашем приложении один поток управляет Волком, другой — Зайцем:
Java-процесс
├── поток main
├── поток wolf-thread
└── поток hare-thread
Все эти потоки находятся внутри одного процесса и разделяют его общую память — прежде всего кучу. Поэтому Волк и Заяц могут видеть один и тот же объект Carrot.
При этом у каждого потока есть собственный стек вызовов. В нём хранятся текущие вызовы методов, их параметры и локальные переменные.
Получается удобная, но потенциально опасная конструкция: потоки могут быстро обмениваться данными через общую память, однако могут одновременно попытаться изменить один и тот же объект. Отсюда появляются гонки данных, проблемы видимости и вся остальная многопоточная радость.
До неё мы ещё доберёмся. Пока достаточно запомнить: процесс изолирован от других процессов, а потоки внутри одного процесса память разделяют.
Выпускаем Волка в отдельный поток
В Java поток представлен классом Thread. Самый прямолинейный способ создать собственный поток — унаследоваться от этого класса и переопределить метод run().
public class WolfThread extends Thread {
@Override
public void run() {
System.out.println("Волк отправился ловить Зайца");
}
public static void main(String[] args) {
WolfThread wolf = new WolfThread();
wolf.start();
}
}
Метод run() содержит работу, которую должен выполнить поток. Однако запускается новый поток не вызовом run(), а вызовом start().
Наследование от Thread часто показывают первым, потому что этот пример легко объяснить. В обычном коде мне такой вариант нравится меньше: задача оказывается намертво связана с самим потоком.
Волк у нас не обязан быть потоком. Волк — это задача. Сегодня он выполняется в отдельном Thread, завтра — в пуле потоков, а послезавтра его вообще запустят как виртуальный поток.
Поэтому чаще используют Runnable.
public class CatchTheHareTask implements Runnable {
@Override
public void run() {
System.out.println("Волк снова пытается поймать Зайца");
}
public static void main(String[] args) {
Runnable wolfTask = new CatchTheHareTask();
Thread wolfThread = new Thread(wolfTask, "wolf-thread");
wolfThread.start();
}
}
Здесь отдельно существует задача:
Runnable wolfTask
и отдельно поток, который её выполняет:
Thread wolfThread
Разница может показаться чисто косметической, пока примеры состоят из одного println. В реальном приложении она быстро становится полезной: одну и ту же задачу можно передавать разным исполнителям, тестировать отдельно и не привязывать к конкретному способу запуска.
Создавать много потоков через new Thread() в рабочем приложении обычно не стоит. Для этого существуют ExecutorService, пулы потоков и другие инструменты из java.util.concurrent. Но начинать всё равно приходится с обычного Thread, иначе дальше будет непонятно, чем именно управляют более высокоуровневые конструкции.
start() и run() выглядят похоже, но делают разное
Допустим, Заяц должен убегать в отдельном потоке:
Thread hareThread = new Thread(
() -> System.out.println(
"Заяц бежит в потоке " + Thread.currentThread().getName()
),
"hare-thread"
);
Правильный запуск выглядит так:
hareThread.start();
После вызова start() JVM создаёт новый путь выполнения. Метод run() будет вызван внутри потока hare-thread.
А теперь вызовем его напрямую:
hareThread.run();
Никакого нового потока здесь не появится. Это будет самый обычный вызов метода в текущем потоке.
Проверим:
public class StartAndRunExample {
public static void main(String[] args) {
Runnable hareTask = () -> System.out.println(
"Заяц находится в потоке "
+ Thread.currentThread().getName()
);
Thread firstHare = new Thread(hareTask, "hare-1");
firstHare.start();
Thread secondHare = new Thread(hareTask, "hare-2");
secondHare.run();
}
}
Результат будет примерно таким:
Заяц находится в потоке main
Заяц находится в потоке hare-1
Порядок строк может поменяться, но суть останется той же:
firstHare.start()запускает код в новом потокеhare-1;secondHare.run()выполняет код в уже существующем потокеmain.
Это одна из тех ошибок, которые легко не заметить. Код работает, исключений нет, сообщения печатаются — только никакой многопоточности на самом деле не появилось.
Есть ещё один момент: объект Thread можно запустить только один раз.
Thread wolf = new Thread(() ->
System.out.println("Волк побежал")
);
wolf.start();
wolf.start();
Второй вызов закончится IllegalThreadStateException. Повторно использовать можно задачу, но не уже запущенный объект Thread.
Кто первым добежит до морковки
Создадим два потока:
public class RaceExample {
public static void main(String[] args) {
Thread wolf = new Thread(
() -> System.out.println("Волк добежал"),
"wolf-thread"
);
Thread hare = new Thread(
() -> System.out.println("Заяц добежал"),
"hare-thread"
);
wolf.start();
hare.start();
}
}
Мы сначала запустили Волка, поэтому может показаться, что строка про него обязательно появится первой.
Но нет.
Сегодня вывод будет таким:
Волк добежал
Заяц добежал
После следующего запуска:
Заяц добежал
Волк добежал
А иногда Волк действительно будет первым несколько десятков запусков подряд, после чего порядок внезапно поменяется именно тогда, когда кто-нибудь решит, что он уже «стабильный».
Вызов start() сообщает, что поток готов к выполнению. Он не означает: «немедленно остановить всё остальное и выполнить этот поток до конца».
Дальше в дело вступает планировщик. Для обычных платформенных потоков значительная часть планирования выполняется операционной системой. Она учитывает доступные ядра, нагрузку и множество других факторов, на которые Java-программа напрямую не влияет.
У потоков есть приоритеты:
wolf.setPriority(Thread.MAX_PRIORITY);
hare.setPriority(Thread.MIN_PRIORITY);
Но использовать их для построения порядка выполнения — плохая идея. Приоритет является скорее подсказкой планировщику, а не гарантией.
Если программе нужно, чтобы Волк обязательно начал действовать после Зайца, это должно быть явно описано средствами синхронизации. Надеяться на удачный порядок запуска нельзя.
interrupt() — это просьба, а не выстрел из ружья
С прерыванием потоков часто возникает неправильная картинка: будто вызов interrupt() мгновенно останавливает поток в любой точке.
На самом деле Java действует мягче.
wolfThread.interrupt();
Этот вызов сообщает потоку: «тебя хотят прервать». Что делать с этим сообщением, решает код самого потока.
Например, Волк может периодически проверять флаг прерывания:
public class WolfChase {
public static void main(String[] args)
throws InterruptedException {
Thread wolf = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("Волк продолжает погоню");
}
System.out.println("Волк прекратил погоню");
}, "wolf-thread");
wolf.start();
Thread.sleep(10);
wolf.interrupt();
}
}
Метод isInterrupted() проверяет флаг, но не сбрасывает его.
Есть похожий статический метод:
Thread.interrupted();
Он работает только с текущим потоком и после проверки сбрасывает флаг. Из-за похожих названий эти два метода легко перепутать.
Сложнее становится, когда поток не выполняет активную работу, а спит или ждёт другой поток.
try {
Thread.sleep(5_000);
} catch (InterruptedException exception) {
// Поток разбудили прерыванием
}
Если поток находится в sleep(), join() или wait(), прерывание приводит к InterruptedException. При этом флаг прерывания сбрасывается.
Поэтому пустой catch обычно скрывает важную информацию:
try {
Thread.sleep(5_000);
} catch (InterruptedException exception) {
// Ничего не делаем
}
Волка попросили прекратить погоню, исключение поймали и выбросили информацию в мусор. После этого он спокойно продолжит работу.
Если метод не умеет нормально обработать прерывание, распространённый вариант — восстановить флаг и завершить текущую операцию:
try {
Thread.sleep(5_000);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return;
}
Иногда InterruptedException лучше просто пробросить выше. Всё зависит от того, на каком уровне программы принимается решение о завершении задачи.
Главная мысль здесь простая: interrupt() не убивает поток. Он запускает механизм кооперативной остановки.
sleep(): Волк решил немного отдохнуть
Метод Thread.sleep() приостанавливает текущий поток на указанный период.
Thread.sleep(1_000);
Число передаётся в миллисекундах, поэтому здесь поток спит примерно одну секунду.
Именно примерно. Через секунду поток становится готов продолжить выполнение, но это не означает, что процессор будет немедленно ему отдан.
Небольшой пример:
public class WolfPlans {
public static void main(String[] args)
throws InterruptedException {
String[] plans = {
"Поставить ловушку",
"Надеть маскировку",
"Догнать Зайца",
"Снова упасть"
};
for (String plan : plans) {
System.out.println(plan);
Thread.sleep(1_000);
}
}
}
Здесь sleep() подходит: мы просто хотим сделать видимую паузу между сообщениями.
А вот такой код подозрителен:
hare.start();
Thread.sleep(1_000);
// Наверное, Заяц уже закончил
System.out.println("Можно продолжать");
«Наверное» — плохое основание для синхронизации. На быстром компьютере секунды окажется слишком много, на загруженном сервере её может не хватить.
Кроме того, sleep() не освобождает уже захваченные мониторные блокировки. Если поток уснул внутри блока synchronized, другие потоки не получат эту блокировку только потому, что текущий решил отдохнуть.
join(): Волк ждёт, пока Заяц закончит
Когда одному потоку действительно нужно дождаться другого, для этого есть метод join().
public class JoinExample {
public static void main(String[] args)
throws InterruptedException {
Thread hare = new Thread(() -> {
System.out.println("Заяц отправился за морковкой");
try {
Thread.sleep(2_000);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return;
}
System.out.println("Заяц вернулся");
}, "hare-thread");
hare.start();
System.out.println("Волк ждёт");
hare.join();
System.out.println("Волк продолжает действовать");
}
}
После вызова:
hare.join();
текущий поток, в данном случае main, остановится и будет ждать завершения hare.
Здесь уже нет предположения, что двух секунд «скорее всего достаточно». Выполнение продолжится только после завершения нужного потока.
Можно ограничить время ожидания:
hare.join(1_000);
Теперь текущий поток будет ждать максимум одну секунду. Если Заяц не закончит за это время, программа всё равно продолжит работу.
join(), как и sleep(), реагирует на прерывание и может выбросить InterruptedException.
Где всё это используется в настоящем приложении
В обычном Spring-приложении мы редко пишем:
new Thread(task).start();
Потоки там уже существуют и управляются инфраструктурой:
- сервер обрабатывает HTTP-запросы;
- планировщик запускает задачи;
- Kafka consumer читает сообщения;
- пул соединений работает с базой;
ExecutorServiceвыполняет переданные задачи;- реактивный код переключается между планировщиками.
Но низкоуровневые правила никуда не исчезают.
Если перепутать start() и run(), задача останется в текущем потоке. Если проигнорировать прерывание, сервис может плохо завершаться. Если рассчитывать на порядок выполнения, который никто не гарантировал, ошибка будет появляться редко и в самые неудобные моменты.
Процесс — это среда, в которой работает JVM. Потоки — отдельные пути выполнения внутри неё. Они используют общую память, могут выполняться в неожиданном порядке и требуют явного управления там, где действия зависят друг от друга.
А дальше начинается самое интересное: что произойдёт, если Волк и Заяц одновременно доберутся до одной морковки и оба попробуют изменить её состояние. Это уже тема состояния гонки и синхронизации.

