Программирование JavaJVM и памятьСтарший Java-разработчик

При многократной перезагрузке Java плагина классы остаются в Metaspace после сборки мусора. Какое условие н...

При многократной перезагрузке Java-плагина классы остаются в Metaspace после сборки мусора. Какое условие нужно проверить первым, чтобы объяснить это поведение?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Первым нужно проверить, стал ли ClassLoader плагина недостижимым от корней GC. Классы обычно выгружаются не по отдельности, а вместе с загрузчиком классов; пока на загрузчик, его классы или связанные с ними объекты существует сильная ссылка, сборка мусора не освободит их метаданные из Metaspace.

Исторический контекст

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

В старых версиях Java метаданные классов размещались в PermGen, а начиная с Java 8 — в Metaspace, использующем память нативной области. Само изменение области хранения не устранило проблему утечек загрузчиков: недостижимый загрузчик по-прежнему должен быть обнаружен GC и выгружен вместе с загруженными им классами.

Постановка проблемы

При каждой перезагрузке приложение может создавать новый экземпляр загрузчика. Если старый загрузчик остается достижимым, его классы и статические данные также остаются живыми, поэтому использование Metaspace растет.

Причиной могут быть потоки с контекстным загрузчиком старого плагина, зарегистрированные слушатели, таймеры, кэши, JDBC-драйверы, статические поля или ссылки из общего кода на объекты плагина. Итогом становятся рост нативной памяти, частые паузы или ошибка OutOfMemoryError: Metaspace.

Подробное решение

Условие выгрузки формулируется через достижимость: ClassLoader должен быть недостижим от корней GC, а загруженные им классы и экземпляры не должны удерживаться другими загрузчиками. После этого GC может обнаружить загрузчик как мусор и освободить связанные метаданные классов.

Наличие вызова System.gc() не гарантирует немедленную выгрузку. Сборщик может проигнорировать такой запрос, выгрузка классов может выполняться только в подходящем цикле сборки, а конкретные возможности и настройки зависят от используемого GC и версии JVM.

Диагностику начинают с подтверждения роста числа загрузчиков и классов: используют JFR, журналы загрузки классов и выгрузки, статистику классов, а при необходимости — Native Memory Tracking. Затем ищут путь удержания старого загрузчика: анализируют дамп кучи, контекстные загрузчики потоков, глобальные кэши, ThreadLocal, статические поля и незакрытые ресурсы.

Исправление состоит не в увеличении Metaspace, а в устранении жизненного цикла плагина: остановке его потоков, отмене задач, удалении регистраций, очистке кэшей и восстановлении контекстных загрузчиков. Увеличение лимита Metaspace может лишь отсрочить отказ и полезно как временная мера после проверки, что рост ожидаем.

Ситуация из практики

Сервис загружал новую версию плагина каждые несколько минут. После нескольких часов число загруженных классов и расход Metaspace постоянно росли, хотя старые объекты плагина, по логике приложения, уже не использовались.

Рассматривались три варианта:

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

В дампе кучи обнаружили рабочий поток старого плагина. Его контекстный ClassLoader указывал на загрузчик предыдущей версии, поэтому тот оставался достижимым. После остановки потока и очистки его контекста старые загрузчики начали выгружаться, а потребление Metaspace стабилизировалось.

Что кандидаты часто упускают

  1. Вопрос: Достаточно ли обнулить ссылку на экземпляр ClassLoader, чтобы его классы выгрузились?

    Ответ: Нет. Обнуление одной ссылки помогает только в том случае, если других путей достижимости не осталось. Загрузчик может удерживаться потоками, статическими структурами, ThreadLocal, регистрациями в глобальных менеджерах или объектами, созданными его классами и доступными из общей инфраструктуры.

  2. Вопрос: Почему утечка ThreadLocal особенно опасна для выгрузки классов?

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

  3. Вопрос: Можно ли считать рост Metaspace доказательством утечки ClassLoader?

    Ответ: Нет. Metaspace может расти из-за нормальной загрузки новых классов, большого числа генерируемых классов, особенностей кэшей JVM или выбранного лимита. Для подтверждения утечки нужно наблюдать, что после повторных циклов загрузки и выгрузки увеличивается число живых экземпляров или загрузчиков, а старые загрузчики не исчезают после подходящих циклов GC; затем следует найти удерживающую ссылку.