JavaRush /Курси /JAVA 25 SELF /Deadlock: причини, приклади, усунення

Deadlock: причини, приклади, усунення

JAVA 25 SELF
Рівень 53 , Лекція 0
Відкрита

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».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ