Загрузка классов в JVM
Загрузил один и тот же User.class двумя своими загрузчиками и привёл объект от первого к типу от второго. Получил: class User cannot be cast to class User (User is in unnamed module of loader 'app-A' @677327b6; User is in unnamed module of loader 'app').
Тема кажется теорией ровно до первого такого сообщения в проде. Проверяют три вещи: фазы загрузки и её ленивость, иерархию загрузчиков с делегированием, и умение различать ошибки-близнецы.
// Формулировки: «как класс попадает в JVM?», «чем ClassNotFoundException отличается от NoClassDefFoundError?», «почему X не кастится к X?», «что течёт при редеплое?».
Фазы и делегирование родителю
Попросил свой загрузчик выдать java.lang.String. Получил класс, у которого getClassLoader() вернул null, а сравнение с String.class по == дало true - тот самый, системный. Потом попробовал определить собственный класс с именем java.lang.Evil и получил SecurityException: Prohibited package name: java.lang.
Так работает делегирование родителю: загрузчик сперва спрашивает родителя и грузит сам, только если тот не смог. Наверху цепочки bootstrap с ядром JDK, под ним platform, под ним application - тот, что грузит твой classpath, то есть список папок и jar-файлов, где JVM ищет классы приложения. Подсунуть свой java.lang.String не выйдет с двух сторон: сначала не даст делегирование, а если обойти его - не даст проверка имени пакета.
Сам класс грузится лениво, при первом использовании, и проходит три фазы. Loading - байты .class-файла превращаются в объект Class. Linking - verify проверяет байткод (инструкции для виртуальной машины, в которые javac перевёл твой исходник) на согласованность, prepare раздаёт статическим полям значения по умолчанию, resolve разрешает ссылки на другие классы. Initialization - отрабатывают статические блоки, строго один раз и под локом. Тяжёлая логика в статическом блоке взорвётся в непредсказуемый момент: тогда, когда до класса впервые дотянутся.
- делегирование родителю
- родительский загрузчик пробует первым
- initialization
- исполнение статических блоков, один раз на класс
Класс - это имя ПЛЮС загрузчик
Тот же замер в цифрах: сравнение классов по == дало false, проверка isInstance для чужого объекта - false, а статическое поле counter я выставил в 5 через первый класс и прочитал 0 через второй. Одно имя, один и тот же файл, две независимые сущности со своей статикой.
JVM идентифицирует класс парой «полное имя плюс загрузчик». Отсюда и вся мистика. Хорошая новость: с Java 9 сообщение об ошибке само называет обоих виновников - loader 'app-A' @677327b6 против loader 'app'. Увидел одинаковые имена и разные загрузчики в скобках - диагноз готов, дальше ищешь, откуда взялась вторая копия библиотеки.
Родом это обычно из инверсии делегирования: Tomcat и плагинные системы сознательно грузят сначала своё, потом родительское, чтобы вебапп нёс собственные версии библиотек. Цена - вот такие классы-двойники и утечки при редеплое.
Class<?> c1 = new Loader("A").loadClass("User");
Class<?> c2 = new Loader("B").loadClass("User");
c1 == c2; // false
c2.isInstance(c1.getDeclaredConstructor().newInstance()); // false
// counter через A = 5, через B = 0 - статика раздельная- идентичность класса
- полное имя плюс загрузчик, а не одно имя
Утечка загрузчика при редеплое, в цифрах
Загрузил User.class тремя тысячами разных загрузчиков и оставил по одному живому объекту от каждого. Было 722 класса в памяти и 635 КиБ Metaspace - стало 3982 класса и 5607 КиБ. Потом отпустил ссылки и позвал сборку: выгружено 3000 классов, Metaspace откатился к 1382 КиБ.
Считаем: 3260 лишних классов съели около 5 МиБ, примерно по полтора килобайта на класс. Немного. Но это ОДИН класс на загрузчик, а реальный вебапп несёт тысячи классов, и каждый редеплой добавляет полный комплект.
Ключевое во втором замере: классы выгрузились, как только загрузчик стал недостижим. Загрузчик держит все свои классы, а каждый класс держит свой загрузчик, поэтому одна живая ссылка снаружи - из пула потоков, ThreadLocal, реестра драйверов, статики сервера - удерживает весь комплект целиком. Metaspace растёт от редеплоя к редеплою, и это уже утечка, а не «нормальный рост».
- утечка загрузчика
- одна внешняя ссылка удерживает все классы вебаппа
Ошибки-близнецы: читаем точные тексты
Сделал класс, у которого статический блок падает, и обратился к нему трижды. Первое обращение: ExceptionInInitializerError, причина - NumberFormatException. Второе и третье: NoClassDefFoundError: Could not initialize class Config.
Вот почему в логах ищут ПЕРВУЮ ошибку, а не последнюю: настоящая причина называется один раз, а дальше JVM просто помнит, что класс мёртв, и отвечает уже другим типом ошибки. Класс после упавшей инициализации не воскресает до рестарта.
Развилка простая. ClassNotFoundException - checked-исключение, то есть такое, которое компилятор заставляет либо ловить, либо объявлять в сигнатуре. Класс просили по имени через Class.forName или loadClass и не нашли: типично для рефлексии (обращения к классам по строковому имени во время работы программы), конфигов и забытого драйвера. NoClassDefFoundError - класс был при компиляции и пропал в рантайме либо умер на инициализации. NoSuchMethodError и IncompatibleClassChangeError - бинарная несовместимость: собрано против одной версии библиотеки, в рантайме другая.
static class Config {
static final int PORT;
static { PORT = Integer.parseInt(System.getenv("PORT")); } // упало
}
// обращение 1: ExceptionInInitializerError, cause = NumberFormatException
// обращение 2: NoClassDefFoundError: Could not initialize class Config
// обращение 3: NoClassDefFoundError: Could not initialize class Config- ExceptionInInitializerError
- упал статический блок, класс мёртв до рестарта
- NoSuchMethodError
- в рантайме другая версия библиотеки, чем при сборке
Как отвечать: «Чем ClassNotFoundException отличается от NoClassDefFoundError?»
ClassNotFoundException - checked-исключение при явной загрузке по имени: Class.forName или loadClass не нашли класс в classpath, типично для рефлексии и подключаемых драйверов. NoClassDefFoundError - ошибка самой JVM: класс был при компиляции, а в рантайме его нет. И у неё есть коварный второй сценарий, который я проверял руками: класс на месте, но его статическая инициализация упала. Первое обращение даёт ExceptionInInitializerError с настоящей причиной, а второе и все следующие - уже NoClassDefFoundError со словами «Could not initialize class». Поэтому при таком падении я ищу в логах первую ошибку, а не последнюю. Если же летит NoSuchMethodError, то это про другое: собрано против одной версии библиотеки, а в classpath оказалась другая.
Почему это сильный ответ: различие проведено по механике - явный запрос против ссылки из кода, назван неочевидный сценарий с упавшим статическим блоком и дан диагностический приём вместо пересказа определений.
На чём валят
- −«X не кастится к X - это глюк JVM». Это две копии класса из разных загрузчиков; имена загрузчиков написаны прямо в сообщении.
- −Списать NoClassDefFoundError на classpath, когда упал статический инициализатор. Смотри первую ошибку в логах, там настоящая причина.
- −Тяжёлая логика или обращение к сети в статическом блоке. Класс взорвётся при первом касании, а момент непредсказуем.
- −Две версии библиотеки в зависимостях. Локально работает, в проде NoSuchMethodError - победила другая версия.
- −Глобальная ссылка на объект вебаппа из пула или ThreadLocal. Редеплой не выгрузит старый загрузчик, и Metaspace будет расти каждый раз.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 16, остальные разбираются в тренажёре.
- Чем ClassNotFoundException отличается от NoClassDefFoundError?A)Это два имени одной ошибки; какое из них бросится, зависит только от версии установленной JVMB)ClassNotFoundException бросается компилятором, а NoClassDefFoundError — во время написания кода в IDEC)Первое — при явной загрузке в рантайме; второе — класс был при компиляции, но пропал в рантаймеD)NoClassDefFoundError возникает для интерфейсов, а ClassNotFoundException — только для обычных классов
показать ответ и разбор
+C)Первое — при явной загрузке в рантайме; второе — класс был при компиляции, но пропал в рантайме// разбор: ClassNotFoundException (проверяемое исключение) возникает при ЯВНОЙ динамической загрузке — Class.forName("..."), загрузчик по имени — когда класса нет на classpath. NoClassDefFoundError (Error) — когда класс БЫЛ доступен при компиляции, компилятор на него сослался, но в РАНТАЙМЕ его не оказалось (или его статическая инициализация упала). Практический вывод: CNFE — обычно неверное имя/рефлексия/плагин; NoClassDefFoundError — рассинхрон classpath между сборкой и запуском.
- Когда происходит инициализация класса (выполнение static-инициализаторов)?A)Сразу при старте JVM для всех классов приложения, ещё до выполнения метода main()B)При каждом создании нового объекта этого класса, поэтому static-блок выполняется многократноC)Только явным вызовом специального метода initialize(), иначе static-поля остаются неинициализированнымиD)Лениво — при первом активном использовании класса (не при загрузке)
показать ответ и разбор
+D)Лениво — при первом активном использовании класса (не при загрузке)// разбор: Жизненный цикл класса: загрузка → компоновка (verify/prepare/resolve) → ИНИЦИАЛИЗАЦИЯ. Последняя происходит ЛЕНИВО и РОВНО ОДИН РАЗ — при первом активном использовании: создании экземпляра, обращении к static-полю/методу (кроме констант времени компиляции), Class.forName с инициализацией. Тогда выполняются static-инициализаторы и инициализаторы static-полей сверху вниз. JVM гарантирует потокобезопасность инициализации — на этом основан идиом lazy-holder для синглтона.
- Что определяет уникальность (идентичность) класса в рантайме JVM?A)Полное имя класса ПЛЮС загрузивший его загрузчикB)Только полное имя класса с пакетом — один и тот же класс идентичен независимо от загрузчикаC)Хеш-код байткода класса, вычисляемый JVM при загрузке файла.class с диска в памятьD)Порядковый номер, который JVM присваивает классу в момент его первой инициализации при старте
показать ответ и разбор
+A)Полное имя класса ПЛЮС загрузивший его загрузчик// разбор: Уникальность класса в JVM = его полное имя И загрузчик, который его загрузил. Поэтому один и тот же .class, загруженный ДВУМЯ разными загрузчиками, — это ДВА разных класса: их экземпляры несовместимы, каст между ними даёт ClassCastException, static-поля независимы. Это ключ к изоляции плагинов/приложений (разные загрузчики) и к коварным багам вроде «ClassCastException: Foo cannot be cast to Foo» и утечкам загрузчиков в metaspace.
- Что делает фаза компоновки (linking) при подготовке класса?A)Компилирует исходный.java в байткод.class и сохраняет результат обратно на диск для повторного запускаB)Verify (проверка байткода), prepare (память под static) и resolve (символьные ссылки)C)Запускает все static-инициализаторы класса и присваивает static-полям их окончательные значенияD)Соединяет класс с сетью, подгружая недостающие зависимости из удалённого репозитория артефактов
показать ответ и разбор
+B)Verify (проверка байткода), prepare (память под static) и resolve (символьные ссылки)// разбор: Linking идёт между загрузкой и инициализацией и состоит из трёх шагов: Verification — проверка, что байткод корректен и безопасен; Preparation — выделение памяти под static-поля и присвоение им ЗНАЧЕНИЙ ПО УМОЛЧАНИЮ (0/null, ещё не пользовательских); Resolution — замена символьных ссылок на прямые (может быть ленивой). Пользовательские значения static-полей и static-блоки выполняются позже — в фазе инициализации. Понимание этих фаз объясняет порядок и лень загрузки классов.
- Как обычно возникает утечка памяти в metaspace?A)Через создание слишком большого числа обычных объектов, которые не помещаются в молодое поколениеB)Через длинные строки: каждая строка приложения дублируется в metaspace при интернированииC)Через утечку загрузчиков классов — классы грузятся снова и не выгружаютсяD)Через рекурсию: каждый вложенный вызов метода добавляет копию класса в область метаданных
показать ответ и разбор
+C)Через утечку загрузчиков классов — классы грузятся снова и не выгружаются// разбор: Metaspace хранит метаданные классов. Класс выгружается только вместе со СВОИМ загрузчиком (когда тот становится недостижим). Если что-то удерживает ссылку на загрузчик (например, ThreadLocal, статический кэш, поток), его классы не выгружаются. При частой перезагрузке классов (hot-reload, динамические прокси, многократный деплой в один процесс) такие «повисшие» загрузчики копят классы → OutOfMemoryError: Metaspace. Диагностируют по числу загруженных классов и загрузчиков.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.