Главная › Блог › Deadlock в Java: как возникает, как найти и как не допустить

Deadlock в Java: как возникает, как найти и как не допустить

Что такое deadlock

Deadlock — это ситуация, когда два или больше потоков навечно блокируют друг друга, потому что каждый ждёт ресурс, который держит другой. Ни один не может продолжить, JVM не убивает такие потоки сама — они просто вечно висят в состоянии BLOCKED. Программа не падает, она просто перестаёт что-либо делать в этом участке кода, и часто это замечают не сразу, а по таймаутам где-то выше по стеку.

Классическая аналогия — два человека в узком коридоре с двумя чемоданами. Первый держит свой чемодан и ждёт, когда второй уступит проход. Второй держит свой чемодан и ждёт того же от первого. Оба вежливо стоят и ждут — и будут стоять вечно, потому что никто не готов отпустить то, что держит.

Механика на уровне кода

Классический пример с двумя synchronized-блоками и перекрёстным порядком захвата локов:

Object lockA = new Object();
Object lockB = new Object();

// поток 1
synchronized (lockA) {
    Thread.sleep(50);
    synchronized (lockB) {
        // работа
    }
}

// поток 2
synchronized (lockB) {
    Thread.sleep(50);
    synchronized (lockA) {
        // работа
    }
}

Поток 1 захватил lockA и ждёт lockB. Поток 2 захватил lockB и ждёт lockA. Оба ждут друг друга — классический цикл ожидания. Для deadlock нужны четыре условия одновременно: взаимное исключение (лок эксклюзивен), удержание ресурса при ожидании другого, отсутствие принудительного отбора ресурса и циклическое ожидание. Убрать любое из условий — и deadlock невозможен.

Как найти deadlock в проде

Самый быстрый способ — снять thread dump: jstack <pid> или kill -3 <pid> (для Linux, вывод пойдёт в stdout/лог приложения). JVM сама детектит циклы в synchronized-локах и прямо пишет в дампе блок Found one Java-level deadlock с указанием, какой поток держит какой монитор и чего ждёт. Это одна из немногих ситуаций, где JVM сама диагностирует проблему за вас.

Сложнее с ReentrantLock и другими Lock из java.util.concurrent — JVM не всегда автоматически строит граф ожидания для них в текстовом дампе, но информация о том, кто держит лок и кто ждёт, там тоже есть, просто искать её приходится руками по стектрейсам потоков.

Как предотвратить

  • Единый порядок захвата локов. Если все потоки берут lockA перед lockB, а никогда в обратном порядке, цикл ожидания невозможен. Это самый надёжный и дешёвый способ — договориться об ordering и не нарушать его.
  • tryLock с таймаутом. Вместо блокирующего lock() использовать tryLock(long, TimeUnit): если лок не достался за время — откатиться, отпустить уже захваченные ресурсы и попробовать снова. Deadlock превращается в livelock-подобную деградацию, но не в вечное зависание.
  • Один общий лок вместо нескольких. Если логика позволяет, синхронизация на одном объекте вместо двух разных убирает саму возможность перекрёстного захвата.
  • Таймауты на уровне выше. Даже если deadlock всё же случился, таймаут на HTTP-запрос или на пул соединений не даст всей системе встать колом — упадёт один запрос, а не всё приложение.
Частая ошибка — синхронизация на разных объектах, которые логически защищают одни и те же данные, но захватываются в разном порядке в разных методах. Например, метод transfer(A, B) захватывает lock(A) потом lock(B), а метод transfer(B, A) — наоборот. При параллельном вызове с разными аргументами получаем классический deadlock, который в review легко не заметить, потому что каждый метод по отдельности выглядит корректным.

Deadlock vs livelock vs starvation

Это разные вещи, и на собесе их часто путают. Deadlock — потоки стоят намертво. Livelock — потоки активно работают, меняют состояние, но система в целом не продвигается (например, оба вежливо уступают друг другу ресурс и раз за разом откатываются). Starvation — поток технически может выполниться, но постоянно обходится приоритетом других потоков и не получает CPU или ресурс.

Как избежать deadlock, если нужно захватить два лока?
Зафиксировать глобальный порядок захвата (например, по hashCode или id объектов) и всегда захватывать в этом порядке во всех местах кода, либо использовать tryLock с таймаутом и откатом при неудаче.
Может ли JVM сама разрешить deadlock?
Нет, JVM не убивает и не разруливает зависшие потоки автоматически. Она может лишь задетектить цикл в мониторах synchronized и показать это в thread dump — разрешение проблемы всегда на стороне разработчика.

Ещё разборы