Перейти к содержимому

Как JVM загружает и инициализирует классы

Разбираемся, что происходит с классом до main(): Loading, Verification, Preparation, Resolution и Initialization, когда запускается <clinit> и какие обращения действительно инициализируют класс.

Мы привыкли считать 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.

Что спрашивают на собеседовании

Если собрать основные вопросы этого материала без заучивания определений, я бы проверял себя примерно так:

  1. Чем Loading отличается от Initialization?
  2. Какие этапы входят в Linking?
  3. Какое значение получит static int x = 10 во время Preparation?
  4. Когда реально выполнится x = 10?
  5. Что такое <clinit>?
  6. Почему JVM вообще проверяет bytecode?
  7. Обязана ли Resolution полностью завершиться до Initialization?
  8. Инициализирует ли Foo.class класс Foo?
  9. Что произойдёт при Class.forName("Foo")?
  10. Инициализируется ли Child при чтении Child.parentField?
  11. Почему чтение static final int X = 10 может не инициализировать класс?
  12. В каком порядке инициализируются 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 и почему два класса с одинаковым полным именем всё ещё могут оказаться разными типами.