Два объекта ссылаются только друг на друга, но ни одна ссылка из корней JVM до них не доходит. Объясните, с...

Два объекта ссылаются только друг на друга, но ни одна ссылка из корней JVM до них не доходит. Объясните, сможет ли сборщик мусора освободить их.

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

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

Да. Циклическая ссылка сама по себе не делает объекты живыми: если из корней JVM до этой группы объектов нет пути, трассирующий сборщик мусора признает оба объекта недостижимыми и сможет reclaimить их. Сборка произойдет не обязательно сразу и зависит от выбранного сборщика и текущей потребности в памяти.

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

Проблема циклов особенно важна для алгоритмов подсчёта ссылок. Такой алгоритм освобождает объект, когда число входящих ссылок становится нулевым, поэтому изолированный цикл может остаться навсегда: ссылки внутри цикла продолжают учитывать друг друга.

Современные сборщики мусора в JVM в основном используют трассировку достижимости, а не простой подсчёт ссылок. Они ищут объекты, до которых можно добраться от корней, что позволяет корректно обрабатывать циклические структуры.

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

Циклы возникают в графах объектов, двусвязных структурах, кэшах и связях между компонентами. Ошибочный вывод о том, что любой цикл является утечкой памяти, приводит к ненужному ручному разрыву ссылок и усложнению кода.

Реальная утечка появляется тогда, когда цикл достижим от корня: например, его удерживает статическое поле, живой поток, активный ThreadLocal, JNI-глобальная ссылка или другая долгоживущая структура. В этом случае сборщик обязан сохранить весь достижимый подграф, даже если он больше не нужен приложению.

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

JVM рассматривает память как граф объектов. К корням относятся, в частности, ссылки из стеков Java-потоков, статические поля загруженных классов, локальные JNI-ссылки и другие специальные ссылки, определённые реализацией JVM.

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

class Node { Node next; } Node first = new Node(); Node second = new Node(); first.next = second; second.next = first; first = null; second = null;

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

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

Специальные типы ссылок меняют правила обнаружения и обработки объектов. Например, объект, доступный только через WeakReference, не считается обычным сильнодостижимым объектом, а финализация и очереди ссылок могут отсрочить фактическое освобождение ресурсов или памяти. Поэтому для диагностики нужно смотреть не только на наличие цикла, но и на путь удержания от корня.

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

В сервере после удаления подписчика из списка наблюдателей heap dump показал пару объектов, ссылающихся друг на друга. Команда решила вручную разрывать все внутренние ссылки, но это не изменило объём памяти: внешняя ссылка действительно была удалена, а объекты уже были недостижимы и ожидали следующего GC.

Были рассмотрены варианты:

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

Выбранным решением стала проверка Shortest Path to GC Roots для подозрительных объектов. Для удерживаемого подграфа обнаружили статический реестр, который не удалял завершённые подписки. После очистки реестра объекты стали недостижимыми, а цикл внутри подписки перестал иметь значение. Рост памяти прекратился после нескольких циклов GC.

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

  1. Сможет ли GC освободить цикл, если один из объектов содержит финализатор?

Не обязательно в ближайшем цикле. Объект, требующий финализации, может быть сначала обнаружен как недостижимый, поставлен в очередь на финализацию и временно сохранён JVM. После выполнения финализатора объект может стать недостижимым снова, и тогда потребуется последующая обработка. Финализаторы также не являются надёжным способом управления внешними ресурсами.

  1. Достаточно ли удалить одну ссылку внутри цикла, чтобы освободить объекты?

Нет, если цикл по-прежнему достижим от корня через другой путь. Разрыв внутренней ссылки помогает только тогда, когда он устраняет все пути, по которым корни достигают объектов, либо делает часть графа недостижимой. Критерий — не отсутствие циклов, а отсутствие пути от корней JVM.

  1. Можно ли по наличию объектов в heap dump сразу заключить, что они не были собраны?

Нет. Снимок показывает состояние heap в конкретный момент и может быть сделан до следующего GC или во время особенностей работы конкретного сборщика. Нужно учитывать момент создания dump, тип сборщика, результаты последних сборок и путь удержания объектов от GC roots. Сам факт присутствия цикла или объекта в памяти ещё не доказывает утечку.