В предыдущей статье о жизненном цикле класса мы дошли до этапа Loading и остановились на фразе: JVM должна найти бинарное представление класса и создать из него класс внутри runtime. Но кто именно его ищет?
Допустим, код впервые обращается к:
ru.acdev.service.UserService
Где JVM возьмёт UserService.class? В каталоге приложения? В JAR-файле? В модуле? А если этот класс вообще нужно скачать из сети или сгенерировать на лету? Этой частью занимается механизм ClassLoader-ов.
Упрощённо его работу можно представить так:
com.acdev.service.UserService
↓
ClassLoader
↓
бинарное представление
↓
java.lang.Class
Формулировка «ClassLoader превращает имя класса в объект Class» удобна для первого объяснения, хотя технически процесс немного шире: загрузчик ищет или создаёт бинарное представление класса, а JVM на его основе создаёт runtime-класс. Каждый созданный Class при этом связан с ClassLoader-ом, который его определил.
Это последнее уточнение позже окажется гораздо важнее, чем кажется.
Зачем вообще нужен ClassLoader
Если бы Java могла загружать классы только из одного каталога, ClassLoader выглядел бы избыточной абстракцией. Но класс необязательно лежит рядом с приложением в виде обычного .class: загрузчик может получать bytecode:
- из директории;
- из JAR-файла;
- из module path;
- из сети;
- из собственного хранилища;
- из массива байтов, сгенерированного прямо во время работы приложения.
Спецификация JVM прямо допускает пользовательские ClassLoader-ы, которые получают классы из нестандартных источников — например, скачивают их или генерируют динамически.
В обычном Spring-приложении мы большую часть времени об этом не думаем. Запускаем:
java -jar application.jar
и классы каким-то образом появляются. Но под капотом загрузка не сводится к одному глобальному поиску:
найти любой UserService.class
и использовать его
У JVM одновременно может существовать несколько ClassLoader-ов, каждый со своей областью видимости и своим набором классов.
Именно поэтому первым делом стоит разобраться со встроенными загрузчиками.
Три основных ClassLoader-а современной Java
Для обычного современного JDK полезно держать в голове такую схему:
Bootstrap ClassLoader
↑
Platform ClassLoader
↑
Application ClassLoader
Стрелка здесь означает отношение delegation: нижний загрузчик обычно сначала обращается к родителю, прежде чем искать класс самостоятельно.
Но это именно удобная базовая модель. После появления модульной системы реальные правила встроенных загрузчиков стали немного сложнее, и Platform ClassLoader в некоторых случаях может обращаться не только вверх по классической иерархии. Для обычного classpath-приложения схема выше всё ещё хорошо объясняет основную механику.
Посмотреть цепочку можно прямо из приложения:
public class ClassLoaderExample {
public static void main(String[] args) {
ClassLoader app =
ClassLoaderExample.class.getClassLoader();
System.out.println(app);
System.out.println(app.getParent());
System.out.println(app.getParent().getParent());
}
}
На современной JDK результат будет похож на:
jdk.internal.loader.ClassLoaders$AppClassLoader@...
jdk.internal.loader.ClassLoaders$PlatformClassLoader@...
null
Последний null здесь особенно интересен.
Bootstrap ClassLoader
Bootstrap ClassLoader находится в основании всей цепочки.
Он загружает фундаментальные классы runtime. Например, java.lang.String относится к классам, определяемым bootstrap loader-ом в обычной современной JDK. Сам bootstrap loader является встроенной частью runtime и в API ClassLoader обычно представлен как null.
Проверить:
public class BootstrapExample {
public static void main(String[] args) {
System.out.println(
String.class.getClassLoader()
);
System.out.println(
Object.class.getClassLoader()
);
}
}
Получим:
null
null
Отсюда иногда делают странный вывод:
Stringзагружен без ClassLoader-а.
Но null здесь — специальное представление Bootstrap ClassLoader в Java API. Он просто не представлен обычным экземпляром java.lang.ClassLoader так же, как пользовательские загрузчики.
Поэтому:
String.class.getClassLoader() == null
означает не отсутствие загрузки, а bootstrap-defined class.
Bootstrap сегодня и Bootstrap в Java 8 — не совсем одно и то же
После появления модульной системы распределение платформенных классов между встроенными загрузчиками изменилось. В современных JDK Bootstrap определяет критические модули вроде java.base, но далеко не обязательно все классы платформы Java. Часть платформенных классов определяется Platform ClassLoader-ом. Oracle отдельно предупреждает, что приложению не стоит зависеть от того, какой именно из этих двух загрузчиков определяет конкретный platform class.
Поэтому правило:
все классы JDK грузит Bootstrap
слишком грубое. Точнее сформулировать так:
Bootstrap находится в основании загрузки и определяет ядро runtime.
Platform ClassLoader
Следующий уровень — Platform ClassLoader.
Получить его можно напрямую:
ClassLoader platform =
ClassLoader.getPlatformClassLoader();
System.out.println(platform);
На современной JDK его встроенное имя — platform. Он отвечает за platform classes: Java SE API, часть внутренних реализаций и JDK-specific runtime classes, видимых через этот загрузчик.
Но если давно работаешь с Java, название может показаться непривычным.
До Java 9 в привычной иерархии существовал:
Extension ClassLoader
После перехода на модульную систему extension mechanism был удалён, а бывший Extension ClassLoader получил другую роль и стал называться:
Platform ClassLoader
То есть удобная историческая шпаргалка выглядит так:
Java 8
----------------------
Bootstrap
Extension
Application
Java 9+
----------------------
Bootstrap
Platform
Application
При этом поменялось не только название. Начиная с JDK 9 Platform и Application loaders больше не обязаны быть URLClassLoader, поэтому код, который раньше без проверки кастовал системный загрузчик:
URLClassLoader loader =
(URLClassLoader) ClassLoader.getSystemClassLoader();
после перехода с Java 8 вполне мог перестать работать.
Это хороший пример того, почему лучше программировать против API ClassLoader, а не против конкретной внутренней реализации JDK.
Application ClassLoader
Для разработчика приложения чаще всего интереснее Application ClassLoader. Его ещё называют:
System ClassLoader
Получить его можно так:
ClassLoader app =
ClassLoader.getSystemClassLoader();
Или посмотреть загрузчик собственного класса:
System.out.println(
MyApplication.class.getClassLoader()
);
В типичном приложении это будет один и тот же application/system loader.
Он обычно отвечает за классы самого приложения и зависимости, доступные через application class path и module path. Platform ClassLoader является его родителем или одним из предков, поэтому platform classes становятся доступны приложению через delegation.
Например:
public class LoaderTree {
public static void main(String[] args) {
ClassLoader loader =
LoaderTree.class.getClassLoader();
while (loader != null) {
System.out.println(loader);
loader = loader.getParent();
}
System.out.println("Bootstrap: null");
}
}
Мы увидим примерно:
jdk.internal.loader.ClassLoaders$AppClassLoader@14dad5dc
jdk.internal.loader.ClassLoaders$PlatformClassLoader@7adf9f5f
Bootstrap: null
И вот здесь возникает центральный вопрос:
Почему Application ClassLoader вообще спрашивает родителя? Почему сразу не поискать класс у себя?
Parent Delegation
Предположим, приложение обращается к:
java.lang.String
Application ClassLoader теоретически мог бы начать искать такой класс в собственном classpath.
Но стандартная модель ClassLoader работает иначе: при поиске класса загрузчик обычно сначала делегирует запрос родителю и только если родитель не смог найти класс, пытается определить его самостоятельно. Это поведение лежит в основе стандартной реализации loadClass().
Упрощённо:
loadClass("java.lang.String")
Application
↓
Platform
↓
Bootstrap
↓
найден
↑
Platform
↑
Application
Application ClassLoader не загружает ещё одну копию String. Он получает уже найденный родительским уровнем класс.
Для собственного класса картина будет другой:
loadClass("com.acdev.UserService")
Application
↓
Platform
↓
Bootstrap
↓
не найден
↑
Platform
↓
не найден
↑
Application
↓
ищет у себя
↓
UserService.class
Если смотреть на эту механику из ClassLoader.loadClass(), алгоритм концептуально выглядит примерно так:
Class<?> loadClass(String name) {
Class<?> loaded = findLoadedClass(name);
if (loaded != null) {
return loaded;
}
try {
return parent.loadClass(name);
} catch (ClassNotFoundException ignored) {
}
return findClass(name);
}
Реальный ClassLoader учитывает больше деталей: bootstrap loader, блокировки загрузки, необязательное resolution и другие нюансы. Но для понимания parent delegation этого псевдокода достаточно. Документация ClassLoader описывает именно родительскую delegation model, а findClass() вызывается после того, как поиск через родителя не дал результата.
Почему сначала родитель
На первый взгляд можно спросить: зачем вообще ходить вверх, если класс приложения всё равно лежит у Application ClassLoader? Одна из причин — стабильность базовых типов.
Допустим, в приложение кто-то положил файл:
java/lang/String.class
и рассчитывает, что его String подменит настоящий JDK-класс.
При обычной parent-first модели запрос сначала дойдёт до Bootstrap ClassLoader, который найдёт платформенный String. Application ClassLoader до собственной копии просто не дойдёт.
Есть и дополнительная защита на уровне API: ClassLoader.defineClass() запрещает пользовательскому загрузчику определять классы в пакетах, имя которых начинается с java. — такая попытка приводит к SecurityException.
Так что parent delegation — не единственная защита системных классов, но она создаёт важное свойство: приложение обычно использует платформенные типы, определённые родительскими загрузчиками, а не свои случайные копии.
Особенно это важно для фундаментальных типов:
java.lang.Object
java.lang.String
java.lang.Class
Если бы каждый plugin или библиотека свободно приносили собственный Object, взаимодействие между классами очень быстро стало бы невозможным.
Parent delegation — это не закон JVM
Здесь стоит сделать одно уточнение. Иногда parent delegation описывают так, будто JVM жёстко заставляет каждый ClassLoader всегда действовать:
parent first
Это не совсем так: ClassLoader предоставляет стандартную модель delegation и стандартную реализацию loadClass(), но пользовательский загрузчик способен реализовать другую стратегию. Сама JVM допускает, что user-defined loader либо определит класс напрямую через defineClass(), либо делегирует загрузку другому loader-у.
Поэтому существуют системы с child-first или более сложной схемой загрузки.
Такое встречается, например, там, где разные части приложения должны иметь изолированные версии библиотек:
Plugin A
└── jackson 2.x
Plugin B
└── jackson 3.x
Но ломать обычный parent delegation без необходимости я бы не стал. Чем сложнее граф загрузчиков, тем интереснее становятся диагностика ClassNotFoundException, LinkageError и особенно ситуации, когда одинаковые с виду типы перестают быть совместимыми. До этого мы ещё дойдём.
loadClass() и findClass() — не одно и то же
Это один из вопросов, которые мне действительно нравится задавать разработчикам уровня middle.
Есть два метода:
loadClass(...)
и:
findClass(...)
По названию кажется, что делают они почти одно и то же, но роли разные.
loadClass()
loadClass() — высокоуровневый вход в механизм загрузки и отвечает за общую стратегию:
класс уже загружен?
↓
спросить родителя
↓
если родитель не нашёл
↓
попробовать найти самому
Именно поэтому JVM вызывает loadClass() при необходимости разрешить ссылку на класс.
findClass()
findClass() решает более узкую задачу:
попробуй найти и определить этот класс средствами конкретного загрузчика.
Стандартная реализация findClass() сама ничего не загружает и выбрасывает ClassNotFoundException. Метод специально предназначен для переопределения загрузчиками, которые хотят сохранить стандартную delegation model.
Поэтому custom loader обычно выглядит не так:
@Override
public Class<?> loadClass(String name) {
// Полностью переписываем всю механику
}
а так:
public final class MyClassLoader
extends ClassLoader {
@Override
protected Class<?> findClass(String name)
throws ClassNotFoundException {
byte[] bytecode = loadBytes(name);
return defineClass(
name,
bytecode,
0,
bytecode.length
);
}
private byte[] loadBytes(String name)
throws ClassNotFoundException {
// Получаем bytecode из своего источника
throw new ClassNotFoundException(name);
}
}
Мы переопределили только собственный способ поиска, а стандартный loadClass() продолжает заниматься delegation. Получается хорошее разделение ответственности:
loadClass()
│
├── orchestration
├── delegation
└── cache/check loaded
↓
findClass()
│
└── где конкретно взять bytecode
В большинстве custom ClassLoader-ов именно это и нужно.
Откуда появляется defineClass()
Внутри findClass() мы вызвали:
defineClass(...)
Этот метод стоит отдельно запомнить. Если findClass() отвечает на вопрос:
где взять класс?
то defineClass() делает примерно следующее:
вот массив байтов — создай из него runtime-класс, определённый этим ClassLoader-ом.
В JVMS пользовательская загрузка описана именно через получение байтов ClassFile и передачу их в ClassLoader.defineClass(). После этого JVM создаёт соответствующий класс с этим loader-ом в качестве defining loader.
Упрощённый custom loading поэтому выглядит так:
binary name
↓
findClass()
↓
byte[]
↓
defineClass()
↓
Class<?>
И вот теперь мы подходим к самому неожиданному свойству ClassLoader-ов.
Имени класса недостаточно
Допустим, есть обычный класс:
package com.acdev;
public class User {
}
В Java-коде его имя:
com.acdev.User
Кажется логичным, что в одной JVM это всегда один и тот же тип, но JVM определяет класс не только по binary name. Для runtime identity важна пара:
binary name
+
defining ClassLoader
Спецификация JVM прямо определяет класс или интерфейс после загрузки парой из его binary name и defining loader.
То есть:
com.acdev.User
loaded by Loader A
и:
com.acdev.User
loaded by Loader B
для JVM могут быть двумя разными классами, даже если оба загрузчика прочитали буквально один и тот же .class.
Один .class, два типа
Представим два независимых загрузчика:
ClassLoader loader1 = createLoader();
ClassLoader loader2 = createLoader();
Class<?> class1 =
loader1.loadClass("com.acdev.User");
Class<?> class2 =
loader2.loadClass("com.acdev.User");
Если оба loader-а действительно самостоятельно определили класс, а не делегировали его одному общему родителю, то:
System.out.println(class1 == class2);
получим:
false
Имя одинаковое:
System.out.println(class1.getName());
System.out.println(class2.getName());
com.acdev.User
com.acdev.User
Но identity разная:
(com.acdev.User, loader1)
!=
(com.acdev.User, loader2)
Это уже не две копии одного Class-объекта, а два разных runtime-типа.
User cannot be cast to User
Именно отсюда появляется одна из самых красивых Java-ошибок:
ClassCastException:
com.acdev.User cannot be cast to com.acdev.User
На первый взгляд сообщение выглядит сломанным: как
User
может быть несовместим с
User
Ответ становится очевиднее, если полностью записать identity:
com.acdev.User
loaded by PluginClassLoader@1
и:
com.acdev.User
loaded by PluginClassLoader@2
Для JVM это разные типы.
Условно:
Object userFromLoader1 =
class1.getConstructor().newInstance();
Object userFromLoader2 =
class2.cast(userFromLoader1);
может закончиться ClassCastException, потому что фактически мы делаем не
User -> User
а
User@loader1 -> User@loader2
И второй вариант уже совсем не выглядит абсурдно.
Где такое встречается в настоящих приложениях
В обычном Spring Boot JAR большинство прикладных классов загружает один Application ClassLoader, поэтому разработчик годами может вообще не сталкиваться с этой особенностью. Она становится заметна там, где нужна изоляция. Например:
Application
├── Plugin A ClassLoader
│ └── plugin.jar
│
└── Plugin B ClassLoader
└── plugin.jar
Или в application server:
Server ClassLoader
├── Application A ClassLoader
└── Application B ClassLoader
Каждое приложение может иметь собственную версию:
com.fasterxml.jackson.databind.ObjectMapper
и эти типы вовсе не обязаны совпадать.
За годы работы с Java я несколько раз встречал ошибки, где название класса в exception выглядело абсолютно правильным, JAR вроде тоже был нужный, но объект всё равно невозможно было привести к ожидаемому типу. В таких ситуациях после проверки имени класса следующий вопрос у меня уже давно не:
какая версия JAR?
а:
кто загрузил этот класс?
Проверяется это элементарно:
System.out.println(
object.getClass().getClassLoader()
);
System.out.println(
ExpectedType.class.getClassLoader()
);
Если ClassLoader-ы разные, расследование резко становится интереснее.
getClassLoader() — простой диагностический инструмент
Проверить загрузчик класса можно в любой момент:
ClassLoader loader =
UserService.class.getClassLoader();
System.out.println(loader);
Я часто добавляю ещё:
System.out.println(
UserService.class
.getProtectionDomain()
.getCodeSource()
);
Первое показывает, кто определил класс, второе часто помогает понять, из какого JAR или каталога он приехал. Для проблем вида:
NoSuchMethodError
ClassCastException
ClassNotFoundException
это бывает гораздо полезнее десятого просмотра pom.xml.
Например, два одинаковых класса могут находиться:
/app/lib/common.jar
/plugin/lib/common.jar
и сами файлы выглядят нормально. Проблема становится понятнее только после того, как выясняется:
common.jar #1 → AppClassLoader
common.jar #2 → PluginClassLoader
Тогда структура runtime становится понятнее.
ClassLoader отвечает ещё и за ресурсы
Хотя статья посвящена классам, у ClassLoader есть ещё одна практическая обязанность — поиск ресурсов.
Например:
InputStream stream =
getClass()
.getClassLoader()
.getResourceAsStream(
"config/default.json"
);
ClassLoader умеет искать не только .class, но и другие данные приложения: конфигурацию, изображения и любые ресурсы, доступные через его search path. Стандартный ClassLoader применяет delegation model и при поиске ресурсов.
Это одна из причин, почему проблема:
resource exists in JAR,
but getResource() returns null
иногда вообще не связана с самим путём: файл может быть доступен одному загрузчику и не виден другому. Архитектура та же, только вместо Class<?> ищем URL или stream.
Почему родитель — не обязательно тот, кто определил класс
Есть ещё один терминологический нюанс, который полезен ближе к senior-уровню.
Допустим:
Application ClassLoader
↓ delegates
Platform ClassLoader
↓ delegates
Bootstrap ClassLoader
↓
loads SomeClass
Запрос на загрузку начал Application loader, но сам SomeClass определил Bootstrap. Поэтому JVMS различает:
initiating loader
и:
defining loader
Loader, который начал цепочку загрузки, не обязан быть тем, кто в итоге определил класс. При delegation несколько loader-ов участвуют в процессе, но runtime identity класса определяется именно его defining loader.
Для повседневной разработки это различие требуется редко, а вот когда начинаются собственные ClassLoader-ы, plugin systems или серьёзная диагностика class loading — термин становится полезным.
Не полагайтесь на конкретный класс Application ClassLoader
В старом коде периодически встречается:
URLClassLoader loader = (URLClassLoader) ClassLoader.getSystemClassLoader();
На Java 8 это часто работало, но начиная с Java 9 Application и Platform ClassLoader-ы реализованы внутренними классами JDK и не являются URLClassLoader. Миграционная документация Oracle отдельно предупреждает об этом изменении.
Поэтому код:
ClassLoader loader = ClassLoader.getSystemClassLoader();
нормальный, а предположение
System ClassLoader == URLClassLoader
— уже нет.
Если нужен собственный загрузчик JAR-файлов, можно явно создать:
URL[] urls = {
Path.of("plugin.jar")
.toUri()
.toURL()
};
try (URLClassLoader loader =
new URLClassLoader(urls, ClassLoader.getSystemClassLoader())) {
Class<?> plugin = loader.loadClass("com.acdev.Plugin");
}
URLClassLoader по-прежнему существует и умеет загружать классы и ресурсы из директорий и JAR-файлов. Просто встроенный Application ClassLoader больше не обязан быть его экземпляром.
Что происходит при loadClass()
Теперь можно собрать всю статью в один маршрут.
Приложению понадобился:
com.acdev.UserService
Application ClassLoader получает запрос:
loadClass("com.acdev.UserService");
Дальше концептуально:
1. Уже загружен?
│
├─ да → вернуть существующий Class
│
└─ нет
↓
2. Может загрузить родитель?
│
├─ да → вернуть родительский Class
│
└─ нет
↓
3. findClass()
↓
4. найти bytecode
↓
5. defineClass()
↓
6. получить Class<?>
Если используется стандартная parent-first модель, пользовательский findClass() вступает в игру только после неудачной попытки родителя.
Эта схема сразу объясняет несколько вещей. Наш java.lang.String не подменяет JDK-класс, потому что родитель находит его раньше. Обычно мы переопределяем findClass(), а не loadClass(), потому что стандартная orchestration уже реализована. А один bytecode может породить два несовместимых типа, если defineClass() вызывают разные defining loader-ы.
Три вопроса — одна модель.
Что спрашивают на собеседовании
Если не хочется учить определения отдельно, я бы проверял понимание такими вопросами:
- Какие встроенные ClassLoader-ы есть в современной Java?
- Почему
String.class.getClassLoader()возвращаетnull? - Чем Platform ClassLoader отличается от старого Extension ClassLoader?
- Кто обычно загружает классы приложения?
- Как работает parent delegation?
- Зачем сначала спрашивать родителя?
- Является ли parent-first обязательным для любого custom ClassLoader?
- Чем
loadClass()отличается отfindClass()? - Зачем нужен
defineClass()? - Почему custom ClassLoader обычно переопределяет
findClass(), а не весьloadClass()? - Из чего складывается runtime identity класса?
- Как возможно
User cannot be cast to User? - Может ли один binary name существовать в JVM несколько раз?
- Почему нельзя полагаться, что system class loader — это
URLClassLoader? - Чем defining loader отличается от initiating loader?
Если на последний блок можно ответить, рисуя одну схему ClassLoader-ов, а не вспоминая пятнадцать отдельных фактов, значит модель уже уложилась.
Какую модель оставить в голове
ClassLoader — не просто способ прочитать .class с диска. Это часть механизма, который определяет, откуда берётся класс, кому он виден и какой runtime identity он получает.
Для обычного приложения достаточно начать с:
Bootstrap
↑
Platform
↑
Application
и parent delegation:
сначала родитель
потом собственный поиск
В стандартной реализации:
loadClass()
↓
delegation
↓
findClass()
↓
defineClass()
А самое полезное правило всей статьи я бы оставил таким:
Class identity = binary name + defining ClassLoader
Поэтому:
com.acdev.User @ Loader A
и:
com.acdev.User @ Loader B
могут быть двумя совершенно разными типами.
После этого становится гораздо понятнее, откуда берутся plugin isolation, несколько версий одной библиотеки, загадочные ClassCastException и проблемы, которые невозможно объяснить одним только classpath.