Программирование JavaJVM и памятьАрхитектор Java-платформы

Ситуация: два потока зависли во взаимных статических инициализаторах разных классов. Как механизм инициализ...

Ситуация: два потока зависли во взаимных статических инициализаторах разных классов. Как механизм инициализации классов приводит к deadlock?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Deadlock возникает, когда каждый поток удерживает монитор инициализации одного класса, одновременно обращаясь к другому классу, монитор которого удерживает второй поток. JVM гарантирует безопасную инициализацию каждого класса, но не устраняет циклы между инициализаторами разных классов.

Исторический контекст

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

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

Постановка проблемы

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

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

Подробное решение

Для каждого класса JVM отслеживает, как минимум, состояния неинициализирован, инициализируется, инициализирован или инициализация завершилась ошибкой. Поток, начавший инициализацию, становится ответственным за её завершение. Другие потоки при обращении к тому же классу ждут результата.

Пример взаимной блокировки:

class A { static int value = B.value + 1; } class B { static int value = A.value + 1; } public class Main { public static void main(String[] args) { new Thread(() -> System.out.println(A.value)).start(); new Thread(() -> System.out.println(B.value)).start(); } }

Если первый поток начал инициализацию A, а второй — B, то первый может ждать завершения B, а второй — завершения A. Это классический цикл ожидания ресурсов. В реальном приложении зависимость часто скрыта за вызовом метода, регистрацией компонента, загрузкой драйвера или обращением к другому статическому полю.

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

Если инициализатор завершается исключением, класс помечается как неуспешно инициализированный, а дальнейшие обращения обычно получают NoClassDefFoundError с причиной в виде исходного исключения. При deadlock исключение не обязательно появляется: потоки просто остаются в состоянии ожидания.

Диагностировать проблему следует по thread dump. Нужно найти потоки, ожидающие монитор или завершение инициализации класса, и восстановить граф зависимостей между классами. Полезно также искать тяжёлую работу в статических инициализаторах и переносить её из них в явно управляемую фазу запуска.

Основной компромисс такой: ранняя статическая инициализация удобна и гарантирует готовность компонента, но увеличивает связанность и усложняет порядок запуска. Ленивую инициализацию можно сохранить, если зависимости направлены в одну сторону или защищены явным объектом-держателем, но общий цикл всё равно необходимо устранить.

Ситуация из практики

В сервисе два модуля регистрировали обработчики через статические поля. Модуль Metrics при создании регистратора обращался к Config, а Config во время инициализации читал статический регистратор Metrics. После параллельного запуска двух подсистем процесс иногда зависал на старте.

Рассматривались три варианта:

  • синхронизировать запуск всех подсистем одним глобальным монитором — просто, но снижает параллелизм и сохраняет неявные зависимости;
  • принудительно инициализировать классы в заранее выбранном порядке — уменьшает вероятность проблемы, но хрупко при добавлении новых модулей;
  • убрать побочные эффекты из статических инициализаторов и выполнить регистрацию в явной фазе запуска — требует изменения архитектуры, зато делает порядок зависимостей видимым.

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

Что кандидаты часто упускают

  1. Может ли JVM гарантированно обнаружить такой deadlock и выбросить исключение?

Нет. JVM обязана корректно синхронизировать инициализацию классов, но взаимная блокировка разных потоков и классов может выглядеть как обычное ожидание. Поэтому диагностика обычно выполняется по thread dump и состояниям потоков, а не по рассчитываемому исключению.

  1. Что произойдёт, если инициализатор класса завершится исключением?

Первое обращение получает исходную ошибку, если она является допустимым Error, либо JVM оборачивает исключение в ExceptionInInitializerError. Класс считается ошибочно инициализированным; последующие попытки активного использования обычно приводят к NoClassDefFoundError. Это отличается от deadlock: при ошибке есть завершение с исключением, при взаимной блокировке потоки могут ждать бесконечно.

  1. Безопасно ли читать статическое поле класса из его собственного инициализатора?

С точки зрения блокировки текущий поток не зациклится на собственном мониторе инициализации. Однако он может увидеть значение поля по умолчанию, например 0 или null, если присваивание этому полю ещё не выполнено. Поэтому самоссылочные статические инициализаторы опасны не только зависанием, но и использованием логически незавершённого состояния.