Программа собралась, тесты прошли, на сервере запускается — и падает с
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-загрузчику последним.
Класс — это имя плюс загрузчик
Здесь начинается то, из-за чего появляется странное «Order нельзя привести к
Order».
Виртуальная машина считает два класса одним и тем же, только если совпадает и
полное имя, и загрузчик. Один и тот же файл, загруженный двумя разными
загрузчиками, даёт два разных типа. Объект, созданный первым, нельзя присвоить
переменной второго — будет 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 — типы, наследование и приведение, с которыми и связаны конфликты загрузчиков.