← назад к разделу

Программа собралась, тесты прошли, на сервере запускается — и падает с NoClassDefFoundError на классе, который лежит в проекте и прекрасно компилировался. Или наоборот: две библиотеки принесли один и тот же класс, и приложение сообщает, что Order нельзя привести к Order.

Оба случая — про то, как виртуальная машина находит классы и что она считает «тем же самым классом». Разберём это по порядку: что происходит в момент первого обращения к классу, кто именно его ищет и почему одинаковые на вид имена оказываются разными типами.

Класс попадает в память не при запуске, а при первом обращении

Когда вы запускаете java -jar app.jar, виртуальная машина не читает все классы подряд. Она берёт класс с методом main, а дальше подтягивает остальные по мере необходимости — как только до них доходит дело.

«Дошло дело» означает конкретные вещи: создали объект через new, обратились к статическому полю или методу, вызвали класс через отражение, обратились к наследнику (сначала загрузится родитель). Пока ни одного такого обращения не случилось, класса в памяти нет.

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

live example

public class Main {
    static class Config {
        static { System.out.println("Config готов"); }
        static final String NAME = "orders";
        static int counter = 0;
    }

    public static void main(String[] args) {
        System.out.println("старт");
        System.out.println(Config.NAME);      // константа — класс НЕ загрузится
        System.out.println(Config.counter);   // обычное поле — вот теперь загрузится
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Порядок вывода: старт, orders, Config готов, 0. Строка orders печатается до сообщения из статического блока, и это не опечатка: static final со значением, известным на этапе компиляции, компилятор подставляет прямо в место использования. Обращения к классу не остаётся, загружать нечего.

Три этапа: найти, связать, выполнить статику

Путь класса от файла до готового к работе типа состоит из трёх шагов.

Загрузка — найти байты .class и создать в памяти объект типа Class. Где именно искать, решает загрузчик (о них ниже).

Связывание — тут три отдельных дела. Проверка (verification): байт-код читается и проверяется на корректность — это защита от испорченного или чужого файла, а не формальность. Подготовка (preparation): статическим полям выделяется память и проставляются значения по умолчанию — ноль, false, null. Разрешение ссылок (resolution): символические ссылки на другие классы и методы заменяются прямыми; часть этой работы виртуальная машина откладывает до первого реального обращения.

Инициализация — выполняются статические блоки и присваивания статическим полям, сверху вниз по тексту класса. Это тот самый момент, когда печатается «Config готов».

Разница между подготовкой и инициализацией видна на примере: после подготовки counter равен нулю, потому что так устроен int, а после инициализации — значению из кода. Если статический блок бросит исключение, класс останется битым навсегда: первое обращение даст ExceptionInInitializerError, а все последующие — NoClassDefFoundError с тем же именем. Отсюда частая путаница: ошибка выглядит как «класса нет», а на самом деле он есть, просто его статика однажды упала.

Кто ищет классы: три загрузчика и делегирование

Загрузчиков в обычном приложении три, и они выстроены цепочкой.

  • Bootstrap — встроенный в виртуальную машину, написан не на Java. Загружает сам язык: java.lang, java.util, всё ядро платформы. В коде он представлен как null — это не ошибка, а соглашение.
  • Platform — остальные модули платформы, те, что не в ядре. До Java 9 назывался extension.
  • Application (он же system) — ваш код и библиотеки, то есть всё, что перечислено в classpath или на пути модулей.

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

live example

public class Loaders {
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader());        // null — bootstrap
        System.out.println(Loaders.class.getClassLoader());       // app
        System.out.println(Loaders.class.getClassLoader().getParent());  // platform
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Смысл правила — безопасность и предсказуемость. Положите в свой проект класс java.lang.String — он никогда не будет загружен: запрос уйдёт вверх, и bootstrap отдаст настоящий. Подменить класс платформы своим нельзя, и это сознательное ограничение.

Класс — это имя плюс загрузчик

Здесь начинается то, из-за чего появляется странное «Order нельзя привести к Order».

Виртуальная машина считает два класса одним и тем же, только если совпадает и полное имя, и загрузчик. Один и тот же файл, загруженный двумя разными загрузчиками, даёт два разных типа. Объект, созданный первым, нельзя присвоить переменной второго — будет ClassCastException, а в сообщении оба имени выглядят одинаково.

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

ClassNotFoundException и NoClassDefFoundError

Два имени, которые постоянно путают. Разница простая, если помнить, кто спрашивал.

ClassNotFoundException — обычное проверяемое исключение. Его бросают, когда класс ищут по имени в строке: Class.forName("com.example.Order"), loadClass, поиск драйвера. Виртуальная машина честно сообщает: такого имени нет ни у одного загрузчика в цепочке. Обычно это опечатка в имени или отсутствующая зависимость, о которой код знал заранее и потому обёрнут в try/catch.

NoClassDefFoundError — ошибка, а не исключение. Она означает: при компиляции класс был, при запуске его нет. Никто не спрашивал класс по строке, на него просто ссылался код. Причины на практике три:

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

Различить второе и третье помогает первое появление ошибки в логах: если самым первым был ExceptionInInitializerError, ищите причину в статическом блоке, а не в сборке.

Где это встречается в обычной работе

Приложение одним архивом. Обычный jar не умеет читать вложенные архивы, а собранное приложение Spring Boot — это как раз jar с jar-ами внутри. Поэтому фреймворк добавляет свой загрузчик, который умеет доставать классы из вложенных архивов, и подменяет им стандартный. Если вы распаковали такой архив и запустили классы напрямую — ничего не найдётся, хотя файлы на месте.

Плагины и модули. Каждый плагин грузят своим загрузчиком: так его классы не мешают соседям, а сам плагин можно выгрузить. Общий контракт при этом кладут в родительский загрузчик — иначе интерфейс из плагина и интерфейс из ядра окажутся разными типами.

Веб-контейнеры. Tomcat и подобные ему нарушают правило делегирования намеренно: загрузчик приложения сначала ищет класс у себя и только потом идёт к родителю. Так приложение может принести свою версию библиотеки, не спрашивая сервер. Обратная сторона — конфликты версий, которые проявляются только в контейнере.

Утечки памяти. Класс выгружается только вместе со своим загрузчиком, а загрузчик жив, пока на него есть ссылка. Достаточно одной статической ссылки из класса платформы или незачищенного ThreadLocal в пуле потоков — и при каждой перезагрузке приложения в памяти остаётся ещё одна копия всех его классов. Симптом — растущий Metaspace и OutOfMemoryError: Metaspace после нескольких перезагрузок без перезапуска процесса.

Коротко

  • Класс загружается не при старте, а при первом реальном обращении к нему; static final константы обращения не создают.
  • Путь класса: загрузка → связывание (проверка, подготовка, разрешение ссылок) → инициализация со статическими блоками.
  • Загрузчиков три — bootstrap (ядро платформы, виден как null), platform и application; каждый сначала делегирует поиск родителю.
  • Класс определяется парой «полное имя + загрузчик»: один файл в двух загрузчиках даёт два несовместимых типа.
  • ClassNotFoundException — класс искали по строке и не нашли; NoClassDefFoundError — он был при компиляции, но не нашёлся при запуске либо упал в статическом блоке.
  • Классы выгружаются вместе с загрузчиком: живая ссылка на загрузчик — это утечка всего его кода в Metaspace.

Что почитать дальше

  • Сборка мусора в Java — как освобождается память объектов и почему Metaspace живёт по своим правилам.
  • Исключения — чем ошибки отличаются от проверяемых исключений и почему Error не ловят по месту.
  • Инструменты разработчика Java — как собрать приложение так, чтобы нужные библиотеки оказались в архиве.
  • ООП в Java — типы, наследование и приведение, с которыми и связаны конфликты загрузчиков.