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

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

Чотири основні практичні методи запобігання Deadlock у Java: 4. Правило відкритих викликів (Open Calls): ніколи не викликати зовнішні методи, обробники подій (listeners) та пові...


🟢 Junior Level

Коротка відповідь для співбесіди (30 секунд)

Запобігання взаємним блокуванням (Deadlock Prevention) — це архітектурний підхід до проєктування багатопотокових програм, при якому усувається сама можливість виникнення Deadlock шляхом гарантованого порушення хоча б однієї з чотирьох умов Коффмана на етапі написання коду.

Чотири основні практичні методи запобігання Deadlock у Java:

  1. Глобальний порядок захоплення (Lock Ordering): усі потоки застосунку захоплюють розділювані блокування суворо в однаковій послідовності (наприклад, за зростанням унікального ідентифікатора id сутності). Це руйнує умову циклічного очікування (Circular Wait).
  2. Захоплення з таймаутом (tryLock): використання ReentrantLock.tryLock(timeout, unit) замість непереривного synchronized. Якщо за відведений час захопити наступний замок не вдалося, потік звільняє всі раніше захоплені ресурси та відступає. Це руйнує умови Hold and Wait та No Preemption.
  3. Використання неблокуючих (Lock-Free) структур: заміна класичних м’ютексів на класи з пакетів java.util.concurrent.atomic та ConcurrentHashMap. Немає блокувань — немає взаємного виключення (Mutual Exclusion).
  4. Правило відкритих викликів (Open Calls): ніколи не викликати зовнішні методи, обробники подій (listeners) та повільний I/O (БД, мережа), утримуючи блокування.

Базовий приклад: Запобігання через Lock Ordering у Java 21

public class SafeBankTransfer {

    public record Account(long id, int balance) {}

    // Безпечний переказ: порядок захоплення детермінований за ID рахунків
    public void transfer(Account from, Account to, int amount) {
        // Захист від спроби переказу самому собі
        if (from.id() == to.id()) {
            throw new IllegalArgumentException("Не можна переказати гроші на той самий рахунок");
        }

        // Детерміноване впорядкування: менший ID завжди блокується першим!
        Account firstLock = from.id() < to.id() ? from : to;
        Account secondLock = from.id() < to.id() ? to : from;

        synchronized (firstLock) {
            synchronized (secondLock) {
                // Критична секція: циклічний Deadlock неможливий за жодних обставин!
                debit(from, amount);
                credit(to, amount);
            }
        }
    }

    private void debit(Account acc, int amount) {}
    private void credit(Account acc, int amount) {}
}

🟡 Middle Level

Патерн «Спроба захоплення з відступом» (tryLock + Back-off)

Якщо ресурси створюються динамічно і встановити для них природний порядок неможливо, застосовується підхід із таймаутом захоплення.

[!WARNING] Небезпека Livelock: якщо два потоки не можуть захопити другий ресурс, одночасно відпускають перший і негайно намагаються повторити спробу, вони можуть нескінченно блокувати один одного на повній потужності CPU (Livelock). Для запобігання Livelock обов’язкова випадкова затримка (Jitter / Random Back-off).

import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class SafeTryLockTransfer {

    public boolean transferWithTimeout(ReentrantLock lockA, ReentrantLock lockB, 
                                       long maxTimeout, TimeUnit unit) throws InterruptedException {
        long deadline = System.nanoTime() + unit.toNanos(maxTimeout);

        while (System.nanoTime() < deadline) {
            // Спроба захоплення першого ресурсу
            if (lockA.tryLock(50, TimeUnit.MILLISECONDS)) {
                try {
                    // Спроба захоплення другого ресурсу
                    if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {
                        try {
                            executeTransfer();
                            return true; // Успішно виконано переказ
                        } finally {
                            lockB.unlock();
                        }
                    }
                } finally {
                    // ЯКЩО ДРУГИЙ ЗАМОК НЕ ОТРИМАНО — НЕГАЙНО ЗВІЛЬНЯЄМО ПЕРШИЙ!
                    lockA.unlock();
                }
            }

            // Випадкова пауза (Jitter) запобігає синхронному входу потоків у Livelock
            int backoffMs = ThreadLocalRandom.current().nextInt(10, 40);
            Thread.sleep(backoffMs);
        }

        return false; // Таймаут вичерпано, операцію скасовано
    }

    private void executeTransfer() {}
}

Архітектурний патерн: Відкриті виклики (Open Calls)

Виклик чужого коду під замком — головна причина прихованих Deadlock у розвинених проєктах:

// НЕБЕЗПЕЧНО: Виклик зовнішнього listener під монітором
public synchronized void updateState(State newState) {
    this.state = newState;
    for (StateListener listener : listeners) {
        // КАТАСТРОФА: зовнішній listener може всередині захоплювати свій власний лок,
        // а інший потік, утримуючи той лок, може викликати метод поточного класу!
        listener.onStateChanged(newState); 
    }
}

Еталонна реалізація Open Call (без утримання лока):

public class SafeStateHolder {
    private final List<StateListener> listeners = new CopyOnWriteArrayList<>();
    private final Object stateLock = new Object();
    private State state;

    public void updateState(State newState) {
        // 1. Швидко оновлюємо внутрішній стан під коротким замком
        synchronized (stateLock) {
            this.state = newState;
        }

        // 2. Зовнішні сповіщення розсилаються СУВОРО ЗА МЕЖАМИ критичної секції!
        for (StateListener listener : listeners) {
            listener.onStateChanged(newState); // Відкритий виклик (Open Call)
        }
    }
}

🔴 Senior Level

Впорядкування довільних об’єктів пам’яті: Tie-Breaker Lock

Коли потрібно впорядкувати захоплення двох довільних Java-об’єктів, у яких немає бізнес-ідентифікаторів (наприклад, графічні вузли графа), використовують їхні адресні хеші System.identityHashCode().

Для обробки рідкісних колізій хеш-кодів застосовується спеціальний статичний замок вирішення нічиєї (Tie-Breaker Lock):

public class ArbitraryLockOrdering {
    private static final Object TIE_BREAKER_LOCK = new Object();

    public void performAction(Object objA, Object objB) {
        int hashA = System.identityHashCode(objA);
        int hashB = System.identityHashCode(objB);

        if (hashA < hashB) {
            synchronized (objA) {
                synchronized (objB) {
                    process(objA, objB);
                }
            }
        } else if (hashA > hashB) {
            synchronized (objB) {
                synchronized (objA) {
                    process(objA, objB);
                }
            }
        } else {
            // КОЛІЗІЯ ХЕШ-КОДІВ: серіалізуємо рідкісні колізії через спільний замок
            synchronized (TIE_BREAKER_LOCK) {
                synchronized (objA) {
                    synchronized (objB) {
                        process(objA, objB);
                    }
                }
            }
        }
    }

    private void process(Object a, Object b) {}
}

Deadlock у базах даних (JPA / Hibernate / SQL)

Deadlock на рівні бази даних відбувається при паралельному виконанні транзакцій, що оновлюють одні й ті самі рядки в різному порядку:

  • Транзакція 1: UPDATE account SET balance = ... WHERE id = 1; потім WHERE id = 2;
  • Транзакція 2: UPDATE account SET balance = ... WHERE id = 2; потім WHERE id = 1;

Запобігання на рівні ORM/SQL:

  1. Детерміноване сортування батчу: при масовому оновленні або блокуванні рядків (SELECT ... FOR UPDATE) список сутностей завжди повинен бути відсортований за первинним ключем (ORDER BY id ASC) перед відправкою запиту.
  2. Песимістичні блокування з таймаутом: використання підказки PESSIMISTIC_WRITE із властивістю jakarta.persistence.lock.timeout:
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
    @Query("SELECT a FROM Account a WHERE a.id IN :ids ORDER BY a.id ASC")
    List<Account> findAccountsForUpdate(@Param("ids") List<Long> ids);
    

Архітектурний контроль: Static Analysis та ArchUnit

У корпоративній розробці дотримання порядку блокувань неможливо проконтролювати лише через ручний Code Review.

Інструменти автоматичної верифікації:

  1. SonarQube / SpotBugs: правила Locks should be acquired in a specific order та ThreadCheck детектують перехресні блокування на етапі CI/CD.
  2. ArchUnit тести: заборона на виклик методів сторонніх сервісів із-під блоків @Synchronized або методів, що утримують блокування.
  3. ThreadSanitizer (TSan): прапорець -XX:+UseThreadSanitizer у JVM виявляє потенційні цикли в графі блокувань під час виконання інтеграційних тестів.

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

1. 30-секундний есенс (Elevator Pitch)

Головна стратегія запобігання Deadlock — Lock Ordering (детермінований порядок захоплення замків) за природним або синтетичним ID, що математично унеможливлює циклічне очікування (Circular Wait). Другим стандартом є застосування ReentrantLock.tryLock(timeout) із випадковим back-off, що руйнує умову Hold and Wait та усуває ризик Livelock. В архітектурі коду критично важливо застосовувати Open Calls (відкриті виклики) — виносити будь-які зовнішні виклики, колбеки та I/O за межі критичних секцій.


2. 4 каверзних питання з глибокими відповідями

Питання 1: Що таке «Відкриті виклики» (Open Calls) у багатопотоковому програмуванні, і як вони захищають від Deadlock?

Відповідь: Відкритий виклик (Open Call) — це архітектурне правило, згідно з яким виклики сторонніх методів (інтерфейсів, обробників подій, делегатів або віддалених служб) виконуються суворо поза контекстом утримуваних синхронізаторів.
Якщо викликати чужий метод, утримуючи свій монітор, розробник втрачає контроль над графом блокувань: невідомий код всередині чужого методу може захопити свій внутрішній замок, а інший потік може спробувати зробити те саме у зворотному порядку. Винесення викликів за межі критичної секції ізолює блокування від чужого коду.

Питання 2: Чому наївний retry-цикл із tryLock() замість запобігання Deadlock часто породжує Livelock, і як цього уникнути?

Відповідь: Якщо два потоки одночасно захоплюють перший замок (lockA.tryLock() та lockB.tryLock()), жоден не може захопити другий замок. Якщо вони обидва звільняють перший замок і відразу ж у наступній ітерації циклу без паузи намагаються повторити захоплення, сценарій відтвориться знову. Потоки будуть нескінченно захоплювати та відпускати замки, споживаючи 100% CPU без здійснення корисного прогресу (Livelock).
Рішення: додавання експоненційної випадкової затримки (Random Back-off / Jitter) перед кожною новою спробою.

Питання 3: Як запобігти Deadlock на рівні SQL-запитів у JPA/Hibernate при оновленні множини пов’язаних сутностей?

Відповідь: На рівні реляційної БД дедлоки виникають при паралельних операціях UPDATE або SELECT FOR UPDATE над одними й тими самими рядками в різному порядку.
Для запобігання:

  1. Колекція оновлюваних ID зобов’язана бути відсортована: ids.sort(Long::compareTo) перед виконанням запиту.
  2. Використовувати SELECT ... FOR UPDATE зі суворою пропозицією ORDER BY a.id ASC.
  3. Задавати таймаут очікування блокування (lock.timeout / NOWAIT / SKIP LOCKED), щоб база скидала завислу транзакцію за таймаутом замість нескінченного очікування.

Питання 4: Навіщо при сортуванні об’єктів за System.identityHashCode() потрібен додатковий Tie-Breaker Lock?

Відповідь: Хеш-код identityHashCode формується на базі 31-бітного представлення і за принципом Діріхле неминуче дає колізії (два різних об’єкти в купі отримують однакове число). Якщо хеші двох конкуруючих об’єктів збіжаться, умова hashA < hashB не спрацює, і потоки можуть захопити їх у довільному порядку, спровокувавши Deadlock. Третій статичний замок TIE_BREAKER_LOCK серіалізує рідкісні колізії, гарантуючи атомарність входу.


3. Типові помилки та Red Flags

❌ “Використання одного глобального замка (synchronized(globalLock)) для вирішення проблеми Deadlock у навантажених сервісах.”
Правильно: Глобальний замок дійсно запобігає Deadlock, але перетворює багатопотокову систему на суворо послідовну, обвалюючи пропускну здатність до рівня одного ядра CPU.

❌ “Утримання Java-блокування під час виконання повільного HTTP-запиту або транзакції БД.”
Правильно: Це найгрубіше порушення правила Open Calls. Затримка зовнішнього сервісу призведе до каскадного блокування всіх потоків пулу застосунку.

❌ “Ігнорування сортування ключів при пакетних операціях.”
Правильно: При пакетній обробці (у Java-колекціях або БД) невідсортовані списки ключів є головною причиною важковідтворюваних спорадичних Deadlock.


4. Пов’язані питання