Які умови необхідні для виникнення deadlock?
Deadlock — це ситуація, коли два або більше потоки назавжди блокують один одного, кожен чекає ресурс, який утримується іншим. На відміну від звичайної конкуренції за ресурси (де...
Deadlock — це ситуація, коли два або більше потоки назавжди блокують один одного, кожен чекає ресурс, який утримується іншим. На відміну від звичайної конкуренції за ресурси (де потік просто якийсь час waiting), deadlock — це повна зупинка, яка не розв’яжеться без зовнішнього втручання.
Junior рівень
Базове розуміння
Deadlock — це не випадковість, а результат математичного поєднання факторів. У 1971 році Едвард Коффман сформулював 4 умови, які мають виконуватися ОДНОЧАСНО для виникнення deadlock. Якщо хоча б одна умова порушена — deadlock неможливий.
4 умови Коффмана
| # | Умова | Просте пояснення |
|---|---|---|
| 1 | Mutual Exclusion (взаємне виключення) | Ресурс може зайняти лише один потік. Чому це важливо: якщо ресурс спільний (наприклад, read-only дані), кілька потоків можуть працювати одночасно без очікування |
| 2 | Hold and Wait (утримання та очікування) | Потік уже тримає один ресурс і запитує ще один, не відпускаючи перший. Це створює “точку блокування” — потік не рухається вперед, але й не звільняє те, що вже має |
| 3 | No Preemption (відсутність витіснення) | JVM чи ОС не можуть примусово забрати ресурс у потоку. synchronized, наприклад, не має таймауту — якщо потік захопив лок, лише він сам може його відпустити |
| 4 | Circular Wait (циклічне очікування) | Потоки чекають один одного по колу: T1 чекає ресурс від T2, T2 чекає ресурс від T1. Без кола — deadlock неможливий |
Приклад
// Потік 1:
synchronized(resourceA) { // Захоплює resourceA — Mutual Exclusion: ніхто інший не увійде
synchronized(resourceB) { // Тримає A, чекає B — Hold and Wait активовано
// ...
}
}
// Потік 2:
synchronized(resourceB) { // Захоплює resourceB — Mutual Exclusion: ніхто інший не увійде
synchronized(resourceA) { // Тримає B, чекає A — Hold and Wait активовано
// ...
}
}
// No Preemption: synchronized не можна "забрати" — лише власник відпустить
// Circular Wait: Потік 1 чекає B (у Потоку 2), Потік 2 чекає A (у Потоку 1) — КОЛО!
// Всі 4 умови виконані = DEADLOCK
Важливий факт
Теоретично, якщо порушити хоча б одну умову — deadlock неможливий. На практиці повне порушення не завжди досяжне: наприклад, Mutual Exclusion не можна порушити для запису у файл або оновлення лічильника. Тому стратегія залежить від типу ресурсу.
Коли НЕ варто вивчати умови Коффмана
- Ви пишете однопотоковий додаток — deadlock неможливий без кількох потоків
- Ви використовуєте лише immutable дані та функціональний стиль — немає спільного змінюваного стану, значить немає блокувань
- Ваша команда повністю перейшла на акторну модель (Akka) — актори не використовують спільні блокування за дизайном
Але в реальних Java-проєктах хоча б один synchronized або ReentrantLock завжди є, тому розуміння умов корисне для будь-якого Middle+ розробника.
Deadlock vs Livelock vs Starvation
Часто плутають ці поняття:
| Проблема | Що відбувається | Приклад |
|---|---|---|
| Deadlock | Потоки назавжди заблокували один одного | T1 чекає T2, T2 чекає T1 — повна зупинка |
| Livelock | Потоки активні, але не просуваються | Двоє людей у коридорі намагаються розійтися, обидва крокують в один бік |
| Starvation | Потік ніколи не отримує ресурс | Низькопріоритетний потік завжди чекає високопріоритетних |
Deadlock — найнебезпечніший, бо вимагає перезапуску JVM для розв’язання. Livelock та starvation можуть (теоретично) розв’язатися самі.
Middle рівень
Детальний розбір умов
1. Mutual Exclusion (Взаємне виключення)
Ресурс може бути зайнятий лише одним потоком одночасно.
// Mutual Exclusion: synchronized
synchronized(lock) {
// Лише один потік тут
}
// НЕМАЄ Mutual Exclusion: ReadWriteLock.readLock()
ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock();
// Безліч потоків можуть читати одночасно → deadlock на readLock неможливий!
Як порушити: Використовуйте ресурси зі спільним доступом (ReadLock, Atomic класи, lock-free структури).
2. Hold and Wait (Утримання та очікування)
Потік уже тримає один ресурс і запитує додаткові.
// Hold and Wait:
synchronized(lockA) {
// Тримаємо lockA
synchronized(lockB) { // Запитуємо lockB — HOLD AND WAIT
// ...
}
}
Як порушити: Запитуйте всі ресурси “одним пакетом”:
// Порушення: запитуємо все або нічого
if (lockA.tryLock() && lockB.tryLock()) {
try {
// Обидва захоплені — немає Hold and Wait
} finally {
lockB.unlock();
lockA.unlock();
}
} else {
// Не вдалося — нічого не тримаємо
lockA.unlock(); // Звільняємо якщо встигли
}
3. No Preemption (Відсутність витіснення)
Ресурс може бути звільнений лише добровільно власником.
// No Preemption: synchronized
synchronized(lock) {
// JVM не може забрати lock у потоку
}
// Порушення: ReentrantLock можна "забрати" через interrupt
lock.lockInterruptibly();
try {
// Потік можна перервати → lock буде звільнено
} finally {
lock.unlock();
}
Як порушити: Використовуйте lockInterruptibly() або tryLock(timeout).
4. Circular Wait (Циклічне очікування)
Існує кільцева цепочка: T1 чекає ресурс від T2, T2 від T3, …, Tn від T1.
Потік 1 ──чекає lockB──→ Потік 2
▲ │
│ │
└────чекає lockA─────────┘
Як порушити: Введіть строгий порядок блокувань:
// Завжди захоплюємо у порядку зростання ID
void safeOperation(Resource r1, Resource r2) {
Resource first = r1.id < r2.id ? r1 : r2;
Resource second = r1.id < r2.id ? r2 : r1;
synchronized(first) {
synchronized(second) {
// Неможливо створити цикл!
}
}
}
Стратегія порушення умов
| Умова | Як порушити | Ціна | |
|---|---|---|---|
| Mutual Exclusion | Lock-free, ReadLock | Середня | |
| Hold and Wait | tryLock() all or nothing | Низька | |
| No Preemption | lockInterruptibly() | Низька | |
| Circular Wait | Lock Ordering | Найнижча |
Senior рівень
Under the Hood: Resource Allocation Graph (RAG)
RAG — інструмент аналізу deadlock:
Процеси (P) ───→ Ресурси (R): запит
Ресурси (R) ───→ Процеси (P): призначення
P1 ──запит──→ R2 ──призначення──→ P2
▲ │
│ │
R1 ◄──призначення── P1 ──запит──→ P2
│ │
└──────── R3 ◄─────────────┘
ЦИКЛ у RAG = Потенційний DEADLOCK
Dynamic Resource Allocation
У складних системах ресурси створюються динамічно (рядки у БД, об’єкти). Як визначити порядок?
// Хешування імен ресурсів для порядку
void safeOperation(Resource r1, Resource r2) {
int h1 = System.identityHashCode(r1);
int h2 = System.identityHashCode(r2);
// Завжди у порядку хешу
if (h1 < h2) {
synchronized(r1) { synchronized(r2) { /* ... */ } }
} else if (h1 > h2) {
synchronized(r2) { synchronized(r1) { /* ... */ } }
} else {
// Колізія хешу — глобальний tie-breaker
synchronized(globalTieBreaker) {
synchronized(r1) { synchronized(r2) { /* ... */ } }
}
}
}
Lock-free як порушення Mutual Exclusion
// Замість synchronized (Mutual Exclusion):
synchronized(counter) { count++; }
// Використовуємо Atomic (немає ексклюзивного володіння):
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();
// Немає блокування → немає Mutual Exclusion → немає deadlock!
Try-Lock як порушення Hold and Wait
ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();
public void safeOperation() {
while (true) {
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try {
// Обидва захоплені — робимо роботу
return;
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock(); // Звільняємо перший — порушуємо Hold and Wait
}
}
// Не вдалося — пауза і повтор
Thread.sleep(10);
}
}
Діагностика
Thread Dump Analysis
jstack <pid>
"Thread-1" BLOCKED
- waiting to lock <0x000000076af0c8d0> (Resource B)
- locked <0x000000076af0c8e0> (Resource A)
"Thread-2" BLOCKED
- waiting to lock <0x000000076af0c8e0> (Resource A)
- locked <0x000000076af0c8d0> (Resource B)
За адресами <0x000...> можна знайти конкретні об’єкти у heap через MAT.
False Deadlock
Іноді інструменти моніторингу можуть помилково прийняти довгу операцію за deadlock:
Симптоми "deadlock":
- Потоки BLOCKED довгий час
- Немає прогресу
Але це НЕ deadlock якщо:
- Важкий SQL запит (хвилини)
- GC pause (може бути довгим)
- I/O timeout
Програмна детекція
public class DeadlockDetector {
public static Optional<DeadlockInfo> detect() {
ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
long[] ids = mxBean.findDeadlockedThreads();
if (ids != null) {
return Optional.of(new DeadlockInfo(ids, mxBean));
}
return Optional.empty();
}
// Warning: дорога операція — не викликати часто!
}
Best Practices
- Lock Ordering — найнадійніший спосіб (порушує Circular Wait)
- tryLock(timeout) — порушує Hold and Wait + No Preemption
- Lock-free структури — порушує Mutual Exclusion
- Один глобальний лок — порушує Circular Wait (але знижує паралелізм)
- Thread-specific resources — шардування, незалежні дані
- Моніторте через ThreadMXBean — але не занадто часто
- Аналізуйте RAG при проєктуванні — запобігайте на рівні архітектури
🎯 Шпаргалка для інтерв’ю
Обов’язково знати:
- 4 умови Коффмана: Mutual Exclusion, Hold and Wait, No Preemption, Circular Wait — всі мають виконуватися ОДНОЧАСНО
- Порушення хоча б однієї умови робить deadlock неможливим
- Lock Ordering (упорядкування блокувань) — порушення Circular Wait — найнадійніший спосіб запобігання
- TryLock порушує Hold and Wait, lockInterruptibly — No Preemption
- Для діагностики:
jstack <pid>, ThreadMXBean.findDeadlockedThreads() - Deadlock vs Livelock vs Starvation: deadlock — повна зупинка, livelock — активність без прогресу, starvation — потік ніколи не отримує ресурс
- Resource Allocation Graph (RAG): цикл у графі = потенційний deadlock
Часті уточнюючі запитання:
- Чи можна порушити Mutual Exclusion? — Так, через lock-free структури (Atomic, ConcurrentHashMap) або ReadWriteLock.readLock()
- Як виявити deadlock у production? — ThreadMXBean.findDeadlockedThreads(), jstack, JFR events, моніторинг BLOCKED потоків
- Що таке false deadlock? — Коли інструменти моніторингу плутають довгу операцію (важкий SQL, GC pause) з deadlock
- Як hashCode-колізія впливає на Lock Ordering? — При збігу identityHashCode двох об’єктів потрібен глобальний tie-breaker (додатковий лок)
Червоні прапорці (НЕ говорити):
- ❌ “Deadlock — це випадкова проблема” — ні, це детермінований наслідок 4 умов
- ❌ “synchronized можна перервати через interrupt()” — synchronized НЕ підтримує interrupt, потрібен ReentrantLock
- ❌ “Deadlock можна обробити всередині JVM без перезапуску” — у більшості випадків deadlock потребує restart JVM
- ❌ “Якщо використовувати ReentrantLock замість synchronized — deadlock неможливий” — ReentrantLock лише дає більше інструментів, але deadlock все ще можливий при неправильному використанні
Пов’язані теми:
- [[20. Як запобігти deadlock]]
- [[21. Що таке race condition]]
- [[22. Як уникнути race condition]]
- [[27. В чому різниця між Thread та Runnable]]
- [[28. Що таке Callable та Future]]