При первом обращении к статическому полю класс неожиданно задерживает поток. Какой этап JVM выполняется перед использованием класса?
Перед активным использованием JVM может выполнить инициализацию класса: запуск статических инициализаторов и блоков в рамках метода <clinit>. Поэтому первый поток может получить задержку, а остальные потоки — временно ожидать завершения этой инициализации.
Разделение загрузки, связывания и инициализации позволяет JVM не выполнять статический код всех классов во время старта приложения. Класс можно загрузить и подготовить заранее, но запустить его инициализацию только при первом действительно необходимом обращении.
Такой подход уменьшает начальное время запуска и позволяет не выполнять код для классов, которые фактически не используются. Одновременно JVM сохраняет гарантию: перед активным использованием класса его статическое состояние должно быть инициализировано согласованно.
Задержка первого обращения особенно заметна, если статический инициализатор выполняет тяжелую работу: создание больших структур данных, чтение файлов, обращение к внешнему сервису или сложную регистрацию компонентов. Ошибка в этом коде может проявиться не при запуске приложения, а в первом пользовательском запросе.
Неверно считать, что загрузка класса автоматически означает выполнение всех его статических инициализаторов. Также опасно считать, что инициализация выполняется отдельно каждым потоком: JVM должна обеспечить однократное и безопасное выполнение.
Обычно жизненный цикл включает загрузку класса, связывание и инициализацию. Инициализация запускается при активном использовании, например при создании экземпляра, вызове статического метода или обращении к большинству статических полей.
JVM выполняет статические инициализаторы в порядке, заданном классом: сначала подготавливаются необходимые супертипы, затем выполняются инициализаторы самого класса в текстовом порядке. Инициализация конкретного класса выполняется один раз; если несколько потоков обращаются к нему одновременно, один поток выполняет работу, а остальные ждут результата.
Не каждое упоминание класса запускает инициализацию. Обращение к константному static final полю примитивного типа или String, значение которого известно на этапе компиляции, обычно не требует инициализации. Получение объекта класса через литерал класса также само по себе не является активным использованием.
Если инициализатор завершается ошибкой, класс переводится в состояние ошибочного. Первый поток получает исходную ошибку или обертку, а последующие попытки обычно завершаются NoClassDefFoundError. Поэтому статическая инициализация должна быть короткой, детерминированной и не зависеть от внешних ресурсов без явной обработки отказов.
Диагностировать задержку можно по профилю стека потока, журналам инициализации классов, JFR и снимкам потоков. Важно отличать время самой инициализации от времени ожидания блокировки на инициализации, потому что виновником задержки может быть другой поток.
В веб-сервисе первый запрос к редко используемому модулю оказался медленнее остальных. Анализ показал, что статический инициализатор создавал крупный справочник и синхронно читал файл конфигурации.
Рассматривались три варианта. Оставить ленивую инициализацию было просто, но задержка оставалась у первого пользователя. Перенести работу в конструктор запроса не устраняло проблему и увеличивало сложность управления состоянием. Выполнить контролируемую инициализацию при старте приложения позволяло заранее обнаружить ошибку и вынести задержку из пользовательского пути, но увеличивало время запуска.
Выбрали явную инициализацию на этапе готовности приложения с проверкой результата. Это сделало время запуска предсказуемым, позволило быстро выявлять ошибочную конфигурацию и исключило неожиданную задержку первого запроса.
Вопрос: Может ли JVM выполнить инициализацию класса раньше первого активного обращения?
Ответ: JVM вправе заранее загрузить и связать класс, а некоторые детали разрешить раньше использования. Однако спецификация не требует заранее выполнять инициализацию произвольно: запуск статических инициализаторов должен происходить по правилам активного использования. Поэтому нельзя полагаться на конкретный момент загрузки как на момент выполнения статического кода.
Вопрос: Что произойдет, если два потока одновременно впервые обратятся к одному классу?
Ответ: JVM координирует инициализацию через внутреннюю синхронизацию состояния класса. Один поток становится выполняющим инициализацию, остальные ожидают; после успешного завершения они видят полностью инициализированное состояние. Если инициализация завершилась ошибкой, ожидающие потоки также получают ошибку, связанную с невозможностью инициализировать класс.
Вопрос: Почему перенос тяжелой логики из статического инициализатора не всегда решает проблему?
Ответ: Если логика остается за первым обращением — например, внутри ленивого фабричного метода или держателя singleton, — задержка просто перемещается в другую точку. Кроме того, ручная ленивая инициализация может потребовать корректной публикации результата и синхронизации. Перенос действительно помогает, когда жизненный цикл явно управляется приложением: работу можно выполнить на старте, асинхронно прогреть с контролем готовности или вернуть диагностируемую ошибку вместо скрытой задержки.