1. Поглиблюємося в Deadlock
Deadlock («взаємне блокування») — це ситуація, коли два чи більше потоків чекають одне одного безкінечно: кожен утримує якийсь ресурс і намагається отримати інший, уже зайнятий сусіднім потоком. У підсумку ніхто не може продовжити роботу і програма «підвисає». Це як дві машини на вузькому мосту ніс у ніс: доки одна не здасть назад — ніхто не проїде.
У Java deadlock — це не виняток (Exception), а «підвисання» програми. Потоки не завершуються і не падають з помилкою, а просто чекають одне одного до безкінечності. Тому такі помилки підступні: вони виявляються лише «за певних обставин».
Чотири умови виникнення deadlock
- 1. Взаємне виключення (Mutual Exclusion)
Ресурс може бути захоплений лише одним потоком одночасно (наприклад, об’єкт-монітор, файл). - 2. Утримання й очікування (Hold and Wait)
Потік утримує один ресурс і намагається отримати другий, не відпускаючи перший. - 3. Відсутність примусового відбирання (No Preemption)
Ресурс не можна «відібрати» у потоку: лише сам потік може його звільнити. - 4. Циклічне очікування (Circular Wait)
Є замкнений ланцюг, де кожен потік чекає на ресурс, який утримує наступний у ланцюгу.
Deadlock можливий лише якщо виконуються УСІ ці умови одночасно. Достатньо порушити хоча б одну — і взаємне блокування неможливе.
2. Приклад коду з deadlock
Схема
- Є два ресурси: lock1 і lock2 (звичайні об’єкти).
- Потік A спочатку захоплює lock1, потім намагається отримати lock2.
- Потік B спочатку захоплює lock2, потім намагається отримати lock1.
Приклад (не повторюйте це вдома!)
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// Потік 1
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Потік 1: захопив lock1");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("Потік 1: намагається захопити lock2");
synchronized (lock2) {
System.out.println("Потік 1: захопив lock2");
}
}
});
// Потік 2
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Потік 2: захопив lock2");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("Потік 2: намагається захопити lock1");
synchronized (lock1) {
System.out.println("Потік 2: захопив lock1");
}
}
});
thread1.start();
thread2.start();
}
}
Що станеться під час запуску?
- Потік 1 захоплює lock1, потік 2 — lock2.
- Обидва «засинають» на 100 мілісекунд, встигаючи захопити перший замок.
- Потім кожен намагається захопити другий замок, який уже утримується іншим потоком.
- Обидва потоки чекають одне одного безкінечно — вийшов deadlock.
У консолі побачите:
Потік 1: захопив lock1
Потік 2: захопив lock2
Потік 1: намагається захопити lock2
Потік 2: намагається захопити lock1
Чому виникає deadlock? Детальний розбір
- Взаємне виключення: кожен замок може утримуватися лише одним потоком.
- Утримання й очікування: Потік 1 тримає lock1 і чекає lock2; Потік 2 тримає lock2 і чекає lock1.
- Відсутність примусового відбирання: ніхто не може «вирвати» замок ззовні — лише вийти з блоку synchronized.
- Циклічне очікування: очікування замкнулося по колу між двома потоками.
3. Як уникнути та запобігти deadlock
Захоплення ресурсів в одному й тому самому порядку
Головне правило: якщо кілька потоків мають захоплювати кілька ресурсів — завжди робіть це в одному й тому самому порядку. Наприклад: спочатку lock1, потім lock2 — для всіх місць у коді.
public void doSomething() {
Object firstLock = lock1;
Object secondLock = lock2; // єдиний порядок "lock1 -> lock2"
synchronized (firstLock) {
synchronized (secondLock) {
// Робота з обома ресурсами
}
}
}
За єдиного порядку неможливе циклічне очікування — умова deadlock порушується.
Використання tryLock із тайм-аутом (ReentrantLock)
Якщо заздалегідь невідомо, які ресурси знадобляться, використовуйте ReentrantLock і метод tryLock, щоб не чекати безкінечно.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public void doWork() {
try {
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// Критична секція
} finally {
lock2.unlock();
}
} else {
System.out.println("Не вдалося захопити lock2, відкочуємося");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("Не вдалося захопити lock1, відкочуємося");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Плюс: можна відкочити операцію і спробувати пізніше. Мінус: код складніший, але deadlock унеможливлено.
Мінімізуйте час утримання блокувань
Тримайте замок якомога менше. Виконуйте всередині synchronized/lock лише необхідне; все інше виносьте за межі критичної секції.
Уникайте вкладених блокувань
Чим менше вкладених synchronized/lock, тим нижчий ризик взаємного блокування. Якщо можна — використовуйте один замок для пов’язаних ресурсів.
Використовуйте готові потокобезпечні структури
Стандартна бібліотека вже розв’язала багато завдань: колекції на кшталт ConcurrentHashMap та інші класи з java.util.concurrent мінімізують ризик deadlock і спрощують код.
4. Діагностика deadlock
Thread Dump і jstack
Thread Dump — знімок стану всіх потоків у JVM.
Отримання з консолі:
jstack <pid>
Де <pid> — ідентифікатор Java-процесу (можна дізнатися через jps).
В IDE зазвичай є кнопка «Thread Dump» на панелі налагодження.
У дампі шукайте:
- Статуси BLOCKED або WAITING.
- Повідомлення на кшталт waiting to lock ... і locked ....
- Фразу JVM: "Found one Java-level deadlock:" — коли взаємне блокування виявлено.
Приклад фрагмента дампу:
"Thread-1":
waiting to lock monitor 0x000000001e4000, (object 0x7f8a5c00, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e3000, (object 0x7f8a5c10, a java.lang.Object),
which is held by "Thread-1"
Це і є deadlock: потоки «утримують» один одного.
VisualVM та інші інструменти
- VisualVM — безкоштовний інструмент аналізу потоків і виявлення deadlock.
- Java Mission Control / Flight Recorder — просунутий моніторинг і профілювання.
У VisualVM можна переглянути дерево потоків, їхні стани й отримати сповіщення про взаємне блокування.
Таблиця: «Як не потрапити у deadlock»
| Причина deadlock | Як уникнути |
|---|---|
| Захоплення ресурсів у різному порядку | Завжди дотримуватися одного порядку захоплення ресурсів |
| Вкладені synchronized | Мінімізувати вкладеність, за можливості використовувати один замок |
| Довге утримання замка | Скоротити роботу всередині критичної секції |
| Використання кількох замків одночасно | Застосовувати tryLock із тайм-аутом і вміти відкочуватися |
| Непотокобезпечні колекції | Використовувати конкурентні колекції (ConcurrentHashMap та ін.) |
5. Типові помилки під час роботи з deadlock
Помилка № 1: Захоплення ресурсів у різному порядку. Найчастіша причина — різні потоки беруть замки в різному порядку. Навіть якщо «так швидше», дотримуйтеся єдиного порядку — інакше циклічне очікування гарантоване.
Помилка № 2: Вкладені synchronized без потреби. Зайва вкладеність підвищує ризик взаємного блокування й ускладнює код. Спрощуйте модель блокувань.
Помилка № 3: Ігнорування tryLock і тайм-аутів. Блокування через synchronized змусить потік чекати безкінечно. Якщо немає впевненості, використовуйте tryLock із тайм-аутом і логіку відкату.
Помилка № 4: Довге виконання коду всередині блокування. Мережеві запити, I/O та важкі обчислення під замком різко підвищують імовірність проблем. Виносьте їх за межі критичної секції.
Помилка № 5: Не аналізувати Thread Dump. Thread Dump — ваш найкращий помічник у пошуку взаємних блокувань. Використовуйте jstack і аналізуйте стани BLOCKED/WAITING, ланцюжки «holding/waiting to lock».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ