Два потока одновременно впервые обращаются к одному классу, чей статический инициализатор выполняется долго. Как JVM координирует эти обращения?
JVM выполняет инициализацию класса только один раз. Один поток получает право выполнить статический инициализатор, а остальные потоки, которым требуется инициализированный класс, ждут завершения этой операции. После успешной инициализации результат безопасно виден другим потокам.
Если инициализатор завершился исключением, класс помечается как ошибочный. Последующие обращения не пытаются инициализировать его заново и обычно получают NoClassDefFoundError.
Классы могут загружаться и подготавливаться заранее, но выполнение статического инициализатора откладывается до первого активного использования. Такой подход уменьшает стартовые затраты и позволяет не инициализировать классы, которые фактически не понадобились.
Одновременная инициализация из нескольких потоков создавала бы риск частично видимых статических данных и повторного выполнения побочных эффектов. Поэтому спецификация Java определяет одноразовую инициализацию класса с необходимой синхронизацией и гарантиями видимости.
Долгий статический инициализатор становится точкой ожидания для всех потоков, которым нужен этот класс. Если внутри него выполняется сетевой вызов, захватывается внешний lock или происходит обращение к другому классу, задержка может распространиться далеко за пределы места инициализации.
Особенно опасна циклическая зависимость: инициализатор класса A ждёт ресурс, удерживаемый инициализатором класса B, а B одновременно ждёт A. В таком случае приложение может получить взаимную блокировку ещё до обработки обычного пользовательского запроса.
При активном использовании класса JVM проверяет, завершена ли его инициализация. Если нет, один поток становится ответственным за выполнение метода инициализации класса, фактически соответствующего статическим инициализаторам и присваиваниям статических полей.
Другие потоки не выполняют этот код параллельно: они блокируются до завершения инициализации. Поток, который уже выполняет инициализацию и рекурсивно обращается к тому же классу, не запускает её повторно.
После успешного завершения класс считается инициализированным. Запись статических полей, сделанная в процессе инициализации, становится корректно видимой потокам, которые дождались этого завершения.
Если инициализатор выбросил исключение, JVM помечает класс как находящийся в ошибочном состоянии. Для первого обращения исключение может быть обёрнуто в ExceptionInInitializerError, если исходное исключение не было объектом типа Error. При последующих попытках JVM не повторяет инициализацию, а сигнализирует о невозможности использовать класс, обычно через NoClassDefFoundError с причиной первоначального сбоя.
Синхронизация инициализации не является универсальной защитой от дедлоков. Внешние locks, вызовы другого кода и блокирующие операции внутри статического инициализатора остаются ответственностью разработчика. Поэтому статическая инициализация должна быть короткой, детерминированной и не зависеть от внешних ресурсов.
Минимальный пример ожидания:
Оба потока могут обратиться к Config.VALUE почти одновременно, но load() выполнится один раз. Второй поток дождётся завершения инициализации, а затем прочитает готовое значение.
В сервисе первый запрос после запуска иногда зависал на несколько секунд. Профилирование показало, что обработчик впервые обращался к классу, чей статический инициализатор загружал конфигурацию из удалённого хранилища. В это же время несколько рабочих потоков ожидали завершения инициализации одного класса.
Рассматривались два варианта. Увеличение таймаутов лишь скрывало проблему и усиливало задержку. Принудительная инициализация класса при старте уменьшала задержку первого запроса, но оставляла блокирующую сетевую операцию внутри статической инициализации и усложняла обработку ошибок.
Выбранное решение состояло в удалении сетевого вызова из статического инициализатора. Конфигурация стала загружаться управляемым компонентом при запуске приложения с явным таймаутом, диагностикой и политикой отказа. В результате исчезла неуправляемая блокировка потоков на первом запросе, а ошибка загрузки стала видна в штатном этапе запуска.
1. Может ли статический инициализатор выполниться повторно после исключения?
Нет, для данного определения класса и данного загрузчика повторная попытка не выполняется. После сбоя класс считается ошибочным, поэтому последующие обращения получают ошибку использования класса, а не новый запуск инициализатора. Повторная инициализация возможна только для другого определения класса, например загруженного другим загрузчиком классов.
2. Обеспечивает ли JVM отсутствие дедлока между инициализаторами классов?
Нет. JVM координирует инициализацию каждого класса, но не устраняет циклы ожидания, возникающие из-за взаимного обращения к классам или внешним locks. Например, поток, инициализирующий A, может ждать инициализацию B, пока другой поток, инициализирующий B, ждёт A. Поэтому сложную логику и блокирующие операции в статических инициализаторах следует избегать.
3. Чем инициализация класса отличается от его загрузки?
Загрузка создаёт представление класса в JVM, а связывание включает проверку, подготовку и при необходимости разрешение ссылок. Инициализация выполняет пользовательскую статическую логику и присваивания статических полей. Класс может быть загружен задолго до первого активного использования, тогда как инициализация обычно запускается только при условии, определённом правилами Java для активного обращения к классу.