Як запобігти deadlock?
Deadlock можна запобігти, порушивши хоча б одну з 4 умов Коффмана. Ключова ідея: deadlock вимагає одночасного виконання всіх 4 умов, тому порушення будь-якої з них робить deadlo...
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
- Lock Ordering — найнадійніший спосіб, використовуйте завжди при вкладених блокуваннях
- tryLock(timeout) — для уникнення вічного очікування
- Уникайте блокувань при I/O — не тримайте lock під час запитів до БД/API
- Lock-free структури — ConcurrentHashMap, Atomic* замість synchronized
- ReentrantLock замість synchronized — для переривності
- Шардування — потоки з незалежними даними = немає deadlock
- Моніторте LockWaitTime — ріст = вісник проблем
- 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]]