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

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

дедлок: круг ожидания поток A замок 1 держит поток B нужен B замок 2 держит нужен A круг замкнулся лайвлок: оба вежливо отступают поток A замок поток B оба тянутся к замку одновременно оба уступают и отходят — тоже одновременно потоки заняты, сделано: 0 голодание: одному не достаётся поток A поток B взял замок взял замок взял замок взял замок стоит в очереди и не получает ничего B не заблокирован — он всегда проигрывает

Три сбоя, три разные картинки. В дедлоке стрелки замыкаются в круг и движение прекращается совсем. В лайвлоке потоки бегают, но каждый их шаг отменяет соседний. В голодании работа идёт, просто всегда чужая.

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

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

Классический пример: поток A держит замок 1 и хочет замок 2, поток B держит замок 2 и хочет замок 1. Оба ждут — бесконечно. Программа ниже устраивает ровно это и спрашивает у JVM, что происходит: findDeadlockedThreads() возвращает потоки, замкнувшиеся в круг.

живой пример

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class DeadlockDemo {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> grabBoth(lock1, lock2, "A"));   // A: сначала первый
        Thread b = new Thread(() -> grabBoth(lock2, lock1, "B"));   // B: сначала второй
        a.setDaemon(true);
        b.setDaemon(true);
        a.start();
        b.start();
        Thread.sleep(500);

        ThreadMXBean threads = ManagementFactory.getThreadMXBean();
        long[] inCycle = threads.findDeadlockedThreads();
        System.out.println("A: " + a.getState() + ", B: " + b.getState());
        System.out.println(inCycle == null ? "дедлока нет" : "дедлок, потоков в круге: " + inCycle.length);
    }

    private static void grabBoth(Object first, Object second, String name) {
        synchronized (first) {
            try {
                Thread.sleep(100);              // даём соседу взять его замок
                synchronized (second) {
                    System.out.println(name + " сделал работу");
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Печатает A: BLOCKED, B: BLOCKED и дедлок, потоков в круге: 2. Строка «сделал работу» не выведется никогда. setDaemon(true) стоит здесь только затем, чтобы программа смогла завершиться: обычный поток, застрявший в дедлоке, держит JVM живой.

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

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

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

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

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

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

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

было: поток A берёт замок 1 ждёт замок 2 было: поток B берёт замок 2 ждёт замок 1 стало: поток A берёт замок 1 берёт замок 2 прошёл стало: поток B ждёт замок 1 берёт замок 2 прошёл

Было и стало на одной картинке: в разном порядке потоки замыкают круг, в едином порядке второй просто ждёт своей очереди и проходит следом.

живой пример

public class LockOrderDemo {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable job = () -> {
            synchronized (lock1) {          // сначала всегда первый
                synchronized (lock2) {      // потом всегда второй
                    System.out.println(Thread.currentThread().getName() + " прошёл оба замка");
                }
            }
        };
        Thread a = new Thread(job, "поток-A");
        Thread b = new Thread(job, "поток-B");
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("оба закончили, круга ожидания не было");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Здесь порядок виден прямо из кода: замков два, и у них есть имена. В жизни так почти не бывает — замки приходят аргументами:

void transfer(Account from, Account to, BigDecimal amount) {
    synchronized (from) {
        synchronized (to) { /* перевод */ }
    }
}

Два перевода навстречу друг другу, с A на B и с B на A, — и вот он, тот же круг. Правило «всегда сначала первый» тут не записать: кто из них первый, выясняется только в момент вызова. Порядок задают сравнением: у каждого объекта берут стабильное число — идентификатор счёта, если он есть, или System.identityHashCode(obj), — и замки захватывают от меньшего к большему.

void transfer(Account from, Account to, BigDecimal amount) {
    Account first  = from.id() < to.id() ? from : to;
    Account second = first == from ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

Остаётся редкий случай, когда числа совпали: identityHashCode у разных объектов повториться может. На него заводят третий замок, один на всё приложение: при равенстве сначала берут его, а уже под ним оба рабочих. Через такой замок проходят только совпадения, так что узким местом он не становится.

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

ReentrantLock позволяет попробовать захватить замок и отступить, если не получилось за отведённое время. Условие «удержание и ожидание» нарушает при этом не сам метод, а приём: не вышло взять второй замок — отпустить первый и начать всё заново. tryLock только даёт такую возможность, у lock() её нет, а работает приём за счёт finally в примере ниже.

живой пример

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

public class TryLockDemo {
    private static final ReentrantLock lock1 = new ReentrantLock();
    private static final ReentrantLock lock2 = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        lock2.lock();                                  // второй замок занял главный поток
        Thread worker = new Thread(() -> {
            try {
                for (int attempt = 1; attempt <= 5; attempt++) {
                    lock1.lock();
                    try {
                        if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
                            try {
                                System.out.println("попытка " + attempt + ": оба замка мои");
                                return;
                            } finally {
                                lock2.unlock();
                            }
                        }
                        System.out.println("попытка " + attempt + ": второй занят, отпускаю первый");
                    } finally {
                        lock1.unlock();                // отпускаем в любом исходе
                    }
                    Thread.sleep(150);                 // и только потом пробуем снова
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        worker.start();
        Thread.sleep(400);
        lock2.unlock();                                // освобождаем, пока сосед ещё пробует
        worker.join();
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Две первые попытки печатают «второй занят», третья — «оба замка мои». Важен тут не столько tryLock, сколько finally: первый замок отпускается в любом исходе, иначе неудачная попытка оставит его захваченным и превратит лечение в болезнь.

Дедлок без единого замка: пул потоков

Самый частый дедлок в сервисах устроен иначе, чем классический, и потому не находится ни findDeadlockedThreads(), ни блоком «Found one Java-level deadlock» в дампе. Замков в нём нет вовсе.

ExecutorService pool = Executors.newFixedThreadPool(2);

Future<String> outer = pool.submit(() -> {
    Future<String> inner = pool.submit(() -> "внутренняя задача");
    return inner.get();          // ждём задачу, которой некуда встать
});

Две внешние задачи заняли оба потока пула и ждут результата внутренних. Внутренние стоят в очереди и не стартуют, пока не освободится поток. Поток не освободится, пока не дождётся внутренней. Круг замкнулся, но JVM его не видит: с её точки зрения два потока просто ждут на Future.get(), а это обычное ожидание, не захват монитора. В дампе будет WAITING (parking) и никаких указаний на дедлок — только по стеку с FutureTask.get внутри задачи пула можно догадаться, что происходит.

Правила против этого простые. Задача не ждёт результата другой задачи того же пула. Если зависимость по работе есть — либо цепочка без ожидания (thenCompose у CompletableFuture), либо разные пулы (внешние задачи в одном, внутренние в другом), либо структурированная конкурентность, где подзадачи выполняются в собственных виртуальных потоках. То же касается parallelStream внутри задачи, которая сама выполняется в общем ForkJoinPool: это та же конструкция, только замаскированная.

Потерянное пробуждение

Второе зависание без замков: поток ждёт на wait() сигнала, который уже был. Сценарий — notify() вместо notifyAll() при нескольких ожидающих: JVM будит одного, какого — не сказано, и разбуженным оказывается поток, чьё условие всё ещё не выполнено. Он проверяет while, засыпает обратно, а тот, кому сигнал предназначался, спит дальше — сигнал потрачен впустую и больше не повторится.

Выглядит это точно как дедлок: потоки в WAITING, прогресса нет, — но дедлоком не является, и findDeadlockedThreads() вернёт null. Три правила, которые его исключают: проверять условие в while, а не в if; звать notifyAll(), пока не доказано, что все ожидающие взаимозаменяемы; и менять само состояние под тем же монитором, на котором ждут, — иначе сигнал может уйти между проверкой условия и засыпанием. Надёжнее всего не писать это руками: BlockingQueue и Condition закрывают те же сценарии без потерянных пробуждений.

Проглоченное прерывание

Самый частый дефект многопоточного кода вообще пишется в две строки:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // тишина
}

InterruptedException при возбуждении сбрасывает флаг прерывания, и пустой catch окончательно стирает просьбу остановиться. Последствия видны не сразу: shutdownNow() у пула не останавливает такую задачу, приложение висит на выходе и в итоге получает SIGKILL от оркестратора, а отмена через Future.cancel(true) перестаёт работать. Правильных реакций две: пробросить исключение выше (если сигнатура позволяет) или восстановить флаг — Thread.currentThread().interrupt() — и выйти из метода. Подробный разбор прерываний — в статье про потоки.

Утечка потоков и ThreadLocal

Две течи, которые копятся месяцами. Первая: пул, который создали и не закрыли. Executors.newFixedThreadPool внутри метода, вызываемого на каждый запрос, оставляет после себя живые непотоки-демоны; через сутки в дампе их тысячи, память занята стеками, а jstack показывает сотни одинаковых pool-473-thread-1. Пул — это ресурс уровня приложения: его создают один раз (бином, статическим полем) и закрывают при остановке.

Вторая: ThreadLocal, не очищенный в переиспользуемом потоке. Поток пула живёт долго, значение, положенное одной задачей, достаётся следующей — чужой userId в логах, чужая роль в проверке прав, а если значение тяжёлое, то и растущая память. Лечение одно и всегда: set в начале задачи, remove в finally. Подробности — в статье про пулы.

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

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

jps -l                       # найти PID процесса Java
jstack <PID>                 # снять thread dump
jcmd <PID> Thread.print      # то же самое, современный способ
jcmd <PID> Thread.dump_to_file -format=json threads.json   # с виртуальными потоками

jcmd предпочтительнее: он один умеет всё, что раньше делали разные утилиты, и у него есть режим, без которого на Java 21 разбор бесполезен, — jcmd <PID> Thread.dump_to_file -format=json threads.json. Обычный jstack не показывает виртуальные потоки вовсе: их тысячи, и в классический дамп они не попадают. Если сервис работает на виртуальных потоках, а в дампе видно два десятка носителей и ничего больше, — это не значит, что всё спокойно.

И правило, которое экономит часы: снимайте дамп дважды с интервалом в несколько секунд. Один снимок не отличает зависший поток от занятого: и тот и другой стоят в одном стеке. Два снимка отвечают сразу: стек не изменился — поток стоит, изменился — работает, просто долго.

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

Found one Java-level deadlock:
=============================
"Thread-0": waiting to lock monitor 0x... which is held by "Thread-1"
"Thread-1": waiting to lock monitor 0x... which is held by "Thread-0"

У дедлока на ReentrantLock вид в дампе другой. Поток стоит не в BLOCKED, а в WAITING (parking), и вместо «waiting to lock monitor» строчка выглядит так:

"T-A":
  waiting for ownable synchronizer 0x..., (a java.util.concurrent.locks.ReentrantLock$NonfairSync),
  which is held by "T-B"

Заголовок Found one Java-level deadlock: при этом тот же — круг на явных замках JVM тоже находит. А вот по одному состоянию потока такой дедлок не опознать: WAITING одинаково выглядит и у застрявшего намертво, и у мирно спящего потока. Поэтому смотрят не на состояния, а на блок про дедлок.

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

Здесь дамп нужен только ради блока про дедлок. Как снять его на проде и в Kubernetes, что значат состояния RUNNABLE, BLOCKED и WAITING на самом деле, как читать пулы потоков и почему виртуальных потоков в jstack нет — в отдельной статье Thread dump и heap dump.

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

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

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

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

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

Случайная добавка к паузе называется джиттером (jitter): она разводит потоки, которые иначе снова столкнулись бы в тот же момент. Рядом почти всегда применяют экспоненциальный откат (exponential backoff): каждая следующая пауза длиннее предыдущей — 50 мс, 100, 200, 400. Растущая задержка плюс случайный разброс и есть привычный рецепт повторов в сетевых протоколах и распределённых системах.

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

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

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

Типичная причина — поток, который раз за разом проигрывает конкуренцию за замок: нечестный ReentrantLock отдаёт замок тому, кто подошёл в удачный момент, а не тому, кто ждёт дольше всех. Приоритеты потоков (setPriority) в этом списке почти не участвуют, хотя их называют первыми: на деле они решают мало, а на Linux по умолчанию попросту игнорируются. Голодающий поток технически не заблокирован — просто его задача никогда не доходит до выполнения.

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

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

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

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

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

Коротко

  • Дедлок — потоки ждут друг друга по кругу и не двигаются. Возникает при четырёх условиях Коффмана.
  • Главная защита от дедлока — единый порядок захвата замков или tryLock с таймаутом. Держите замок недолго и не зовите внутри synchronized чужой код: он может взять ещё один замок.
  • Лайвлок — потоки активны, но мешают друг другу. Лечится случайной паузой перед повторной попыткой.
  • Голодание — один поток вечно проигрывает в конкуренции за ресурс. new ReentrantLock(true) выравнивает шансы.
  • Thread dump (jstack) — главный инструмент диагностики: JVM сама находит дедлоки и показывает, кто что держит.
  • Самый частый дедлок в сервисах — без замков: задача ждёт Future другой задачи того же пула; JVM его не находит, признак — FutureTask.get в стеке задачи пула.
  • Потерянное пробуждение (notify вместо notifyAll) выглядит как дедлок, но им не является: условие проверяют в while, меняют под тем же монитором, а лучше берут BlockingQueue.
  • Проглоченный InterruptedException ломает отмену и остановку приложения: пробросить или восстановить флаг. Незакрытый пул и неочищенный ThreadLocal — две медленные течи.
  • Дамп снимают jcmd Thread.print, а на виртуальных потоках только Thread.dump_to_file -format=json; и всегда дважды с интервалом, иначе «завис» не отличить от «долго работает».

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