Два объекта ссылаются только друг на друга, но ни одна ссылка из корней JVM до них не доходит. Объясните, сможет ли сборщик мусора освободить их.
Да. Циклическая ссылка сама по себе не делает объекты живыми: если из корней JVM до этой группы объектов нет пути, трассирующий сборщик мусора признает оба объекта недостижимыми и сможет reclaimить их. Сборка произойдет не обязательно сразу и зависит от выбранного сборщика и текущей потребности в памяти.
Проблема циклов особенно важна для алгоритмов подсчёта ссылок. Такой алгоритм освобождает объект, когда число входящих ссылок становится нулевым, поэтому изолированный цикл может остаться навсегда: ссылки внутри цикла продолжают учитывать друг друга.
Современные сборщики мусора в JVM в основном используют трассировку достижимости, а не простой подсчёт ссылок. Они ищут объекты, до которых можно добраться от корней, что позволяет корректно обрабатывать циклические структуры.
Циклы возникают в графах объектов, двусвязных структурах, кэшах и связях между компонентами. Ошибочный вывод о том, что любой цикл является утечкой памяти, приводит к ненужному ручному разрыву ссылок и усложнению кода.
Реальная утечка появляется тогда, когда цикл достижим от корня: например, его удерживает статическое поле, живой поток, активный ThreadLocal, JNI-глобальная ссылка или другая долгоживущая структура. В этом случае сборщик обязан сохранить весь достижимый подграф, даже если он больше не нужен приложению.
JVM рассматривает память как граф объектов. К корням относятся, в частности, ссылки из стеков Java-потоков, статические поля загруженных классов, локальные JNI-ссылки и другие специальные ссылки, определённые реализацией JVM.
Сборщик помечает объекты, достижимые из этих корней, непосредственно или через цепочку ссылок. Если два объекта ссылаются друг на друга, но ни один корень не ссылается на эту пару, обход от корней до них не дойдёт. Поэтому оба объекта считаются недостижимыми одновременно.
После обнуления последних внешних ссылок цикл остаётся внутренне связным, но путь от корней к нему исчезает. При подходящей сборке объекты будут освобождены; само наличие переменных в исходном коде не означает, что они остаются корнями после того, как их область действия завершилась и JIT доказал отсутствие дальнейшего использования.
Важно различать недостижимость и немедленное освобождение. GC запускается по эвристикам, а объект может временно оставаться в памяти до следующего подходящего цикла. Поведение также зависит от поколенческой организации, региона и выбранного сборщика.
Специальные типы ссылок меняют правила обнаружения и обработки объектов. Например, объект, доступный только через WeakReference, не считается обычным сильнодостижимым объектом, а финализация и очереди ссылок могут отсрочить фактическое освобождение ресурсов или памяти. Поэтому для диагностики нужно смотреть не только на наличие цикла, но и на путь удержания от корня.
В сервере после удаления подписчика из списка наблюдателей heap dump показал пару объектов, ссылающихся друг на друга. Команда решила вручную разрывать все внутренние ссылки, но это не изменило объём памяти: внешняя ссылка действительно была удалена, а объекты уже были недостижимы и ожидали следующего GC.
Были рассмотрены варианты:
Выбранным решением стала проверка Shortest Path to GC Roots для подозрительных объектов. Для удерживаемого подграфа обнаружили статический реестр, который не удалял завершённые подписки. После очистки реестра объекты стали недостижимыми, а цикл внутри подписки перестал иметь значение. Рост памяти прекратился после нескольких циклов GC.
Не обязательно в ближайшем цикле. Объект, требующий финализации, может быть сначала обнаружен как недостижимый, поставлен в очередь на финализацию и временно сохранён JVM. После выполнения финализатора объект может стать недостижимым снова, и тогда потребуется последующая обработка. Финализаторы также не являются надёжным способом управления внешними ресурсами.
Нет, если цикл по-прежнему достижим от корня через другой путь. Разрыв внутренней ссылки помогает только тогда, когда он устраняет все пути, по которым корни достигают объектов, либо делает часть графа недостижимой. Критерий — не отсутствие циклов, а отсутствие пути от корней JVM.
Нет. Снимок показывает состояние heap в конкретный момент и может быть сделан до следующего GC или во время особенностей работы конкретного сборщика. Нужно учитывать момент создания dump, тип сборщика, результаты последних сборок и путь удержания объектов от GC roots. Сам факт присутствия цикла или объекта в памяти ещё не доказывает утечку.