Нативный модуль сохраняет ссылку на Java-объект после завершения обычного вызова. Почему сборщик мусора может не освободить этот объект?
Если нативный код сохранил объект через сильную JNI-ссылку, объект остаётся достижимым от корней JVM и не может быть собран. Глобальная JNI-ссылка живёт до явного удаления, а локальная ссылка обычно действует в рамках текущего вызова нативного метода.
JNI появился как стандартный мост между Java-кодом и нативными библиотеками. Он решает задачу интеграции с операционной системой, существующими библиотеками и производительными нативными компонентами, но требует явно описывать время жизни ссылок, поскольку сборщик мусора не управляет обычными указателями нативного кода.
Нативный компонент может сохранить ссылку в глобальном хранилище, кэше или структуре сторонней библиотеки. Пока ссылка зарегистрирована в JVM как сильная JNI-ссылка, объект считается достижимым даже после исчезновения всех ссылок из Java-кода.
Последствия — рост heap, неожиданные долгоживущие объекты и невозможность объяснить утечку только анализом Java-полей. Особенно опасны редко освобождаемые глобальные ссылки и большие объекты, связанные с ними.
При создании глобальной JNI-ссылки JVM учитывает её при обходе графа достижимости. Поэтому вызов сборки мусора не освобождает соответствующий объект, пока нативный код не удалит ссылку. Само завершение Java-метода не уничтожает глобальную ссылку.
Локальная JNI-ссылка обычно автоматически становится недействительной после возврата из текущего нативного вызова. Однако внутри долгого нативного метода большое число временных локальных ссылок может удерживать объекты до завершения вызова; их следует удалять по мере необходимости. Для ссылки, которая не должна препятствовать сборке, используется слабая глобальная JNI-ссылка, но перед использованием её нужно проверять и корректно обрабатывать исчезновение объекта.
Диагностика начинается с анализа корней в heap dump: среди них могут быть JNI-ссылки, удерживающие объект. Затем проверяют места создания и удаления глобальных ссылок в нативном коде, жизненный цикл кэшей и длительность нативных вызовов. Native Memory Tracking полезен для анализа памяти самой JVM и нативных аллокаций, но не заменяет поиск Java-объектов, удерживаемых JNI-ссылками.
Компромисс таков: глобальные ссылки нужны для долгого взаимодействия Java и native-кода, но требуют явного управления временем жизни. Слабые ссылки уменьшают риск утечки, однако добавляют гонку с GC и необходимость проверять, что объект ещё существует.
Сервис обработки изображений после каждого задания создавал Java-объект буфера и передавал его в нативную библиотеку. Heap dump показывал цепочку удержания от JNI-корня, хотя Java-кэш и очереди уже не содержали этот объект.
Рассматривались три варианта: увеличить heap, перейти на слабые ссылки или исправить освобождение глобальных ссылок. Увеличение heap лишь отсрочило бы проблему, а слабые ссылки были рискованны, поскольку библиотека могла обращаться к буферу асинхронно. Выбрали парное управление жизненным циклом: создавать глобальную ссылку перед передачей в нативный асинхронный контекст и удалять её после callback о завершении обработки. После этого объём удерживаемых Java-объектов стабилизировался.
Нет. GC анализирует не только поля Java-объектов, но и внешние корни, включая JNI-ссылки, активные стеки потоков и другие структуры JVM. Если хотя бы один путь от корня до объекта сохраняется, объект считается достижимым.
Глобальная ссылка препятствует сборке объекта до явного удаления. Слабая глобальная ссылка не удерживает объект живым: после сборки она может стать недействительной. Поэтому нативный код обязан проверять результат обращения к такой ссылке и не использовать объект без подтверждения его доступности.
Да, если нативный метод работает долго и создаёт множество локальных ссылок, не освобождая их. Они обычно очищаются при выходе из метода, поэтому это временное удержание, но при большом количестве ссылок оно способно вызвать рост памяти или превышение локального лимита ссылок. В долгих циклах ссылки удаляют после завершения их использования.