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-запрос или на пул соединений не даст всей системе встать колом — упадёт один запрос, а не всё приложение.
Deadlock vs livelock vs starvation
Это разные вещи, и на собесе их часто путают. Deadlock — потоки стоят намертво. Livelock — потоки активно работают, меняют состояние, но система в целом не продвигается (например, оба вежливо уступают друг другу ресурс и раз за разом откатываются). Starvation — поток технически может выполниться, но постоянно обходится приоритетом других потоков и не получает CPU или ресурс.