Потоки в Java работают параллельно, и большую часть времени это хорошо. Но иногда они начинают мешать друг другу: ждут, топчутся на месте или один поток вечно остаётся без работы. Разберём три главных сценария.
Три сбоя, три разные картинки. В дедлоке стрелки замыкаются в круг и движение прекращается совсем. В лайвлоке потоки бегают, но каждый их шаг отменяет соседний. В голодании работа идёт, просто всегда чужая.
Deadlock — взаимная блокировка
Дедлок (deadlock, взаимная блокировка) — ситуация, когда два или больше потока ждут друг друга и никто из них не может продолжить работу. Программа не падает с исключением — она просто зависает.
Классический пример: поток A держит замок 1 и хочет замок 2, поток B держит замок 2 и хочет замок 1. Оба ждут — бесконечно. Программа ниже устраивает ровно это и спрашивает у JVM, что происходит: findDeadlockedThreads() возвращает потоки, замкнувшиеся в круг.
живой пример
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(() -> grabBoth(lock1, lock2, "A")); // A: сначала первый
Thread b = new Thread(() -> grabBoth(lock2, lock1, "B")); // B: сначала второй
a.setDaemon(true);
b.setDaemon(true);
a.start();
b.start();
Thread.sleep(500);
ThreadMXBean threads = ManagementFactory.getThreadMXBean();
long[] inCycle = threads.findDeadlockedThreads();
System.out.println("A: " + a.getState() + ", B: " + b.getState());
System.out.println(inCycle == null ? "дедлока нет" : "дедлок, потоков в круге: " + inCycle.length);
}
private static void grabBoth(Object first, Object second, String name) {
synchronized (first) {
try {
Thread.sleep(100); // даём соседу взять его замок
synchronized (second) {
System.out.println(name + " сделал работу");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатает A: BLOCKED, B: BLOCKED и дедлок, потоков в круге: 2. Строка «сделал работу» не выведется никогда. setDaemon(true) стоит здесь только затем, чтобы программа смогла завершиться: обычный поток, застрявший в дедлоке, держит JVM живой.
Четыре условия Коффмана
Дедлок возникает только при одновременном выполнении четырёх условий, которые в 1971 году сформулировал Эдвард Коффман:
- Взаимное исключение — ресурс может держать только один поток одновременно.
- Удержание и ожидание — поток держит уже захваченные ресурсы и ждёт новые.
- Отсутствие принудительного освобождения — ресурс нельзя отобрать у потока силой, только поток сам его отпускает.
- Циклическое ожидание — есть цепочка потоков, где каждый ждёт ресурс от следующего.
В программе выше выполнены все четыре: каждый замок держит только один поток, каждый поток держит свой замок и ждёт чужой, отобрать замок некому, и первый ждёт второго, а второй первого. Чтобы предотвратить дедлок, достаточно нарушить хотя бы одно из четырёх.
Как избежать дедлока
Способ 1 — фиксированный порядок захвата замков
Самый простой приём: захватывайте несколько замков всегда в одном порядке. Если оба потока сначала берут lock1, а потом lock2 — циклическое ожидание невозможно.
Было и стало на одной картинке: в разном порядке потоки замыкают круг, в едином порядке второй просто ждёт своей очереди и проходит следом.
живой пример
public class LockOrderDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
synchronized (lock1) { // сначала всегда первый
synchronized (lock2) { // потом всегда второй
System.out.println(Thread.currentThread().getName() + " прошёл оба замка");
}
}
};
Thread a = new Thread(job, "поток-A");
Thread b = new Thread(job, "поток-B");
a.start(); b.start();
a.join(); b.join();
System.out.println("оба закончили, круга ожидания не было");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Обе строки печатаются, программа завершается. Поменяйте у второго потока порядок на обратный — получится первый пример.
Здесь порядок виден прямо из кода: замков два, и у них есть имена. В жизни так почти не бывает — замки приходят аргументами:
void transfer(Account from, Account to, BigDecimal amount) {
synchronized (from) {
synchronized (to) { /* перевод */ }
}
}
Два перевода навстречу друг другу, с A на B и с B на A, — и вот он, тот же круг. Правило «всегда сначала первый» тут не записать: кто из них первый, выясняется только в момент вызова. Порядок задают сравнением: у каждого объекта берут стабильное число — идентификатор счёта, если он есть, или System.identityHashCode(obj), — и замки захватывают от меньшего к большему.
void transfer(Account from, Account to, BigDecimal amount) {
Account first = from.id() < to.id() ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
Остаётся редкий случай, когда числа совпали: identityHashCode у разных объектов повториться может. На него заводят третий замок, один на всё приложение: при равенстве сначала берут его, а уже под ним оба рабочих. Через такой замок проходят только совпадения, так что узким местом он не становится.
Способ 2 — tryLock с таймаутом
ReentrantLock позволяет попробовать захватить замок и отступить, если не получилось за отведённое время. Условие «удержание и ожидание» нарушает при этом не сам метод, а приём: не вышло взять второй замок — отпустить первый и начать всё заново. tryLock только даёт такую возможность, у lock() её нет, а работает приём за счёт finally в примере ниже.
живой пример
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
lock2.lock(); // второй замок занял главный поток
Thread worker = new Thread(() -> {
try {
for (int attempt = 1; attempt <= 5; attempt++) {
lock1.lock();
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
System.out.println("попытка " + attempt + ": оба замка мои");
return;
} finally {
lock2.unlock();
}
}
System.out.println("попытка " + attempt + ": второй занят, отпускаю первый");
} finally {
lock1.unlock(); // отпускаем в любом исходе
}
Thread.sleep(150); // и только потом пробуем снова
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
worker.start();
Thread.sleep(400);
lock2.unlock(); // освобождаем, пока сосед ещё пробует
worker.join();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Две первые попытки печатают «второй занят», третья — «оба замка мои». Важен тут не столько tryLock, сколько finally: первый замок отпускается в любом исходе, иначе неудачная попытка оставит его захваченным и превратит лечение в болезнь.
Дедлок без единого замка: пул потоков
Самый частый дедлок в сервисах устроен иначе, чем классический, и потому не находится ни findDeadlockedThreads(), ни блоком «Found one Java-level deadlock» в дампе. Замков в нём нет вовсе.
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<String> outer = pool.submit(() -> {
Future<String> inner = pool.submit(() -> "внутренняя задача");
return inner.get(); // ждём задачу, которой некуда встать
});
Две внешние задачи заняли оба потока пула и ждут результата внутренних. Внутренние стоят в очереди и не стартуют, пока не освободится поток. Поток не освободится, пока не дождётся внутренней. Круг замкнулся, но JVM его не видит: с её точки зрения два потока просто ждут на Future.get(), а это обычное ожидание, не захват монитора. В дампе будет WAITING (parking) и никаких указаний на дедлок — только по стеку с FutureTask.get внутри задачи пула можно догадаться, что происходит.
Правила против этого простые. Задача не ждёт результата другой задачи того же пула. Если зависимость по работе есть — либо цепочка без ожидания (thenCompose у CompletableFuture), либо разные пулы (внешние задачи в одном, внутренние в другом), либо структурированная конкурентность, где подзадачи выполняются в собственных виртуальных потоках. То же касается parallelStream внутри задачи, которая сама выполняется в общем ForkJoinPool: это та же конструкция, только замаскированная.
Потерянное пробуждение
Второе зависание без замков: поток ждёт на wait() сигнала, который уже был. Сценарий — notify() вместо notifyAll() при нескольких ожидающих: JVM будит одного, какого — не сказано, и разбуженным оказывается поток, чьё условие всё ещё не выполнено. Он проверяет while, засыпает обратно, а тот, кому сигнал предназначался, спит дальше — сигнал потрачен впустую и больше не повторится.
Выглядит это точно как дедлок: потоки в WAITING, прогресса нет, — но дедлоком не является, и findDeadlockedThreads() вернёт null. Три правила, которые его исключают: проверять условие в while, а не в if; звать notifyAll(), пока не доказано, что все ожидающие взаимозаменяемы; и менять само состояние под тем же монитором, на котором ждут, — иначе сигнал может уйти между проверкой условия и засыпанием. Надёжнее всего не писать это руками: BlockingQueue и Condition закрывают те же сценарии без потерянных пробуждений.
Проглоченное прерывание
Самый частый дефект многопоточного кода вообще пишется в две строки:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// тишина
}
InterruptedException при возбуждении сбрасывает флаг прерывания, и пустой catch окончательно стирает просьбу остановиться. Последствия видны не сразу: shutdownNow() у пула не останавливает такую задачу, приложение висит на выходе и в итоге получает SIGKILL от оркестратора, а отмена через Future.cancel(true) перестаёт работать. Правильных реакций две: пробросить исключение выше (если сигнатура позволяет) или восстановить флаг — Thread.currentThread().interrupt() — и выйти из метода. Подробный разбор прерываний — в статье про потоки.
Утечка потоков и ThreadLocal
Две течи, которые копятся месяцами. Первая: пул, который создали и не закрыли. Executors.newFixedThreadPool внутри метода, вызываемого на каждый запрос, оставляет после себя живые непотоки-демоны; через сутки в дампе их тысячи, память занята стеками, а jstack показывает сотни одинаковых pool-473-thread-1. Пул — это ресурс уровня приложения: его создают один раз (бином, статическим полем) и закрывают при остановке.
Вторая: ThreadLocal, не очищенный в переиспользуемом потоке. Поток пула живёт долго, значение, положенное одной задачей, достаётся следующей — чужой userId в логах, чужая роль в проверке прав, а если значение тяжёлое, то и растущая память. Лечение одно и всегда: set в начале задачи, remove в finally. Подробности — в статье про пулы.
Диагностика: thread dump
Когда приложение зависает и вы подозреваете дедлок — возьмите thread dump (снимок состояния всех потоков). JVM умеет его выдавать прямо во время работы.
jps -l # найти PID процесса Java
jstack <PID> # снять thread dump
jcmd <PID> Thread.print # то же самое, современный способ
jcmd <PID> Thread.dump_to_file -format=json threads.json # с виртуальными потоками
jcmd предпочтительнее: он один умеет всё, что раньше делали разные утилиты, и у него есть режим, без которого на Java 21 разбор бесполезен, — jcmd <PID> Thread.dump_to_file -format=json threads.json. Обычный jstack не показывает виртуальные потоки вовсе: их тысячи, и в классический дамп они не попадают. Если сервис работает на виртуальных потоках, а в дампе видно два десятка носителей и ничего больше, — это не значит, что всё спокойно.
И правило, которое экономит часы: снимайте дамп дважды с интервалом в несколько секунд. Один снимок не отличает зависший поток от занятого: и тот и другой стоят в одном стеке. Два снимка отвечают сразу: стек не изменился — поток стоит, изменился — работает, просто долго.
В выводе jstack ищите блок Found one Java-level deadlock: — JVM находит циклические ожидания сама и показывает, какой поток что держит и чего ждёт. Это тот же поиск, что делал findDeadlockedThreads() выше, — только снаружи процесса и без правки кода.
Found one Java-level deadlock:
=============================
"Thread-0": waiting to lock monitor 0x... which is held by "Thread-1"
"Thread-1": waiting to lock monitor 0x... which is held by "Thread-0"
У дедлока на ReentrantLock вид в дампе другой. Поток стоит не в BLOCKED, а в WAITING (parking), и вместо «waiting to lock monitor» строчка выглядит так:
"T-A":
waiting for ownable synchronizer 0x..., (a java.util.concurrent.locks.ReentrantLock$NonfairSync),
which is held by "T-B"
Заголовок Found one Java-level deadlock: при этом тот же — круг на явных замках JVM тоже находит. А вот по одному состоянию потока такой дедлок не опознать: WAITING одинаково выглядит и у застрявшего намертво, и у мирно спящего потока. Поэтому смотрят не на состояния, а на блок про дедлок.
Thread dump также показывает потоки, застрявшие в BLOCKED или WAITING дольше ожидаемого.
Здесь дамп нужен только ради блока про дедлок. Как снять его на проде и в Kubernetes, что значат состояния RUNNABLE, BLOCKED и WAITING на самом деле, как читать пулы потоков и почему виртуальных потоков в jstack нет — в отдельной статье Thread dump и heap dump.
Livelock — топтание на месте
Лайвлок (livelock) — потоки активны, не заблокированы, но не движутся вперёд. Они постоянно реагируют друг на друга и мешают друг другу — как два человека в узком коридоре, которые вежливо уступают дорогу одновременно и всё равно сталкиваются.
Пример: два потока используют tryLock и всегда отступают одновременно — каждый видит, что второй активен, и снова откладывает работу.
Лечение — добавить случайную задержку перед повторной попыткой, чтобы потоки разошлись по времени:
// Случайная пауза перед повторной попыткой
long pause = (long) (Math.random() * 50); // 0–50 мс
Thread.sleep(pause);
Случайная добавка к паузе называется джиттером (jitter): она разводит потоки, которые иначе снова столкнулись бы в тот же момент. Рядом почти всегда применяют экспоненциальный откат (exponential backoff): каждая следующая пауза длиннее предыдущей — 50 мс, 100, 200, 400. Растущая задержка плюс случайный разброс и есть привычный рецепт повторов в сетевых протоколах и распределённых системах.
У лайвлока есть и вторая природа, в сервисах даже более частая: не вежливый взаимный отход, а шторм повторов. Десяток потоков пытается обновить одну и ту же запись, каждый натыкается на конфликт, откатывается и пробует снова — процессор загружен под завязку, а полезных обновлений почти нет. Случайная пауза тут не лечит, а растягивает: столкновения просто размазываются по времени. Помогает другое — ограничить число одновременных попыток (очередь или семафор) или развести конфликтующие операции так, чтобы они не пересекались: по ключу, по разделу данных, по одной за раз.
Starvation — голодание потока
Голодание (starvation) — один или несколько потоков постоянно не получают процессорное время или доступ к ресурсу, потому что другие потоки всегда оказываются приоритетнее.
Типичная причина — поток, который раз за разом проигрывает конкуренцию за замок: нечестный ReentrantLock отдаёт замок тому, кто подошёл в удачный момент, а не тому, кто ждёт дольше всех. Приоритеты потоков (setPriority) в этом списке почти не участвуют, хотя их называют первыми: на деле они решают мало, а на Linux по умолчанию попросту игнорируются. Голодающий поток технически не заблокирован — просто его задача никогда не доходит до выполнения.
ReentrantLock позволяет включить справедливый режим (fair mode): замок отдаётся тому, кто ждал дольше всего.
// true = справедливый режим, потоки получают замок по очереди
ReentrantLock fairLock = new ReentrantLock(true);
Справедливый режим снижает производительность из-за накладных расходов на очередь — включайте его там, где голодание действительно проблема, а не предположение.
Как три проблемы отличаются друг от друга
| Потоки активны? | Прогресс есть? | |
|---|---|---|
| Deadlock | Нет (BLOCKED или WAITING) | Нет |
| Livelock | Да | Нет |
| Starvation | Один да, другой нет | У одного да, у другого нет |
Коротко
- Дедлок — потоки ждут друг друга по кругу и не двигаются. Возникает при четырёх условиях Коффмана.
- Главная защита от дедлока — единый порядок захвата замков или
tryLockс таймаутом. Держите замок недолго и не зовите внутриsynchronizedчужой код: он может взять ещё один замок. - Лайвлок — потоки активны, но мешают друг другу. Лечится случайной паузой перед повторной попыткой.
- Голодание — один поток вечно проигрывает в конкуренции за ресурс.
new ReentrantLock(true)выравнивает шансы. - Thread dump (
jstack) — главный инструмент диагностики: JVM сама находит дедлоки и показывает, кто что держит. - Самый частый дедлок в сервисах — без замков: задача ждёт
Futureдругой задачи того же пула; JVM его не находит, признак —FutureTask.getв стеке задачи пула. - Потерянное пробуждение (
notifyвместоnotifyAll) выглядит как дедлок, но им не является: условие проверяют вwhile, меняют под тем же монитором, а лучше берутBlockingQueue. - Проглоченный
InterruptedExceptionломает отмену и остановку приложения: пробросить или восстановить флаг. Незакрытый пул и неочищенныйThreadLocal— две медленные течи. - Дамп снимают
jcmd Thread.print, а на виртуальных потоках толькоThread.dump_to_file -format=json; и всегда дважды с интервалом, иначе «завис» не отличить от «долго работает».
Что почитать дальше
- synchronized и monitor в Java — как работает ключевое слово
synchronized, монитор объекта, входные условия для дедлока. - ReentrantLock и другие замки —
tryLock,lockInterruptibly,Conditionи когда замки лучшеsynchronized. - Гонки в многопоточных программах — вторая половина проблем с потоками: испорченные данные вместо зависания.
- ExecutorService и пул потоков — как управлять жизненным циклом потоков и не создавать их вручную.