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

Як запобігти deadlock?

Deadlock можна запобігти, порушивши хоча б одну з 4 умов Коффмана. Ключова ідея: deadlock вимагає одночасного виконання всіх 4 умов, тому порушення будь-якої з них робить deadlo...

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

Deadlock можна запобігти, порушивши хоча б одну з 4 умов Коффмана. Ключова ідея: deadlock вимагає одночасного виконання всіх 4 умов, тому порушення будь-якої з них робить deadlock неможливим.

На відміну від виявлення deadlock (файл 19), яке лише діагностує проблему, цей файл описує превентивні стратегії — як спроєктувати систему так, щоб deadlock ніколи не виник.


Junior рівень

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

Deadlock можна запобігти, порушивши хоча б одну з 4 умов Коффмана. Найефективніший спосіб — Lock Ordering (упорядкування блокувань).

Спосіб 1: Lock Ordering

Завжди захоплюйте ресурси у суворо визначеному порядку:

public class BankAccount {
    private final int id; // Унікальний ID для визначення порядку
    private double balance;

    public BankAccount(int id, double balance) {
        this.id = id;
        this.balance = balance;
    }

    // Безпечний переказ грошей — завжди у порядку ID
    public static void transfer(BankAccount from, BankAccount to, int amount) {
        // Визначаємо порядок за ID
        BankAccount first = from.id < to.id ? from : to;
        BankAccount second = from.id < to.id ? to : from;

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

    private void withdraw(int amount) { balance -= amount; }
    private void deposit(int amount) { balance += amount; }
}

Спосіб 2: tryLock з таймаутом

Використовуйте ReentrantLock.tryLock() замість synchronized:

ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();

public void safeOperation() throws InterruptedException {
    while (true) {
        if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
            try {
                if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
                    try {
                        // Обидва захоплені — працюємо
                        return;
                    } finally {
                        lock2.unlock();
                    }
                }
            } finally {
                lock1.unlock(); // Завжди звільняємо перший!
            }
        }
        // Не вдалося — пауза і повтор
        Thread.sleep(50);
    }
}

Спосіб 3: Один глобальний лок

// Простий, але повільний спосіб
private final Object globalLock = new Object();

public void operation1() {
    synchronized(globalLock) {
        // Всі операції через один лок — deadlock неможливий
    }
}

public void operation2() {
    synchronized(globalLock) {
        // Немає вкладених блокувань → немає deadlock
    }
}

Чому це повільно: всі потоки виконують операції послідовно, навіть якщо операції не конфліктують (наприклад, operation1 читає дані, а operation2 пише в інший файл). Це зводить багатопоточність до однопоточності. Використовуйте лише коли операції дійсно мають бути строго послідовними.


Middle рівень

Lock Ordering з hashCode

Коли ресурси не мають натурального порядку:

public void safeOperation(Object resource1, Object resource2) {
    // Порядок за identity hash code
    int h1 = System.identityHashCode(resource1);
    int h2 = System.identityHashCode(resource2);

    if (h1 < h2) {
        synchronized(resource1) {
            synchronized(resource2) { /* ... */ }
        }
    } else if (h1 > h2) {
        synchronized(resource2) {
            synchronized(resource1) { /* ... */ }
        }
    } else {
        // Колізія hashCode — використовуємо глобальний tie-breaker
        synchronized(globalLock) {
            synchronized(resource1) {
                synchronized(resource2) { /* ... */ }
            }
        }
    }
}

Try-Lock з відкатом

ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();

public boolean tryTransfer(Account from, Account to, int amount) {
    if (!lockA.tryLock()) return false;
    try {
        if (!lockB.tryLock()) return false; // Не вдалося — відпускаємо A
        try {
            from.withdraw(amount);
            to.deposit(amount);
            return true;
        } finally {
            lockB.unlock();
        }
    } finally {
        lockA.unlock();
    }
}

Це порушує умову Hold and Wait — якщо не вдалося отримати другий ресурс, відпускаємо перший.

Lock-free структури даних

Найкращий спосіб запобігти deadlock — не використовувати блокування:

// Замість synchronized HashMap:
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());

// Використовуйте ConcurrentHashMap — lock-free:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

// Замість AtomicInteger + synchronized:
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // CAS — немає блокування → немає deadlock

Порівняння підходів

Метод Плюси Мінуси Коли використовувати
Lock Ordering Надійний, простий Потрібен порядок ресурсів Більшість випадків
Try-Lock Гнучкий, з таймаутом Потрібен retry-цикл Коли порядок невідомий
Один лок Простий Низький паралелізм Прості системи
Lock-free Немає deadlock взагалі Складніше реалізувати Високе навантаження

Senior рівень

Under the Hood: Порушення умов Коффмана

Метод Яку умову порушує Механізм
Lock Ordering Circular Wait Неможливо створити цикл
Try-Lock + відпускання Hold and Wait Не тримаємо при очікуванні
lockInterruptibly No Preemption Можна перервати
Lock-free/Atomic Mutual Exclusion Немає ексклюзивного володіння
Один глобальний лок Circular Wait Немає вкладеності

Thread-Specific Resources (Шардування)

Проєктуйте систему так, щоб потоки не ділили ресурси:

// Кожен потік працює зі своїм сегментом
public class ShardedCounter {
    private final Map<Integer, AtomicInteger> shards = new ConcurrentHashMap<>();

    public void increment(int userId) {
        int shard = userId % 16; // 16 сегментів
        shards.computeIfAbsent(shard, k -> new AtomicInteger(0))
              .incrementAndGet();
    }
}
// Потоки з різними userId ніколи не конкурують → немає deadlock

Верифікація: Static Analysis

ThreadSanitizer (TSan)

# Для Java через LLVM
java -agentpath:/path/to/libtsan.so MyApp

Знаходить потенційні гонки та deadlock ще на етапі тестування.

SonarQube

Виявляє вкладені synchronized блоки у різному порядку:

// SonarQube попередить:
public void method1() {
    synchronized(lockA) {
        synchronized(lockB) { // ⚠️ Potential deadlock
        }
    }
}

public void method2() {
    synchronized(lockB) {
        synchronized(lockA) { // ⚠️ У зворотному порядку!
        }
    }
}

Edge Cases

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

Потік Java: тримає lock → чекає відповідь від БД
БД: тримає lock на таблицю → чекає дані від Java (через callback)

→ Deadlock між Java та БД!

Рішення: не тримайте Java-блокування під час I/O операцій.

// ПОГАНО:
synchronized(lock) {
    database.update(); // Тримаємо lock під час I/O
}

// ГАРНО:
Object result = database.update(); // I/O без блокування
synchronized(lock) {
    // Оновлюємо стан
}

Interruption та synchronized

// synchronized НЕМОЖЛИВО перервати:
synchronized(lock) {
    // Якщо deadlock — потік висітиме до перезавантаження JVM
}

// ReentrantLock МОЖНА перервати:
lock.lockInterruptibly();
try {
    // Якщо deadlock — можна викликати thread.interrupt()
} finally {
    lock.unlock();
}

Діагностика та Моніторинг

ThreadMXBean

public class DeadlockMonitor {
    public static void checkAndAlert() {
        ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
        long[] deadlocked = mxBean.findDeadlockedThreads();

        if (deadlocked != null) {
            for (long threadId : deadlocked) {
                ThreadInfo info = mxBean.getThreadInfo(threadId, true, true);
                log.error("DEADLOCK: Thread {} blocked on {}",
                    info.getThreadName(),
                    info.getLockName());
            }
            // Send alert, restart, etc.
        }
    }
}

Warning: Це дорога операція — не запускайте занадто часто (раз у 30+ секунд).

LockWaitTime метрики

// Моніторте час захоплення блокувань
long start = System.nanoTime();
lock.lock();
long waitTime = System.nanoTime() - start;

if (waitTime > 1_000_000) { // > 1ms
    log.warn("High lock wait time: {} ms", waitTime / 1_000_000);
}
// Раптовий ріст = вісник deadlock або contention

Best Practices

  1. Lock Ordering — найнадійніший спосіб, використовуйте завжди при вкладених блокуваннях
  2. tryLock(timeout) — для уникнення вічного очікування
  3. Уникайте блокувань при I/O — не тримайте lock під час запитів до БД/API
  4. Lock-free структури — ConcurrentHashMap, Atomic* замість synchronized
  5. ReentrantLock замість synchronized — для переривності
  6. Шардування — потоки з незалежними даними = немає deadlock
  7. Моніторте LockWaitTime — ріст = вісник проблем
  8. Static Analysis — SonarQube знайде вкладені блокування на етапі розробки

Коли НЕ використовувати prevention-стратегії

  • Одне блокування, один потік — якщо у вас лише один лок і один потік, deadlock неможливий за визначенням, не треба ускладнювати
  • Тільки read-операції — якщо всі потоки лише читають дані (ReadLock або immutable), Mutual Exclusion не застосовується, deadlock неможливий
  • Прототип/MVP на 1-2 дні — глобальний лок цілком підійде, рефакторите коли з’явиться реальне навантаження
  • Акторна модель (Akka) — якщо ви вже використовуєте актори, deadlock prevention на рівні блокувань не застосовний — актори не ділять стан

Lock Ordering vs Try-Lock: що обрати?

Ситуація Вибір Чому
Фіксований набір ресурсів (БД, файли) Lock Ordering Порядок відомий заздалегідь, просто і надійно
Динамічні ресурси (створюються на льоту) Try-Lock Порядок невідомий, tryLock гнучкіший
Високе навантаження + відомі ресурси Lock Ordering Нульовий оверхед при захопленні
Потрібно уникнути вічного очікування Try-Lock + timeout Lock Ordering може чекати вічно при багу в порядку
Legacy-код з вкладеними synchronized Try-Lock Переписати на порядок складніше, ніж замінити synchronized на tryLock

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

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

  • Deadlock запобігається порушенням хоча б однієї з 4 умов Коффмана
  • Lock Ordering — порушення Circular Wait — найнадійніший і безкоштовний спосіб
  • TryLock + відпускання при невдачі — порушення Hold and Wait
  • ReentrantLock замість synchronized — порушення No Preemption (підтримка interrupt)
  • Lock-free/Atomic — порушення Mutual Exclusion
  • Не тримайте блокування під час I/O операцій — це призводить до deadlock із зовнішніми системами (БД, API)

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

  • Що обрати: Lock Ordering чи Try-Lock? — Lock Ordering при фіксованому наборі ресурсів (нульовий оверхед), Try-Lock при динамічних ресурсах або в legacy-коді
  • Як SonarQube допомагає запобігти deadlock? — Знаходить вкладені synchronized блоки у різному порядку на етапі розробки
  • Що таке шардування як спосіб запобігання? — Потоки працюють з незалежними сегментами даних, немає конкуренції = немає deadlock

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

  • ❌ “Один глобальний лок — це гарне рішення для production” — вбиває паралелізм, лише для прототипів
  • ❌ “TryLock гарантує відсутність deadlock” — TryLock запобігає вічному очікуванню, але deadlock-патерн все ще можливий при неправильній retry-логіці
  • ❌ “Deadlock prevention не потрібен для веб-додатків” — веб-додатки теж використовують БД, кеші, зовнішні API
  • ❌ “ReentrantLock розв’язує всі проблеми з deadlock” — він лише дає більше інструментів, deadlock все ще можливий при неправильному використанні

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

  • [[19. Які умови необхідні для виникнення deadlock]]
  • [[21. Що таке race condition]]
  • [[22. Як уникнути race condition]]
  • [[23. Що таке Virtual Threads в Java 21]]
  • [[27. В чому різниця між Thread та Runnable]]