При многократной перезагрузке Java-плагина классы остаются в Metaspace после сборки мусора. Какое условие нужно проверить первым, чтобы объяснить это поведение?
Первым нужно проверить, стал ли 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 постоянно росли, хотя старые объекты плагина, по логике приложения, уже не использовались.
Рассматривались три варианта:
В дампе кучи обнаружили рабочий поток старого плагина. Его контекстный ClassLoader указывал на загрузчик предыдущей версии, поэтому тот оставался достижимым. После остановки потока и очистки его контекста старые загрузчики начали выгружаться, а потребление Metaspace стабилизировалось.
Вопрос: Достаточно ли обнулить ссылку на экземпляр ClassLoader, чтобы его классы выгрузились?
Ответ: Нет. Обнуление одной ссылки помогает только в том случае, если других путей достижимости не осталось. Загрузчик может удерживаться потоками, статическими структурами, ThreadLocal, регистрациями в глобальных менеджерах или объектами, созданными его классами и доступными из общей инфраструктуры.
Вопрос: Почему утечка ThreadLocal особенно опасна для выгрузки классов?
Ответ: Поток часто живет дольше плагина, особенно если он принадлежит пулу. Его ThreadLocal может содержать объект класса плагина, а сам поток — иметь контекстный загрузчик этого плагина; поэтому старый загрузчик остается достижимым даже после удаления ссылки на основной объект модуля. При остановке или переиспользовании потока нужно корректно очищать значения ThreadLocal и восстанавливать контекстный загрузчик.
Вопрос: Можно ли считать рост Metaspace доказательством утечки ClassLoader?
Ответ: Нет. Metaspace может расти из-за нормальной загрузки новых классов, большого числа генерируемых классов, особенностей кэшей JVM или выбранного лимита. Для подтверждения утечки нужно наблюдать, что после повторных циклов загрузки и выгрузки увеличивается число живых экземпляров или загрузчиков, а старые загрузчики не исчезают после подходящих циклов GC; затем следует найти удерживающую ссылку.