В профиле heap после нескольких полных сборок остаются объекты результатов запросов, хотя локальные ссылки на них давно исчезли. Почему сборщик мусора всё ещё может считать эти объекты достижимыми?
Сборщик мусора освобождает не объекты без локальных ссылок, а объекты, которые недостижимы от GC Roots. Результаты запросов могут оставаться живыми из-за статического поля, активного потока, JNI-ссылки, локального кэша или другого объекта, достижимого от корня.
Поэтому исчезновение локальной ссылки само по себе не означает, что объект можно удалить. Нужно найти путь удержания от GC Root до оставшегося объекта; если такой путь ошибочен, это утечка памяти на уровне логики приложения.
Автоматическая сборка мусора появилась как способ избавить разработчика от ручного освобождения памяти и связанных с ним ошибок: двойного освобождения, обращения к уже освобождённой памяти и утечек из-за пропущенного освобождения.
Современные сборщики обычно используют анализ достижимости. Такой подход не требует заранее знать, когда объект больше не нужен: JVM удаляет его, когда из корней выполнения больше нельзя построить цепочку ссылок к этому объекту.
Локальная переменная существует только в определённой области выполнения, но созданный ею объект может быть доступен через другие ссылки. Например, обработчик может положить результат в статическую коллекцию, кэш или структуру долгоживущего сервиса.
Если такая ссылка не удаляется, размер живого набора объектов растёт после каждой обработки. Это приводит к более частым и долгим сборкам, увеличению пауз, а при исчерпании доступной памяти — к OutOfMemoryError.
Есть и другой важный случай: после сборки объекты уже освобождены, но JVM сохраняет выделенные области heap для последующего использования. Поэтому само уменьшение или неизменность занятого процессом heap не доказывает наличие утечки; нужно сравнивать объём живых объектов после сборок и анализировать их пути удержания.
Сборщик начинает обход из набора GC Roots. К ним относятся, в частности, ссылки из активных стеков потоков, статические поля загруженных классов, ссылки из объектов потоков, JNI-ссылки и другие внутренние корни JVM. Все объекты, достижимые по ссылкам от этих корней, считаются живыми.
Простейший пример логической утечки — долгоживущая статическая коллекция:
Каждый массив доступен через статическое поле results, а статическое поле является частью графа от GC Root, связанного с загруженным классом. Даже после завершения метода saveResult массивы остаются достижимыми, пока элементы не будут удалены из коллекции или сама коллекция не станет недостижимой.
Для диагностики обычно сравнивают heap dump после нескольких полных сборок и изучают retained size объекта. Важно смотреть не только на тип крупного объекта, но и на путь от GC Root: он показывает, какая ссылка фактически удерживает память.
Варианты исправления зависят от причины. Можно ограничить размер кэша и использовать вытеснение, явно удалять записи по жизненному циклу, выбрать слабые ссылки для подходящего сценария или устранить ненужное статическое состояние. WeakReference не является универсальным лечением: объект, доступный только через слабую ссылку, может быть собран в любой момент, поэтому такой механизм подходит лишь там, где потеря значения допустима.
Сервис обрабатывал документы и сохранял результаты в кэш для повторных запросов. После нескольких часов работы росли живые объекты heap, а в heap dump путь удержания вёл через статическое поле кэша; локальные переменные обработчика в этот путь не входили.
Рассматривались три варианта. Полное отключение кэша быстро устраняло рост памяти, но увеличивало нагрузку на базу данных. Замена значений на слабые ссылки снижала удержание памяти, однако кэш мог терять записи под давлением GC и переставал давать предсказуемый процент попаданий. Увеличение heap только откладывало проблему.
Выбрали ограниченный кэш с максимальным размером, политикой вытеснения и временем жизни записи. Это сохранило пользу повторного использования результатов и одновременно ограничило верхнюю границу памяти; после изменения объём живых объектов стабилизировался, а частота длительных сборок снизилась.
Вопрос: Может ли объект быть собран, если на него всё ещё существует ссылка?
Ответ: Да, если эта ссылка сама недостижима от GC Roots. Сборщик анализирует не наличие любой ссылки, а достижимость всего графа от корней. Например, объект, на который ссылается недостижимый контейнер, не удерживается только фактом существования ссылки внутри этого контейнера.
Вопрос: Почему после устранения ссылки размер heap процесса может не уменьшиться?
Ответ: Сборка мусора освобождает объекты внутри уже выделенных областей, но JVM может оставить эти области за собой для будущих аллокаций. Кроме того, поведение возврата памяти операционной системе зависит от сборщика, размеров областей и текущей политики управления heap. Поэтому для проверки утечки сравнивают живые объекты после сборок, а не только показатель занятой памяти процесса.
Вопрос: Почему добавление слабой ссылки иногда не решает проблему удержания памяти?
Ответ: Слабая ссылка влияет только на конкретное ребро графа. Если тот же объект доступен через обычную сильную ссылку из другого кэша, очереди, статического поля или активного потока, он всё равно остаётся достижимым. Кроме того, сама слабая ссылка может сохраняться в контейнере вместе с отработавшими значениями, поэтому нужно учитывать жизненный цикл ссылочных объектов и очищать контейнер, например через подходящую очередь ссылок или явную политику удаления.