1. Углубляемся в Deadlock
Deadlock («взаимная блокировка») — это ситуация, когда два или более потоков ждут друг друга вечно: каждый удерживает какой-то ресурс и пытается получить другой, уже занятый соседним потоком. В итоге никто не может продолжить работу, и программа «зависает». Это как две машины на узком мосту нос к носу: пока одна не сдаст назад — никто не проедет.
В Java deadlock — это не исключение (Exception), а «зависание» программы. Потоки не завершаются и не падают с ошибкой, а просто ждут друг друга до бесконечности. Поэтому такие ошибки коварны: они проявляются только «при определённых обстоятельствах».
Четыре условия возникновения deadlock
- 1. Взаимное исключение (Mutual Exclusion)
Ресурс может быть захвачен только одним потоком одновременно (например, объект-монитор, файл). - 2. Удержание и ожидание (Hold and Wait)
Поток удерживает один ресурс и пытается получить второй, не отпуская первый. - 3. Неотъемлемость (No Preemption)
Ресурс нельзя «отобрать» у потока: только сам поток может его освободить. - 4. Круговое ожидание (Circular Wait)
Есть замкнутая цепочка, где каждый поток ждёт ресурс, удерживаемый следующим в цепи.
Deadlock возможен только если выполняются ВСЕ эти условия одновременно. Достаточно нарушить хотя бы одно — и взаимная блокировка невозможна.
2. Пример кода с deadlock
Схема
- Есть два ресурса: lock1 и lock2 (обычные объекты).
- Поток A сначала захватывает lock1, затем пытается получить lock2.
- Поток B сначала захватывает lock2, затем пытается получить lock1.
Пример (не делайте так у себя дома!)
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// Поток 1
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Поток 1: захватил lock1");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("Поток 1: пытается захватить lock2");
synchronized (lock2) {
System.out.println("Поток 1: захватил lock2");
}
}
});
// Поток 2
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Поток 2: захватил lock2");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("Поток 2: пытается захватить lock1");
synchronized (lock1) {
System.out.println("Поток 2: захватил lock1");
}
}
});
thread1.start();
thread2.start();
}
}
Что произойдёт при запуске?
- Поток 1 захватывает lock1, поток 2 — lock2.
- Оба «засыпают» на 100 миллисекунд, успевая захватить первый замок.
- Затем каждый пытается захватить второй замок, который уже удерживается другим потоком.
- Оба потока ждут друг друга бесконечно — получился deadlock.
В консоли увидите:
Поток 1: захватил lock1
Поток 2: захватил lock2
Поток 1: пытается захватить lock2
Поток 2: пытается захватить lock1
Почему возникает deadlock? Подробный разбор
- Взаимное исключение: каждый замок может удерживаться только одним потоком.
- Удержание и ожидание: Поток 1 держит lock1 и ждёт lock2; Поток 2 держит lock2 и ждёт lock1.
- Неотъемлемость: никто не может «выдернуть» замок извне — только выйти из блока synchronized.
- Круговое ожидание: ожидание замкнулось по кругу между двумя потоками.
3. Как избежать и предотвратить deadlock
Захват ресурсов в одном и том же порядке
Главное правило: если несколько потоков должны захватывать несколько ресурсов — всегда делайте это в одном и том же порядке. Например: сначала lock1, потом lock2 — для всех мест в коде.
public void doSomething() {
Object firstLock = lock1;
Object secondLock = lock2; // единый порядок "lock1 -> lock2"
synchronized (firstLock) {
synchronized (secondLock) {
// Работа с обоими ресурсами
}
}
}
При едином порядке невозможно круговое ожидание — условие deadlock нарушается.
Использование tryLock с таймаутом (ReentrantLock)
Когда заранее неизвестно, какие ресурсы понадобятся, используйте ReentrantLock и метод tryLock, чтобы не ждать бесконечно.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public void doWork() {
try {
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// Критическая секция
} finally {
lock2.unlock();
}
} else {
System.out.println("Не удалось захватить lock2, откатываемся");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("Не удалось захватить lock1, откатываемся");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Плюс: можно откатить операцию и попробовать позже. Минус: код сложнее, но deadlock исключён.
Минимизируйте время удержания блокировок
Держите замок как можно меньше. Делайте внутри synchronized/lock только необходимое; всё остальное выносите за пределы критической секции.
Избегайте вложенных блокировок
Чем меньше вложенных synchronized/lock, тем ниже риск взаимной блокировки. Если можно — используйте один замок для связанных ресурсов.
Используйте готовые thread-safe структуры
Стандартная библиотека уже решила многие задачи: коллекции вроде ConcurrentHashMap и другие классы из java.util.concurrent минимизируют риск deadlock и упрощают код.
4. Диагностика deadlock
Thread Dump и jstack
Thread Dump — снимок состояния всех потоков в JVM.
Получение из консоли:
jstack <pid>
Где <pid> — идентификатор Java-процесса (можно узнать через jps).
В IDE обычно есть кнопка «Thread Dump» в панели отладки.
В дампе ищите:
- Статусы BLOCKED или WAITING.
- Сообщения вида waiting to lock ... и locked ....
- Фразу JVM: "Found one Java-level deadlock:" — когда взаимная блокировка обнаружена.
Пример куска дампа:
"Thread-1":
waiting to lock monitor 0x000000001e4000, (object 0x7f8a5c00, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e3000, (object 0x7f8a5c10, a java.lang.Object),
which is held by "Thread-1"
Это и есть deadlock: потоки «держат» друг друга.
VisualVM и другие инструменты
- VisualVM — бесплатный инструмент анализа потоков и обнаружения deadlock.
- Java Mission Control / Flight Recorder — продвинутый мониторинг и профилирование.
В VisualVM можно посмотреть дерево потоков, их состояния и получить уведомление о взаимной блокировке.
Таблица: «Как не попасть в deadlock»
| Причина deadlock | Как избежать |
|---|---|
| Захват ресурсов в разном порядке | Всегда придерживаться одного порядка захвата ресурсов |
| Вложенные synchronized | Минимизировать вложенность, по возможности использовать один замок |
| Долгое удержание замка | Сократить работу внутри критической секции |
| Использование нескольких замков одновременно | Применять tryLock с таймаутом и уметь откатываться |
| Не thread-safe коллекции | Использовать конкурентные коллекции (ConcurrentHashMap и др.) |
5. Типичные ошибки при работе с deadlock
Ошибка №1: Захват ресурсов в разном порядке. Самая частая причина — разные потоки берут замки в разном порядке. Даже если «так быстрее», придерживайтесь единого порядка — иначе круговое ожидание гарантировано.
Ошибка №2: Вложенные synchronized без необходимости. Лишняя вложенность повышает риск взаимной блокировки и усложняет код. Упрощайте модель блокировок.
Ошибка №3: Игнорирование tryLock и таймаутов. Блокировка через synchronized заставит поток ждать бесконечно. Если нет уверенности, используйте tryLock с таймаутом и логику отката.
Ошибка №4: Долгое выполнение кода внутри блокировки. Сетевые запросы, I/O и тяжёлые вычисления под замком резко повышают вероятность проблем. Выносите их за пределы критической секции.
Ошибка №5: Не анализировать Thread Dump. Thread Dump — ваш лучший друг при поиске взаимных блокировок. Используйте jstack и анализируйте состояния BLOCKED/WAITING, цепочки «holding/waiting to lock».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ