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

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

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

Обязательно

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

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

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

Отражение сюда тоже входит, но с оговоркой, которая позже понадобится. Class.forName("com.example.Order") класс инициализирует — статика выполнится. А classLoader.loadClass("com.example.Order") только найдёт и загрузит его: статический блок промолчит и подождёт первого настоящего обращения. Разница видна ровно там, где статика падает.

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

живой пример

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);   // обычное поле — вот теперь загрузится
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

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

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

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

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

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

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

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

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

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

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

живой пример

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
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Application просит родителя Platform просит родителя Bootstrap ядро, не нашёл Platform модули, не нашёл Application classpath, нашёл

Запрос поднимается по цепочке до самого верха, и только на обратном пути каждый загрузчик ищет сам; ваш класс достаётся application-загрузчику последним.

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

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

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

Order.class тип в загрузчике A первый прочитал тип в загрузчике B второй прочитал объект типа A приведение к типу B ClassCastException

Один файл, прочитанный двумя загрузчиками, даёт два разных типа с одинаковым именем; внизу видно, чем это кончается при приведении.

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

Увидеть это можно и без сервера приложений, в двадцать строк. Два загрузчика читают один и тот же файл класса из одного каталога; родителем обоим назначен null, то есть bootstrap, который про наш класс не знает, и каждый загружает Order сам:

import java.net.URL;
import java.net.URLClassLoader;

public class TwoLoaders {
    public static class Order {}

    public static void main(String[] args) throws Exception {
        URL[] where = {TwoLoaders.class.getProtectionDomain().getCodeSource().getLocation()};
        try (var first = new URLClassLoader(where, null); var second = new URLClassLoader(where, null)) {
            Class<?> a = first.loadClass("TwoLoaders$Order");
            Class<?> b = second.loadClass("TwoLoaders$Order");
            System.out.println(a.getName().equals(b.getName()) + " " + (a == b));
            Object order = a.getDeclaredConstructor().newInstance();
            System.out.println(b.isInstance(order));
            b.cast(order);
        }
    }
}

Вывод: true false, потом false, потом ClassCastException: Cannot cast TwoLoaders$Order to TwoLoaders$Order. Имена совпадают, объекты Class разные, объект первого типа для второго чужой. При обычном приведении через (Order) obj сообщение длиннее и полезнее: JVM дописывает, в каком загрузчике живёт каждый из двух классов, и именно по этой приписке в логе понимают, что дело в загрузчиках.

ClassNotFoundException и NoClassDefFoundError

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

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

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

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

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

Когда по логу непонятно, откуда взялся класс и взялся ли вообще, JVM умеет рассказать это сама. Флаг -Xlog:class+load=info (старое написание -verbose:class) печатает каждый загруженный класс и источник, из которого он прочитан: путь к jar или jrt:/java.base для платформы. По этому списку видно, из какого архива приехал класс с конфликтующей версией, загрузился ли вообще класс, на который ссылается код, и сколько классов поднимает приложение на старте. Вывод большой, тысячи строк, поэтому его пишут в файл (-Xlog:class+load=info:file=classes.log) и ищут по имени.

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

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

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

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

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

Контекстный загрузчик потока. У каждого потока есть свой указатель на загрузчик, Thread.currentThread().getContextClassLoader(), и он существует ради одной задачи: дать коду платформы найти классы приложения. ServiceLoader, DriverManager для JDBC, JNDI живут в bootstrap-загрузчике и по правилу делегирования ваш драйвер увидеть не могут: делегирование идёт вверх, а драйвер лежит внизу. Поэтому они спрашивают контекстный загрузчик текущего потока, который по умолчанию равен application-загрузчику. Обычно об этом не думают, пока код не уйдёт в чужой пул потоков или в плагин со своим загрузчиком: тогда ServiceLoader вдруг «не находит» реализацию, которая лежит рядом, и лечится это установкой контекстного загрузчика на время вызова. Spring использует его же при загрузке ресурсов и классов по имени, так что в системах плагинов на Spring это первое, что проверяют.

Дополнительно: при первом чтении можно пропустить

Глубже: аннотации и отражение: на чём стоит Springрасширенное

«Вызвали класс через отражение» встречается в этой статье, а следующая фаза целиком про Spring, который на аннотациях и отражении стоит. Обзор двух механизмов, без которого магия фреймворка остаётся магией.

Аннотация это пометка на классе, методе, поле или параметре, которая сама ничего не делает. @Override читает компилятор и проверяет, что метод правда переопределяет. @Deprecated читает компилятор и IDE. А @Service, @Transactional, @Test читает не компилятор, а библиотека во время выполнения, и для этого у аннотации указано @Retention(RUNTIME): она сохраняется в байткоде и доступна программе. Своя аннотация объявляется через @interface с полями-параметрами и сама по себе ничего не делает, пока кто-то её не прочитает.

Отражение это доступ к устройству класса во время выполнения: получить объект Class по имени или по экземпляру, перечислить его методы, поля, конструкторы и аннотации, создать экземпляр, вызвать метод по имени, прочитать приватное поле. То, что компилятор проверяет статически, отражение делает динамически, и потому оно медленнее и без проверки типов.

живой пример

import java.lang.annotation.*;
import java.lang.reflect.Method;

public class ReflectionDemo {
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    @interface Scheduled { String cron(); }

    static class Jobs {
        @Scheduled(cron = "0 3 * * *") public void rebuildReport() { System.out.println("отчёт пересобран"); }
        public void helper() {}
    }

    public static void main(String[] args) throws Exception {
        Object jobs = Class.forName("ReflectionDemo$Jobs").getDeclaredConstructor().newInstance();
        for (Method m : jobs.getClass().getDeclaredMethods()) {
            Scheduled s = m.getAnnotation(Scheduled.class);
            if (s != null) {
                System.out.println(m.getName() + " по расписанию " + s.cron());
                m.invoke(jobs);
            }
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Это и есть Spring в миниатюре: при старте он сканирует классы из classpath, читает аннотации через отражение, создаёт экземпляры конструктором, который нашёл сам, подставляет зависимости в параметры и вызывает методы по расписанию или по HTTP-запросу. Прокси для @Transactional это тоже отражение: сгенерированный подкласс перехватывает вызов и оборачивает его транзакцией, поэтому вызов изнутри того же класса мимо прокси не работает, о чём статья про транзакции.

Цена: отражение обходит private, ломает проверку типов до выполнения и медленнее прямого вызова, а ошибка в имени метода обнаруживается при запуске, а не при компиляции; в модулях JPMS доступ к закрытому по отражению запрещён, пока модуль его не открыл. В прикладном коде отражение почти не пишут, но читать стек-трейсы с Method.invoke и понимать, откуда Spring знает про ваш класс, нужно каждый день.

Коротко

  • Класс загружается не при старте, а при первом реальном обращении к нему; static final константы обращения не создают.
  • Путь класса: загрузка → связывание (проверка, подготовка, разрешение ссылок) → инициализация со статическими блоками.
  • Загрузчиков три — bootstrap (ядро платформы, виден как null), platform и application; каждый сначала делегирует поиск родителю.
  • Класс определяется парой «полное имя + загрузчик»: один файл в двух загрузчиках даёт два несовместимых типа.
  • ClassNotFoundException — класс искали по строке и не нашли; NoClassDefFoundError — он был при компиляции, но не нашёлся при запуске либо упал в статическом блоке.
  • Классы выгружаются вместе с загрузчиком: живая ссылка на загрузчик — это утечка всего его кода в Metaspace.
  • Аннотация сама ничего не делает: её читает компилятор или библиотека во время выполнения при @Retention(RUNTIME); отражение находит классы, аннотации и методы по имени и вызывает их, так Spring создаёт бины и прокси; цена это скорость, обход private и ошибки при запуске вместо компиляции.
  • Два загрузчика с одним файлом класса дают два разных Class: имена равны, a == b ложно, cast падает; сообщение при (Order) obj называет оба загрузчика.
  • -Xlog:class+load=info печатает каждый загруженный класс и его источник — так находят, из какого jar приехала не та версия.
  • Контекстный загрузчик потока нужен коду платформы (ServiceLoader, JDBC), чтобы найти классы приложения снизу; в чужом пуле потоков и плагинах его выставляют явно.

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

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