Потоки в Java работают параллельно, и большую часть времени это хорошо. Но иногда они начинают мешать друг другу: ждут, топчутся на месте или один поток вечно остаётся без работы. Разберём три главных сценария.
Deadlock — взаимная блокировка
Дедлок (deadlock, взаимная блокировка) — ситуация, когда два или больше потока ждут друг друга и никто из них не может продолжить работу. Программа не падает с исключением — она просто зависает.
Классический пример: поток A держит замок 1 и хочет замок 2, поток B держит замок 2 и хочет замок 1. Оба ждут — бесконечно.
Object замок1 = new Object();
Object замок2 = new Object();
Thread потокA = new Thread(() -> {
synchronized (замок1) {
// Поток A захватил замок1, теперь хочет замок2
synchronized (замок2) {
System.out.println("Поток A работает");
}
}
});
Thread потокB = new Thread(() -> {
synchronized (замок2) {
// Поток B захватил замок2, теперь хочет замок1
synchronized (замок1) {
System.out.println("Поток B работает");
}
}
});
потокA.start();
потокB.start();
// Оба потока будут ждать вечно
Четыре условия Коффмана
Дедлок возникает только при одновременном выполнении четырёх условий, которые в 1971 году сформулировал Эдвард Коффман:
- Взаимное исключение — ресурс может держать только один поток одновременно.
- Удержание и ожидание — поток держит уже захваченные ресурсы и ждёт новые.
- Отсутствие принудительного освобождения — ресурс нельзя отобрать у потока силой, только поток сам его отпускает.
- Циклическое ожидание — есть цепочка потоков, где каждый ждёт ресурс от следующего.
Короткая формула: дедлок = все четыре условия одновременно.
Чтобы предотвратить дедлок, достаточно нарушить хотя бы одно из них.
Как избежать дедлока
Способ 1 — фиксированный порядок захвата замков
Самый простой приём: всегда захватывайте несколько замков в одном и том же порядке. Если оба потока сначала берут замок1, а потом замок2 — циклическое ожидание невозможно.
// Оба потока захватывают замки в одном порядке: замок1 → замок2
Thread потокA = new Thread(() -> {
synchronized (замок1) {
synchronized (замок2) {
System.out.println("Поток A");
}
}
});
Thread потокB = new Thread(() -> {
synchronized (замок1) { // тоже начинаем с замок1
synchronized (замок2) {
System.out.println("Поток B");
}
}
});
Способ 2 — tryLock с таймаутом
ReentrantLock позволяет попробовать захватить замок и отступить, если не получилось за отведённое время. Это нарушает условие «удержание и ожидание»: поток отпускает то, что держит, и повторяет попытку позже.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
ReentrantLock замок1 = new ReentrantLock();
ReentrantLock замок2 = new ReentrantLock();
boolean выполнитьОперацию() throws InterruptedException {
boolean захвачен1 = замок1.tryLock(100, TimeUnit.MILLISECONDS);
if (!захвачен1) return false;
try {
boolean захвачен2 = замок2.tryLock(100, TimeUnit.MILLISECONDS);
if (!захвачен2) return false;
try {
// Оба замка захвачены — выполняем работу
return true;
} finally {
замок2.unlock();
}
} finally {
замок1.unlock();
}
}
Если поток не смог захватить второй замок за 100 мс — он отпускает первый и попробует снова. Дедлок невозможен.
Диагностика: thread dump
Когда приложение зависает и вы подозреваете дедлок — возьмите thread dump (снимок состояния всех потоков). JVM умеет его выдавать прямо во время работы.
# Найти PID процесса Java
jps -l
# Взять thread dump
jstack <PID>
В выводе jstack ищите блок Found one Java-level deadlock: — JVM автоматически находит циклические ожидания и показывает, какой поток что держит и чего ждёт.
Found one Java-level deadlock:
=============================
"Thread-0":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "Thread-1"
"Thread-1":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "Thread-0"
Thread dump также помогает найти потоки, которые застряли в состоянии BLOCKED или WAITING дольше ожидаемого.
Livelock — топтание на месте
Лайвлок (livelock) — потоки активны, не заблокированы, но не движутся вперёд. Они постоянно реагируют друг на друга и мешают друг другу — как два человека в узком коридоре, которые вежливо уступают дорогу одновременно и всё равно сталкиваются.
Пример: два потока используют tryLock и всегда отступают одновременно — каждый видит, что второй активен, и снова откладывает работу. Формально потоки работают, но прогресса нет.
Лечение — добавить случайную задержку перед повторной попыткой, чтобы потоки разошлись по времени:
// Случайная пауза перед повторной попыткой
long пауза = (long) (Math.random() * 50); // 0–50 мс
Thread.sleep(пауза);
Эта техника называется экспоненциальным откатом (exponential backoff) — она широко применяется в сетевых протоколах и распределённых системах.
Starvation — голодание потока
Голодание (starvation) — один или несколько потоков постоянно не получают процессорное время или доступ к ресурсу, потому что другие потоки всегда оказываются приоритетнее.
Типичная причина: поток с низким приоритетом или поток, который вечно проигрывает в конкуренции за замок. Он не заблокирован технически, но его задача никогда не выполняется.
ReentrantLock позволяет включить справедливый режим (fair mode): замок отдаётся тому, кто ждал дольше всего.
// true = справедливый режим, потоки получают замок по очереди
ReentrantLock справедливыйЗамок = new ReentrantLock(true);
Справедливый режим снижает производительность из-за накладных расходов на отслеживание очереди — используйте его только там, где голодание — реальная проблема, а не предположение.
Как три проблемы отличаются друг от друга
| Потоки активны? | Прогресс есть? | |
|---|---|---|
| Deadlock | Нет (BLOCKED) | Нет |
| Livelock | Да | Нет |
| Starvation | Один да, другой нет | У одного да, у другого нет |
Чек-лист безопасного многопоточного кода
Перед тем как писать код с несколькими замками, пройдитесь по этому списку:
- Определите порядок захвата замков — и придерживайтесь его во всей кодовой базе.
- Если нужно захватить несколько замков — рассмотрите
tryLockс таймаутом. - Минимизируйте время удержания замка: захватили → выполнили → отпустили.
- Не вызывайте чужой код (коллбэки, методы, которые вы не контролируете) внутри
synchronized-блока. - При зависании — сразу берите thread dump и ищите
Found one Java-level deadlock. - Если нужна справедливость — используйте
new ReentrantLock(true), но осознанно.
Коротко
- Дедлок — потоки ждут друг друга по кругу и не двигаются. Возникает при четырёх условиях Коффмана.
- Главная защита от дедлока — единый порядок захвата замков или
tryLockс таймаутом. - Лайвлок — потоки активны, но мешают друг другу. Лечится случайной паузой перед повторной попыткой.
- Голодание — один поток вечно проигрывает в конкуренции за ресурс.
new ReentrantLock(true)выравнивает шансы. - Thread dump (
jstack) — главный инструмент диагностики: JVM сама находит дедлоки и показывает, кто что держит.
Что почитать дальше
- synchronized и monitor в Java — как работает ключевое слово
synchronized, монитор объекта, входные условия для дедлока. - ReentrantLock и другие замки —
tryLock,lockInterruptibly,Conditionи когда замки лучшеsynchronized. - ExecutorService и пул потоков — как управлять жизненным циклом потоков и не создавать их вручную.