Питання 17 · Розділ 9

Що таке deadlock (взаємне блокування)?

Двоє людей на вузькому мості з різних боків:

Мовні версії: English Russian Ukrainian

Junior рівень

Базове розуміння

Deadlock (взаємне блокування) — це ситуація, коли два або більше потоки блокують один одного назавжди.

У коді: Потік 1 тримає lockA і чекає lockB. Потік 2 тримає lockB і чекає lockA. Жоден не відпустить свій lock першим. Обидва потоки зависли назавжди.

Проста аналогія

Двоє людей на вузькому мості з різних боків:

  • Людина A: “Я не пропущу тебе, поки ти не пропустиш мене”
  • Людина B: “Я не пропущу тебе, поки ти не пропустиш мене”
  • Результат: Обидва стоять вічно

Приклад у Java

public class DeadlockDemo {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void methodA() {
        synchronized(lock1) {
            System.out.println("Потік 1: Захопив lock1");
            // Пауза, щоб інший потік встиг захопити lock2
            try { Thread.sleep(100); } catch (InterruptedException e) {}
            synchronized(lock2) { // ЧЕКАЄМО lock2 — але він в іншого потоку!
                System.out.println("Потік 1: Захопив lock2");
            }
        }
    }

    public void methodB() {
        synchronized(lock2) {
            System.out.println("Потік 2: Захопив lock2");
            try { Thread.sleep(100); } catch (InterruptedException e) {}
            synchronized(lock1) { // ЧЕКАЄМО lock1 — але він в іншого потоку!
                System.out.println("Потік 2: Захопив lock1");
            }
        }
    }
}

// Запуск:
new Thread(demo::methodA).start();
new Thread(demo::methodB).start();
// → DEADLOCK! Потік 1 чекає lock2, Потік 2 чекає lock1

Deadlock vs Livelock vs Starvation

Тип Поведінка CPU  
Deadlock Потоки “сплять” і ніколи не прокинуться 0%  
Livelock Потоки постійно реагують один на одного, але не роблять прогресу 100%  
Starvation Потік готовий до роботи, але ніколи не отримує ресурс Залежить  

Livelock приклад

// Двоє людей у коридорі — обидва намагаються поступитися, але в один бік
while (otherPersonMoving) {
    stepAside(); // Постійно реагуємо, але не проходимо
}

Starvation приклад

// Потік з низьким пріоритетом ніколи не отримує CPU
Thread lowPriority = new Thread(task);
lowPriority.setPriority(Thread.MIN_PRIORITY); // Завжди чекає

Middle рівень

Wait-for-Graph (Граф очікування)

У теорії deadlock представляється як цикл у графі:

Потік 1 ──чекає──→ Lock B ──тримає──→ Потік 2
  ▲                                       │
  │                                       │
  └───────────чекає──────── Lock A ◄───────┘
                тримає

ЦИКЛ = DEADLOCK

Умови Коффмана (Coffman Conditions)

Deadlock можливий ТІЛЬКИ при одночасному виконанні всіх 4 умов:

# Умова Опис
1 Mutual Exclusion Ресурс може бути зайнятий лише одним потоком
2 Hold and Wait Потік тримає один ресурс і чекає інший
3 No Preemption (відсутність примусового вилучення) — якщо потік захопив synchronized(lock), ніхто не може насильно забрати у нього цей lock. Тільки сам потік може вийти з synchronized.  
4 Circular Wait Існує замкнений ланцюжок очікувань

Database Deadlocks

Deadlock може виникнути і на рівні БД:

// Потік 1:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

// Потік 2:
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
// → DEADLOCK! БД виявить і відкочує одну транзакцію

Відмінність: БД вміє детектувати deadlock і автоматично відкочує одну транзакцію. У Java вам доведеться детектувати вручну.

Hidden Deadlock (Прихований)

// Deadlock через Thread Pool Starvation
ExecutorService pool = Executors.newFixedThreadPool(1);

// Покроковий розбір:
// Крок 1: pool.submit(A) — єдиний потік почав виконувати задачу А
// Крок 2: Всередині А викликається pool.submit(B).get() — задача Б поставлена в чергу
// Крок 3: .get() блокує потік А в очікуванні Б
// Крок 4: Потік єдиний і зайнятий, Б ніколи не почнеться = DEADLOCK
// Це називається "thread pool starvation deadlock"
Future<String> future = pool.submit(() -> {
    // Ця задача чекає іншу задачу з того ж пулу
    return pool.submit(() -> "result").get(); // DEADLOCK!
});
// Один потік зайнятий першою задачею, друга задача чекає в черзі

Static Initialization Deadlock

// Клас A
class A {
    static {
        System.out.println(B.value); // Звертається до B
    }
}

// Клас B
class B {
    static {
        System.out.println(A.value); // Звертається до A
    }
}

// Два потоки завантажують A і B одночасно → deadlock у ClassLoader

Senior рівень

Коли НЕ використовувати вкладені synchronized

  1. Порядок захоплення не можна гарантувати — різні шляхи коду захоплюють locks у різному порядку
  2. Є зовнішні виклики всередині блокування (БД, HTTP, файлова система) — зовнішній ресурс може заблокувати і створити deadlock
  3. Невідомий код — передаєте lock у чужий метод, який може захопити інший lock

Альтернативи: ReentrantLock.tryLock(timeout), lock-free структури (ConcurrentHashMap, AtomicReference), або один спільний lock.

Under the Hood: JVM Deadlock Detection

JVM має вбудований детектор deadlock через ThreadMXBean:

ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = mxBean.findDeadlockedThreads();

if (deadlockedThreads != null) {
    for (long threadId : deadlockedThreads) {
        ThreadInfo info = mxBean.getThreadInfo(threadId, true, true);
        System.out.println("Deadlock: " + info.getThreadName());
        System.out.println("Blocked on: " + info.getLockName());
        System.out.println("Blocked by: " + info.getLockOwnerName());
    }
}

Важливо: Це дорога операція — не запускайте занадто часто.

Deadlock у Highload

Проблема: Deadlock може паралізувати систему за мілісекунди
Детектор JVM: Працює періодично (не миттєво)

Результат: потоки, яким потрібні заблоковані ресурси, зависнуть назавжди. Решта системи продовжить працювати, але пропускна здатність впаде.

Edge Cases

Deadlock із зовнішніми системами

Java потік: тримає lockA → чекає відповідь від БД
БД транзакція: тримає lock на таблицю → чекає HTTP від Java додатку

→ DEADLOCK між Java та БД!

ReentrantLock та interruption

// synchronized не можна перервати — потік висітиме до перезавантаження
synchronized(lock) {
    // Deadlock тут = вічне очікування
}

// ReentrantLock можна перервати
lock.lockInterruptibly();
try {
    // Deadlock тут = можна перервати через thread.interrupt()
} finally {
    lock.unlock();
}

Діагностика

jstack — найшвидший спосіб

jstack -l <pid>

Вивід:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00007f... (object 0x000000076af0c8d0),
  which is held by "Thread-2"
"Thread-2":
  waiting to lock monitor 0x00007f... (object 0x000000076af0c8e0),
  which is held by "Thread-1"

VisualVM / JConsole

Кнопка “Check Deadlock” — миттєва перевірка.

Програмна перевірка

// Періодична перевірка (кожні 30 секунд)
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
    ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
    long[] deadlocked = mxBean.findDeadlockedThreads();
    if (deadlocked != null) {
        log.error("DEADLOCK DETECTED! Threads: {}", Arrays.toString(deadlocked));
        // Alert, restart, etc.
    }
}, 0, 30, TimeUnit.SECONDS);

Prevention Rule: Lock Ordering

Найефективніший спосіб запобігання — завжди захоплюйте ресурси у суворо визначеному порядку:

// Визначаємо порядок через hashCode
void transfer(Account from, Account to, int amount) {
    Object first = System.identityHashCode(from) < System.identityHashCode(to) ? from : to;
    Object second = first == from ? to : from;

    synchronized(first) {
        synchronized(second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

Best Practices

  1. Lock Ordering — завжди захоплюйте ресурси у визначеному порядку
  2. tryLock(timeout) — використовуйте ReentrantLock з таймаутом
  3. Уникайте вкладених блокувань — якщо можливо
  4. Lock-free структури — ConcurrentHashMap, Atomic*
  5. Thread-specific resources — шардування, незалежні дані
  6. Моніторте через ThreadMXBean.findDeadlockedThreads()
  7. jstack — перший інструмент при підозрі на deadlock
  8. Використовуйте ReentrantLock — замість synchronized для переривності

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Deadlock = два потоки блокують один одного назавжди (Потік 1 тримає lockA і чекає lockB, Потік 2 тримає lockB і чекає lockA)
  • 4 умови Коффмана (всі одночасно): Mutual Exclusion, Hold and Wait, No Preemption, Circular Wait
  • Deadlock vs Livelock vs Starvation: deadlock — потоки сплять (0% CPU), livelock — реагують але без прогресу (100% CPU), starvation — потік ніколи не отримує ресурс
  • Lock Ordering — головний спосіб запобігання: завжди захоплюйте ресурси у суворо визначеному порядку (наприклад, за hashCode)
  • ReentrantLock.tryLock(timeout) — дозволяє уникнути вічного очікування, на відміну від synchronized
  • JVM детектує deadlock через ThreadMXBean.findDeadlockedThreads(), але це дорога операція
  • Прихований deadlock: thread pool starvation — задача чекає результат іншої задачі з того ж пулу (особливо single-thread pool)
  • БД автоматично детектує і відкочує deadlock, Java — ні (потрібен ручний моніторинг)

Часті уточнюючі запитання:

  • Як запобігти deadlock? — Lock ordering (фіксований порядок захоплення), tryLock(timeout), lock-free структури, один спільний lock
  • Чим synchronized відрізняється від ReentrantLock при deadlock? — synchronized не можна перервати (вічне очікування), ReentrantLock.lockInterruptibly() можна перервати через thread.interrupt()
  • Що таке thread pool starvation deadlock? — Задача в пулі чекає .get() від іншої задачі в тому ж пулі; якщо пул single-thread — deadlock гарантований
  • Як виявити deadlock у production? — jstack -l , ThreadMXBean.findDeadlockedThreads(), VisualVM «Check Deadlock»

Червоні прапорці (НЕ говорити):

  • “Deadlock неможливий у моєму коді” — він часто прихований (вкладені synchronized, зовнішні виклики)
  • “JVM автоматично розв’язує deadlock” — ні, JVM лише детектує, але не розв’язує (на відміну від БД)
  • “shutdownNow() розв’яже deadlock” — ні, він посилає interrupt(), але synchronized не можна перервати
  • “Достатньо одного lock для запобігання” — ні, deadlock виникає при 2+ ресурсах у різному порядку

Пов’язані теми:

  • [[10. Як працюють AtomicInteger, AtomicLong]]
  • [[11. В чому перевага Atomic класів перед synchronized]]
  • [[12. Що таке пул потоків (Thread Pool)]]
  • [[15. Що робить ExecutorService]]