W3docs

Взаимная блокировка в Java

Что такое взаимные блокировки в Java, как они возникают и какие паттерны позволяют их предотвратить.

Взаимная блокировка (deadlock) — это режим отказа при использовании блокировок. Два или более потока удерживают блокировки, которые нужны друг другу; ни один не может продолжить выполнение; исключения не выбрасываются; в логах нет ни слова «мы зависли». Снаружи программа выглядит так, будто ничего не делает — точно такой же внешний симптом, как у бесконечного цикла или долгого сетевого запроса.

Взаимные блокировки возникают в любой программе, захватывающей более одной блокировки одновременно. Их пугающе легко написать и пугающе сложно воспроизвести — расписание, провоцирующее deadlock, может проявляться раз в неделю в продакшне и никогда в тестах. Правильная стратегия — не «отлаживать по факту», а «структурировать код так, чтобы это стало невозможным».

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

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

  1. Взаимное исключение. Некоторый ресурс (блокировка) может удерживаться только одним потоком одновременно.
  2. Удержание и ожидание. Поток удерживает хотя бы один ресурс, ожидая получения другого.
  3. Отсутствие вытеснения. Ресурсы нельзя принудительно забрать у удерживающего потока; поток должен освободить их добровольно.
  4. Циклическое ожидание. В графе ожидания есть цикл — A ждёт блокировку B, B ждёт блокировку C, ..., Z ждёт блокировку A.

Нарушьте любое из них — и взаимные блокировки станут невозможны. Стандартные техники предотвращения нарушают одно из четырёх:

  • Упорядочение блокировок (наиболее распространённое): устраняет циклическое ожидание, всегда захватывая блокировки в глобально согласованном порядке.
  • tryLock с таймаутом: устраняет удержание и ожидание, отказываясь от попытки, если вторая блокировка не получена достаточно быстро.
  • Одна большая блокировка: полностью устраняет многоблокировочную структуру. Грубо, но работает при небольшой конкуренции.
  • Безблокировочные / неизменяемые данные: устраняет взаимное исключение, убирая ресурс. Атомарные операции и конкурентные коллекции, рассматриваемые далее в этой части книги, реализуют именно этот подход.

Пример с двумя счетами

Классическая демонстрация:

void transfer(Account from, Account to, int amount) {
  synchronized (from) {
    synchronized (to) {
      from.debit(amount);
      to.credit(amount);
    }
  }
}

// Thread A: transfer(accountX, accountY, 100)
// Thread B: transfer(accountY, accountX, 100)

Расписание:

  1. Поток A захватывает монитор accountX.
  2. Поток B захватывает монитор accountY.
  3. Поток A пытается захватить accountY — заблокирован, удерживается B.
  4. Поток B пытается захватить accountX — заблокирован, удерживается A.

Ни один поток никогда не освободит блокировку. Оба будут BLOCKED вечно. Исправление:

void transfer(Account from, Account to, int amount) {
  Account first  = from.id() < to.id() ? from : to;
  Account second = from.id() < to.id() ? to   : from;
  synchronized (first) {
    synchronized (second) {
      from.debit(amount);
      to.credit(amount);
    }
  }
}

Теперь оба потока захватывают accountX, затем accountY независимо от направления перевода. Циклическое ожидание не может образоваться.

Ключ упорядочения не обязательно должен быть idSystem.identityHashCode(obj) работает как стабильный разрыватель связей для любых объектов, однако возможны коллизии, поэтому в продакшн-коде обычно используется реальный ключ (идентификатор в базе данных, идентификатор пользователя и т.д.) с резервной блокировкой-разрывателем при совпадении ключей.

Упорядочение блокировок в масштабах программы

Упорядочение блокировок работает только если каждый путь кода, захватывающий две блокировки одного типа, делает это в одном и том же порядке. Одного «нарушителя» — метода с synchronized (b) { synchronized (a) { ... } } — достаточно, чтобы снова получить deadlock.

Способы обеспечить это последовательно в крупной кодовой базе:

  • Документируйте порядок. «Всегда захватывать parent перед child.» Укажите это в комментарии к классу.
  • Используйте единый вспомогательный метод. Все вызовы «transfer» проходят через один метод, который выполняет упорядочение — отдельный вызов не может нарушить порядок.
  • -XX:+PrintConcurrentLocks в дампе потоков — один из способов изучить реальные графы захвата блокировок в продакшне.

Дисциплина важна не меньше, чем само правило.

tryLock с таймаутом

Когда гарантировать упорядочение невозможно — разные библиотеки, разные команды, сложные графы объектов — ReentrantLock.tryLock(timeout, unit) даёт выход:

boolean done = false;
while (!done) {
  if (firstLock.tryLock(100, TimeUnit.MILLISECONDS)) {
    try {
      if (secondLock.tryLock(100, TimeUnit.MILLISECONDS)) {
        try {
          doWork();
          done = true;
        } finally { secondLock.unlock(); }
      }
    } finally { firstLock.unlock(); }
  }
  // back off briefly, retry — eventually we'll get both
}

Если вторую блокировку не удаётся получить за 100 мс, поток освобождает первую и повторяет попытку позже. Условие удержания и ожидания нарушено — ни один поток не блокируется навсегда, даже если оба пытаются захватить одни и те же блокировки в обратном порядке.

Цена — повторные попытки и дополнительный код отступления. Используйте упорядочение блокировок, когда это возможно; прибегайте к tryLock, когда нет.

Обнаружение взаимной блокировки во время выполнения

Два основных инструмента.

Дамп потоков. jstack <pid> или kill -3 <pid> выводит состояние и стек каждого потока. Взаимная блокировка видна чётко: два потока в состоянии BLOCKED, каждый - waiting to lock <0x...> на объекте, который другой отмечает - locked <0x...>. JVM Java даже любезно помечает очевидные циклы в конце дампа:

Found one Java-level deadlock:
=============================
"thread-2":
  waiting to lock monitor 0x00007fcd0e..., which is held by "thread-1"
"thread-1":
  waiting to lock monitor 0x00007fcd0e..., which is held by "thread-2"

ThreadMXBean.findDeadlockedThreads(). Программный вариант — удобен для встраивания в health-check эндпоинт:

ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] deadlocked = mx.findDeadlockedThreads();
if (deadlocked != null) log.error("deadlock detected: {} threads", deadlocked.length);

Этот метод находит только взаимные блокировки на внутренних мониторах и ReentrantLock. Он не обнаруживает лайвлоки или общие случаи «поток просто медленный».

Лайвлок и голодание — родственники deadlock'а

Два режима отказа, которые выглядят как взаимные блокировки, но ими не являются:

  • Лайвлок. Потоки непрерывно меняют состояние, но не продвигаются вперёд. Классический случай: два потока с tryLock повторяют попытки бесконечно, так как ни один не уступит первым. Процессор занят; работа не выполняется.
  • Голодание. Поток технически находится в состоянии RUNNABLE или доступен для пробуждения, но планировщик / политика блокировок никогда не даёт ему запуститься. Несправедливые блокировки при высокой конкуренции могут морить голодом писателя, пока читатели беспрепятственно проходят.

Оба имеют тот же поверхностный симптом, что и deadlock («ничто, похоже, не продвигается»), но диагностика отличается — дамп потоков не показывает BLOCKED в взаимном цикле; он показывает потоки, крутящиеся впустую, или один поток, вечно ожидающий.

Практический пример: создание и предотвращение deadlock'а

Программа ниже запускает паттерн перевода в обоих направлениях — сначала с неисправной версией вложенных блокировок (которая создаст deadlock при конкуренции), затем с исправлением на основе упорядочения блокировок. Неисправная версия обёрнута в таймаут сторожевого таймера, чтобы демо не зависало навсегда.

java— editable, runs on the server

Что следует вынести из запуска:

  • Вариант BROKEN не завершил все 100 переводов. При конкуренции t1 оказался удерживающим a и ожидающим b, пока t2 удерживал b и ожидал a. Сторожевой таймер сработал по истечении 3 секунд; findDeadlockedThreads() подтвердил цикл. Вот что такое deadlock — никаких исключений, никаких логов, ничего неправильного ни в одной отдельной строке кода.
  • Вариант FIXED завершился успешно. Правило упорядочения (first = id-min, second = id-max) означает, что оба потока захватывают сначала a, затем b, независимо от направления перевода. Цикл не может образоваться, потому что оба потока обходят граф блокировок в одном направлении.
  • Thread.sleep(1) внутри первого synchronized неисправной версии делает deadlock легко воспроизводимым. В реальном коде такой явный sleep встречается крайне редко — но ввод-вывод, сборка мусора или переключение контекста могут создать то же окно. Именно поэтому deadlock'и нерегулярно воспроизводятся в продакшне и никогда в тестах.
  • ThreadMXBean.findDeadlockedThreads() вернул ненулевой массив для неисправного варианта и подтвердил количество потоков в цикле. Этот вызов — страховочная сетка для обнаружения проблемы внутри процесса: подключите его к health-эндпоинту, и вы узнаете о deadlock'е раньше пользователя.
  • После того как сторожевой таймер объявил неисправный вариант зависшим, программа прервала оба потока. interrupt() не пробуждает поток, заблокированный на мониторе synchronized — он только пробуждает потоки в sleep, wait, join или LockSupport.park. Вот почему прерывание deadlock'а не освобождает его; пришлось бы убить JVM (или использовать ReentrantLock.lockInterruptibly).

Что дальше

Следующая глава, Java volatile, посвящена видимости — второй половине истории о безопасности: ключевому слову, решающему проблему «один поток пишет, другой вечно читает старое значение» без использования блокировок.

Практика

Практика
Какая стратегия напрямую нарушает условие Коффмана 'циклическое ожидание' и является наиболее распространённой техникой предотвращения deadlock'ов в Java?
Какая стратегия напрямую нарушает условие Коффмана 'циклическое ожидание' и является наиболее распространённой техникой предотвращения deadlock'ов в Java?
Was this page helpful?