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 независимо от направления перевода. Циклическое ожидание не может образоваться.

Ключ упорядочения не обязательно должен быть id — System.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?