Мы привыкли считать main() началом Java-приложения:
public static void main(String[] args) {
System.out.println("Started");
}
Но JVM начинает работать раньше. Чтобы вообще вызвать этот метод, ей сначала нужно найти класс, загрузить его, проверить bytecode, подготовить статические поля и выполнить инициализацию.
Упрощённо запуск выглядит так:
java MyApp
↓
запуск JVM
↓
Loading MyApp
↓
Linking
├─ Verification
├─ Preparation
└─ Resolution
↓
Initialization
↓
main()
И здесь полезно сразу убрать одно распространённое представление: JVM не обязана при старте приложения взять весь classpath и загрузить каждый найденный .class. Начальный класс загружается для запуска приложения, а остальные классы могут появляться в JVM позже, когда выполнение кода действительно приводит к их использованию. Спецификация отдельно допускает достаточно большую свободу в моменте loading и resolution, но initialization уже подчиняется конкретным правилам.
Поэтому в большом Spring-приложении наличие десяти тысяч классов в зависимостях ещё не означает, что все десять тысяч уже загружены и инициализированы к первой строке нашего контроллера.
Жизненный цикл класса
Если отбросить детали, жизненный цикл класса удобно представить так:
Loading
↓
Linking
├─ Verification
├─ Preparation
└─ Resolution
↓
Initialization
↓
Using
↓
Unloading
У схемы есть небольшая неточность, которую полезно знать уже сейчас: Resolution не обязана целиком завершиться строго в этом месте. JVM разрешено откладывать разрешение некоторых символических ссылок до момента их фактического использования. Зато перед initialization класс уже должен быть загружен, проверен и подготовлен.
То есть схема хороша для понимания последовательности, но воспринимать её как жёсткий pipeline из пяти синхронных функций не стоит.
Loading: от имени класса до класса внутри JVM
Начнём с:
Loading
На этом этапе JVM вместе с ClassLoader должна получить бинарное представление класса — обычно это содержимое .class — и создать из него представление класса внутри JVM. Источник при этом вообще не обязан быть обычным файлом на диске: пользовательский ClassLoader теоретически может получать bytecode из сети, генерировать его на лету или читать из собственного формата.
Для обычного приложения всё выглядит гораздо прозаичнее:
com.example.OrderService
↓
ClassLoader
↓
OrderService.class
↓
JVM
Вместе с классом в Java-мире появляется знакомый объект:
Class<?> clazz = OrderService.class;
java.lang.Class представляет загруженный тип и позволяет дальше работать с ним через reflection:
System.out.println(clazz.getName());
System.out.println(clazz.getDeclaredMethods().length);
Но здесь появляется важное различие, которое дальше будет встречаться постоянно:
загрузить класс и инициализировать класс — не одно и то же.
Класс может уже существовать внутри JVM, но его static-инициализация ещё не выполнялась.
Это различие особенно заметно, когда начинаешь работать с ClassLoader, reflection, DI-контейнерами и библиотеками, которые сканируют classpath.
Linking: класс уже найден, но выполнять его ещё рано
После Loading начинается Linking.
В него входят три этапа:
Linking
├─ Verification
├─ Preparation
└─ Resolution
Каждый решает свою задачу.
Verification: можно ли вообще выполнять этот bytecode
Предположим, у нас есть:
OrderService.class
Сам факт наличия файла ещё не означает, что JVM обязана доверять всему, что внутри.
На этапе Verification JVM проверяет бинарное представление класса: корректность структуры, ограничения bytecode, работу с типами, operand stack и допустимость инструкций. Если представление нарушает требования JVM, загрузка может закончиться VerifyError.
На собеседованиях здесь любят спрашивать:
Зачем JVM проверяет bytecode, если его уже создал
javac?
Потому что никто не обещал JVM, что этот .class вообще прошёл через javac.
Bytecode может быть:
- сгенерирован другой JVM-языковой компиляцией;
- изменён Java Agent;
- создан библиотекой вроде ASM или Byte Buddy;
- сгенерирован во время работы программы;
- просто повреждён.
JVM выполняет bytecode, а не исходный .java, поэтому проверяет именно то представление, которое получила на вход.
Наличие файла с расширением .class ещё не означает, что JVM обязана его принять. Bytecode verifier проверяет структуру и ограничения формата, а не доверяет имени файла или способу его получения.
Preparation: место для static уже есть, значения ещё нет
Preparation — один из самых интересных этапов, потому что здесь легко перепутать создание статического поля с выполнением Java-кода.
Возьмём:
class Example {
static int x = 10;
}
Интуитивно хочется считать, что после появления класса в JVM уже существует:
x = 10
Но это происходит позже.
Во время Preparation JVM создаёт статические поля класса и присваивает им значения по умолчанию. Явный Java-инициализатор = 10 на этом этапе ещё не выполняется.
Поэтому концептуально получается:
Loading
static int x = 10;
↓
Preparation
x = 0
↓
Initialization
x = 10
Для ссылочного типа картина аналогичная:
static User user = new User();
На Preparation:
user = null
Создание new User() относится уже к initialization.
Это полезно помнить не столько ради практической отладки — поймать класс посередине Preparation обычным Java-кодом сложно, — сколько для понимания того, что JVM разделяет выделение и подготовку статического состояния и выполнение наших инициализаторов.
А что со static final?
Теперь немного изменим пример:
class Example {
static final int MAX_RETRIES = 10;
}
Здесь появляется понятие constant variable.
Если static final поле имеет примитивный тип или String и инициализируется compile-time constant expression, компилятор может записать это значение как константу и вообще встроить его в код места использования. Такие constant variables обрабатываются отдельно при процедуре initialization и до остальных обычных static-инициализаторов.
Например:
class Config {
static {
System.out.println("Config initialized");
}
static final int TIMEOUT = 10;
}
и:
System.out.println(Config.TIMEOUT);
могут вообще не привести к initialization Config: значение 10 может оказаться встроенным прямо в bytecode вызывающего класса.
Но стоит написать:
static final Integer TIMEOUT = Integer.valueOf(10);
или:
static final int TIMEOUT = readTimeout();
и это уже не compile-time constant в том же смысле.
Так что правило:
static finalне инициализирует класс
неверное.
Нужно смотреть, является ли поле constant variable.
Resolution: вместо имени нужна конкретная сущность
В .class много ссылок существует не в виде готовых указателей на объекты JVM, а символически.
Условно bytecode может содержать ссылку на:
java/lang/String
или на метод:
java/io/PrintStream.println
На этапе Resolution JVM преобразует такие символические ссылки в конкретные runtime-представления классов, методов и полей. Для этого используется run-time constant pool класса.
Упрощённо:
"java/lang/String"
↓
Resolution
↓
конкретный класс String внутри JVM
Глубже здесь быстро начинаются:
constant_pool
CONSTANT_Class_info
CONSTANT_Methodref_info
descriptors
run-time constant pool
и статья незаметно превращается в разбор структуры .class. Это отдельная хорошая тема, поэтому пока достаточно одного принципа:
bytecode может ссылаться на сущности символически, а JVM должна связать эти ссылки с реальными runtime-структурами.
Причём resolution может выполняться лениво. Спецификация не требует заранее разрешить каждую символическую ссылку класса перед его initialization.
Initialization: наконец выполняется наш static код
Вот здесь уже начинается выполнение того, что разработчик обычно воспринимает как «инициализацию класса».
Например:
class Example {
static int x = 10;
static {
System.out.println("Example initialized");
}
}
Статический field initializer:
static int x = 10;
и блок:
static {
System.out.println("Example initialized");
}
выполняются во время initialization класса. Спецификация JVM описывает это через специальный class initialization method с именем:
<clinit>
Инициализация класса состоит в выполнении его статических инициализаторов и инициализаторов static-полей; JVM представляет этот процесс через <clinit>.
В исходном Java-коде мы написать такое не можем:
void <clinit>() {
}
Это специальный метод JVM.
Если посмотреть bytecode класса через:
javap -c Example.class
можно увидеть примерно такую картину:
static {};
Code:
0: bipush 10
2: putstatic #...
5: getstatic #...
...
То есть JVM не ходит по исходнику и не интерпретирует отдельные static-конструкции. Компилятор уже собрал необходимую работу в понятную JVM форму.
Порядок static-инициализации имеет значение
Рассмотрим:
class Example {
static int first = initFirst();
static {
System.out.println("static block");
}
static int second = initSecond();
private static int initFirst() {
System.out.println("first");
return 1;
}
private static int initSecond() {
System.out.println("second");
return 2;
}
}
При initialization порядок будет соответствовать порядку объявлений:
first
static block
second
Для разработчика это означает простое правило: static field initializers и static {} не являются двумя раздельными фазами. Они образуют одну последовательность инициализации класса.
Поэтому такой код уже выглядит подозрительно:
static int first = second;
static int second = 10;
Порядок действительно имеет значение.
Сначала Parent, потом Child
Теперь классический вопрос с собеседования:
class Parent {
static {
System.out.println("Parent");
}
}
class Child extends Parent {
static {
System.out.println("Child");
}
}
Запускаем:
new Child();
Получим:
Parent
Child
Перед initialization класса JVM сначала инициализирует его superclass, если тот ещё не был инициализирован. Для современных Java есть дополнительные нюансы с superinterfaces, объявляющими default methods, но базовое правило для обычной цепочки наследования именно такое: родитель раньше потомка.
Это объясняет много загадочных примеров вида:
class Parent {
static int parentValue = init("Parent value");
static int init(String value) {
System.out.println(value);
return 1;
}
}
class Child extends Parent {
static int childValue = init("Child value");
}
При первом активном использовании Child:
Parent value
Child value
JVM не начинает с класса, который мы написали справа от new. Сначала она обеспечивает корректную initialization-цепочку.
Когда JVM действительно инициализирует класс
Вот здесь начинается самая полезная для практики часть.
Класс не инициализируется просто потому, что JVM когда-то узнала о его существовании. Спецификация определяет конкретные действия, которые инициируют initialization. Среди основных: создание экземпляра класса, вызов объявленного в нём static метода, запись в объявленное им static поле и чтение такого поля, если оно не является constant variable. Reflection тоже может инициировать initialization.
Разберём на примерах.
new Foo()
Foo foo = new Foo();
Если Foo ещё не был инициализирован, создание экземпляра потребует initialization:
initialize Foo
↓
create Foo instance
Поэтому:
class Foo {
static {
System.out.println("Foo initialized");
}
}
и:
new Foo();
напечатает:
Foo initialized
Вызов static метода
Foo.doSomething();
Если метод объявлен в Foo, класс должен быть инициализирован перед вызовом.
class Foo {
static {
System.out.println("init");
}
static void doSomething() {
System.out.println("work");
}
}
Результат:
init
work
Чтение обычного static поля
System.out.println(Foo.value);
Если value — не constant variable и объявлено в Foo, такое обращение вызывает initialization Foo.
class Foo {
static int value = 10;
static {
System.out.println("Foo initialized");
}
}
Получим:
Foo initialized
10
Запись static поля
То же относится к:
Foo.value = 20;
Перед первым таким обращением класс должен быть инициализирован.
Foo.class — уже интереснее
А теперь:
Class<Foo> clazz = Foo.class;
Интуитивно можно ожидать:
Foo initialized
Но использование class literal само по себе не входит в список активных использований, требующих initialization класса.
Например:
class Foo {
static {
System.out.println("Foo initialized");
}
}
public class App {
public static void main(String[] args) {
System.out.println(Foo.class);
}
}
может вывести:
class Foo
без:
Foo initialized
Класс как тип уже доступен JVM, но его статическая initialization ещё не понадобилась.
Именно поэтому полезно постоянно разделять:
class loaded
и:
class initialized
Это два разных состояния.
А что делает Class.forName()?
Классический вариант:
Class.forName("com.example.Foo");
обычно не просто загружает Foo, но и инициирует его initialization. Reflection входит в механизмы, которые могут инициировать initialization класса.
Поэтому такой код:
class Foo {
static {
System.out.println("Foo initialized");
}
}
и:
Class.forName("com.example.Foo");
приведёт к выполнению static блока.
Но у Class.forName есть перегрузка:
Class.forName(
"com.example.Foo",
false,
classLoader
);
Второй параметр:
initialize = false
позволяет получить класс без требования немедленно выполнять initialization.
Это уже хороший пример того, почему фраза:
«Class.forName загружает класс»
слишком неточная.
Главный вопрос — нужно ли после загрузки ещё и инициализировать его.
Interview puzzle: будет ли инициализирован Child?
Теперь один из самых удачных вопросов по этой теме.
Есть:
class Parent {
static int value = 10;
static {
System.out.println("Parent initialized");
}
}
class Child extends Parent {
static {
System.out.println("Child initialized");
}
}
Код:
System.out.println(Child.value);
Что напечатает программа?
Можно ожидать:
Parent initialized
Child initialized
10
Но value объявлено не в Child.
Инициализируется класс, который действительно объявляет используемое static-поле. Поэтому результат будет:
Parent initialized
10
Child ради этого обращения инициализировать не требуется. Это прямо следует из правил initialization: использование static поля инициирует класс или интерфейс, который объявляет это поле.
В коде:
Child.value
имя Child немного обманывает глазами. Поле всё равно остаётся:
Parent.value
Ещё один puzzle: static final
Добавим:
class Config {
static {
System.out.println("Config initialized");
}
static final int TIMEOUT = 30;
}
И:
System.out.println(Config.TIMEOUT);
В большинстве ожидаемых случаев:
Config initialized
не появится.
TIMEOUT — constant variable, а её значение компилятор может встроить в вызывающий класс. Использование constant variable не относится к обращениям, которые требуют initialization класса.
Теперь поменяем:
static final int TIMEOUT = loadTimeout();
private static int loadTimeout() {
return 30;
}
Это уже не compile-time constant expression.
При:
System.out.println(Config.TIMEOUT);
Config придётся инициализировать.
На собеседовании разница между этими двумя примерами гораздо интереснее ответа «static final — это константа».
Loading может потянуть другие классы, но это ещё не initialization всего вокруг
Ещё одна причина не воспринимать lifecycle как простой линейный скрипт — этапы могут приводить к работе с другими классами.
Например, verification одного класса может потребовать загрузки других типов. Resolution символической ссылки тоже может привести к loading соответствующего класса. При этом JVM не обязана автоматически инициализировать каждый класс, который встретился по дороге.
Условно:
Loading A
↓
нужен B
↓
Loading B
не означает:
Initialization B
Иначе ленивость загрузки очень быстро потеряла бы смысл: один OrderService через зависимости вытягивал бы половину приложения ещё до первого полезного действия.
Initialization выполняется один раз
Есть ещё одно полезное свойство: initialization конкретного класса выполняется JVM один раз. Причём JVM должна корректно синхронизировать этот процесс, потому что несколько потоков могут одновременно впервые обратиться к одному классу. Процедура initialization в спецификации отдельно учитывает такую многопоточность.
Например:
class ExpensiveConfig {
static {
System.out.println("Loading config");
}
}
Если два потока одновременно впервые используют ExpensiveConfig, JVM не должна два раза выполнить:
Loading config
Loading config
Именно на этом свойстве исторически основан lazy holder idiom:
public class Service {
private Service() {
}
private static class Holder {
private static final Service INSTANCE =
new Service();
}
public static Service getInstance() {
return Holder.INSTANCE;
}
}
Holder не требуется инициализировать при загрузке Service. Он понадобится при первом:
Service.getInstance();
и initialization класса обеспечивает безопасное создание INSTANCE.
Сам паттерн сегодня нужен далеко не везде, но как демонстрация реального использования правил initialization он почти идеален.
А когда класс выгружается
В lifecycle мы оставили последний этап:
Unloading
Каждый загруженный класс связан с определившим его ClassLoader. JVM может выгрузить класс только тогда, когда может быть освобождён его defining ClassLoader; классы, загруженные bootstrap class loader, таким способом не выгружаются.
Поэтому нельзя сделать:
unload(OrderService.class);
и удалить один произвольный класс из работающей JVM.
На практике unloading становится особенно интересным в системах, где живут отдельные ClassLoader’ы:
- application servers;
- plugin systems;
- hot reload;
- IDE;
- некоторые framework-инструменты.
Если ClassLoader продолжает удерживаться какой-нибудь статической ссылкой, вместе с ним могут оставаться и все определённые им классы. Отсюда уже начинается отдельная тема classloader leaks.
Для базовой статьи достаточно помнить:
класс живёт не сам по себе — его identity и lifecycle связаны с ClassLoader.
Что спрашивают на собеседовании
Если собрать основные вопросы этого материала без заучивания определений, я бы проверял себя примерно так:
- Чем Loading отличается от Initialization?
- Какие этапы входят в Linking?
- Какое значение получит
static int x = 10во время Preparation? - Когда реально выполнится
x = 10? - Что такое
<clinit>? - Почему JVM вообще проверяет bytecode?
- Обязана ли Resolution полностью завершиться до Initialization?
- Инициализирует ли
Foo.classклассFoo? - Что произойдёт при
Class.forName("Foo")? - Инициализируется ли
Childпри чтенииChild.parentField? - Почему чтение
static final int X = 10может не инициализировать класс? - В каком порядке инициализируются Parent и Child?
Если на эти вопросы можно ответить не отдельными правилами, а одной моделью lifecycle класса, значит тема уже уложилась нормально.
Какую модель оставить в голове
Запуск Java-кода начинается не с первой строки main(). JVM должна сначала получить начальный класс, связать его с runtime JVM и выполнить initialization перед вызовом main. Дальнейшее выполнение постепенно приводит к загрузке и использованию остальных классов.
Для каждого класса удобно держать последовательность:
Loading
↓
Verification
↓
Preparation
↓
Resolution
↓
Initialization
с двумя оговорками.
Во-первых, Resolution может быть ленивой и не обязана вся завершиться в одной точке.
Во-вторых, Loading не означает Initialization. Класс может уже существовать внутри JVM, но его <clinit> ещё ни разу не выполнялся.
Именно второе различие объясняет большую часть interview-пазлов:
Foo.class
не то же самое, что:
new Foo();
а:
Child.parentField
не означает, что нужно инициализировать Child.
После этого уже логично идти глубже и разбираться, кто именно выполняет Loading: Bootstrap, Platform и Application ClassLoader, как работает parent delegation и почему два класса с одинаковым полным именем всё ещё могут оказаться разными типами.