← назад к разделу

Потоки в Java работают параллельно, и большую часть времени это хорошо. Но иногда они начинают мешать друг другу: ждут, топчутся на месте или один поток вечно остаётся без работы. Разберём три главных сценария.

Deadlock — взаимная блокировка

Дедлок (deadlock, взаимная блокировка) — ситуация, когда два или больше потока ждут друг друга и никто из них не может продолжить работу. Программа не падает с исключением — она просто зависает.

Классический пример: поток A держит замок 1 и хочет замок 2, поток B держит замок 2 и хочет замок 1. Оба ждут — бесконечно.

Object замок1 = new Object();
Object замок2 = new Object();

Thread потокA = new Thread(() -> {
    synchronized (замок1) {
        // Поток A захватил замок1, теперь хочет замок2
        synchronized (замок2) {
            System.out.println("Поток A работает");
        }
    }
});

Thread потокB = new Thread(() -> {
    synchronized (замок2) {
        // Поток B захватил замок2, теперь хочет замок1
        synchronized (замок1) {
            System.out.println("Поток B работает");
        }
    }
});

потокA.start();
потокB.start();
// Оба потока будут ждать вечно

Четыре условия Коффмана

Дедлок возникает только при одновременном выполнении четырёх условий, которые в 1971 году сформулировал Эдвард Коффман:

  1. Взаимное исключение — ресурс может держать только один поток одновременно.
  2. Удержание и ожидание — поток держит уже захваченные ресурсы и ждёт новые.
  3. Отсутствие принудительного освобождения — ресурс нельзя отобрать у потока силой, только поток сам его отпускает.
  4. Циклическое ожидание — есть цепочка потоков, где каждый ждёт ресурс от следующего.

Короткая формула: дедлок = все четыре условия одновременно.

Чтобы предотвратить дедлок, достаточно нарушить хотя бы одно из них.

Как избежать дедлока

Способ 1 — фиксированный порядок захвата замков

Самый простой приём: всегда захватывайте несколько замков в одном и том же порядке. Если оба потока сначала берут замок1, а потом замок2 — циклическое ожидание невозможно.

// Оба потока захватывают замки в одном порядке: замок1 → замок2
Thread потокA = new Thread(() -> {
    synchronized (замок1) {
        synchronized (замок2) {
            System.out.println("Поток A");
        }
    }
});

Thread потокB = new Thread(() -> {
    synchronized (замок1) { // тоже начинаем с замок1
        synchronized (замок2) {
            System.out.println("Поток B");
        }
    }
});

Способ 2 — tryLock с таймаутом

ReentrantLock позволяет попробовать захватить замок и отступить, если не получилось за отведённое время. Это нарушает условие «удержание и ожидание»: поток отпускает то, что держит, и повторяет попытку позже.

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;

ReentrantLock замок1 = new ReentrantLock();
ReentrantLock замок2 = new ReentrantLock();

boolean выполнитьОперацию() throws InterruptedException {
    boolean захвачен1 = замок1.tryLock(100, TimeUnit.MILLISECONDS);
    if (!захвачен1) return false;

    try {
        boolean захвачен2 = замок2.tryLock(100, TimeUnit.MILLISECONDS);
        if (!захвачен2) return false;

        try {
            // Оба замка захвачены — выполняем работу
            return true;
        } finally {
            замок2.unlock();
        }
    } finally {
        замок1.unlock();
    }
}

Если поток не смог захватить второй замок за 100 мс — он отпускает первый и попробует снова. Дедлок невозможен.

Диагностика: thread dump

Когда приложение зависает и вы подозреваете дедлок — возьмите thread dump (снимок состояния всех потоков). JVM умеет его выдавать прямо во время работы.

# Найти PID процесса Java
jps -l

# Взять thread dump
jstack <PID>

В выводе jstack ищите блок Found one Java-level deadlock: — JVM автоматически находит циклические ожидания и показывает, какой поток что держит и чего ждёт.

Found one Java-level deadlock:
=============================
"Thread-0":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "Thread-1"
"Thread-1":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "Thread-0"

Thread dump также помогает найти потоки, которые застряли в состоянии BLOCKED или WAITING дольше ожидаемого.

Livelock — топтание на месте

Лайвлок (livelock) — потоки активны, не заблокированы, но не движутся вперёд. Они постоянно реагируют друг на друга и мешают друг другу — как два человека в узком коридоре, которые вежливо уступают дорогу одновременно и всё равно сталкиваются.

Пример: два потока используют tryLock и всегда отступают одновременно — каждый видит, что второй активен, и снова откладывает работу. Формально потоки работают, но прогресса нет.

Лечение — добавить случайную задержку перед повторной попыткой, чтобы потоки разошлись по времени:

// Случайная пауза перед повторной попыткой
long пауза = (long) (Math.random() * 50); // 0–50 мс
Thread.sleep(пауза);

Эта техника называется экспоненциальным откатом (exponential backoff) — она широко применяется в сетевых протоколах и распределённых системах.

Starvation — голодание потока

Голодание (starvation) — один или несколько потоков постоянно не получают процессорное время или доступ к ресурсу, потому что другие потоки всегда оказываются приоритетнее.

Типичная причина: поток с низким приоритетом или поток, который вечно проигрывает в конкуренции за замок. Он не заблокирован технически, но его задача никогда не выполняется.

ReentrantLock позволяет включить справедливый режим (fair mode): замок отдаётся тому, кто ждал дольше всего.

// true = справедливый режим, потоки получают замок по очереди
ReentrantLock справедливыйЗамок = new ReentrantLock(true);

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

Как три проблемы отличаются друг от друга

diagram
Потоки активны?Прогресс есть?
DeadlockНет (BLOCKED)Нет
LivelockДаНет
StarvationОдин да, другой нетУ одного да, у другого нет

Чек-лист безопасного многопоточного кода

Перед тем как писать код с несколькими замками, пройдитесь по этому списку:

  • Определите порядок захвата замков — и придерживайтесь его во всей кодовой базе.
  • Если нужно захватить несколько замков — рассмотрите tryLock с таймаутом.
  • Минимизируйте время удержания замка: захватили → выполнили → отпустили.
  • Не вызывайте чужой код (коллбэки, методы, которые вы не контролируете) внутри synchronized-блока.
  • При зависании — сразу берите thread dump и ищите Found one Java-level deadlock.
  • Если нужна справедливость — используйте new ReentrantLock(true), но осознанно.

Коротко

  • Дедлок — потоки ждут друг друга по кругу и не двигаются. Возникает при четырёх условиях Коффмана.
  • Главная защита от дедлока — единый порядок захвата замков или tryLock с таймаутом.
  • Лайвлок — потоки активны, но мешают друг другу. Лечится случайной паузой перед повторной попыткой.
  • Голодание — один поток вечно проигрывает в конкуренции за ресурс. new ReentrantLock(true) выравнивает шансы.
  • Thread dump (jstack) — главный инструмент диагностики: JVM сама находит дедлоки и показывает, кто что держит.

Что почитать дальше

  • synchronized и monitor в Java — как работает ключевое слово synchronized, монитор объекта, входные условия для дедлока.
  • ReentrantLock и другие замки — tryLock, lockInterruptibly, Condition и когда замки лучше synchronized.
  • ExecutorService и пул потоков — как управлять жизненным циклом потоков и не создавать их вручную.