Сервис держит heap ниже установленного лимита, но RSS процесса постоянно растёт после обработки сетевых запросов. Какой механизм JVM может объяснить это расхождение?
Причиной могут быть прямые буферы: их небольшой Java-объект находится в heap, а основная память выделяется вне heap — в нативной памяти процесса. Поэтому heap-профиль может выглядеть стабильным, тогда как RSS растёт; освобождение нативной памяти обычно связано с обнаружением недостижимости объекта и работой механизма очистки.
Прямые буферы появились для эффективного взаимодействия Java с операционной системой и нативными сетевыми или файловыми API. Они позволяют уменьшить количество копирований между Java-heap и памятью, используемой системными вызовами.
Цена этого подхода — память буфера не контролируется непосредственно лимитом -Xmx. Для диагностики процесса нужно учитывать не только heap, но и другие области нативной памяти.
Например, приложение создаёт временные прямые буферы для каждого запроса. В heap остаются только объекты-обёртки, поэтому обычный heap-профиль не показывает объём самих буферов.
Если буферы освобождаются медленнее, чем создаются, растёт RSS процесса. Это может привести к давлению на память или завершению процесса операционной системой даже при свободном месте в heap.
Неверно считать, что отсутствие роста heap доказывает отсутствие утечки. Ссылки на буферы могут сохраняться в очередях, кэшах или объектах запросов, а при недостижимости очистка нативной памяти всё равно не обязана происходить немедленно.
При создании прямого ByteBuffer JVM создаёт Java-объект, связанный с областью нативной памяти. Содержимое буфера не размещается в обычном Java-массиве внутри heap.
В этом примере объект buffer мал относительно выделенных 64 МБ, а сами данные находятся вне heap. После исчезновения сильных ссылок JVM может обнаружить объект как недостижимый и запустить связанный механизм очистки, но момент освобождения нативной памяти не совпадает с моментом выхода локальной переменной из области видимости.
На практике нужно разделять несколько причин роста RSS: прямые буферы, память потоков, метаданные классов, JNI-выделения и память самого загрузчика или библиотек. Для прямых буферов полезно сопоставлять метрики их использования с данными JFR, мониторингом JVM и системными показателями процесса; для общей нативной памяти применяют инструменты вроде Native Memory Tracking, если он был включён заранее.
Основные варианты исправления — переиспользование и ограничение пула буферов, уменьшение их размера, корректное снятие ссылок после обработки и контроль лимита прямой памяти. Пул снижает частоту аллокаций, но усложняет управление жизненным циклом: слишком большой пул сам становится источником удержания памяти.
Публичного универсального метода немедленно освободить любой прямой буфер в прикладном коде нет. Поэтому архитектура должна рассчитывать на явное управление временем жизни объектов и не полагаться на сборщик мусора как на механизм оперативного освобождения крупных нативных областей.
После перехода сервиса на сетевой стек с прямыми буферами heap стабилизировался на уровне 2 ГБ, но RSS за несколько часов вырос до 7 ГБ. Профилирование heap не обнаружило соответствующего количества живых Java-объектов.
Рассмотрели три варианта:
Выбрали третий вариант, добавили метрики размера пула и количества занятых буферов, а также проверили очереди на удержание завершённых запросов. После этого RSS вышел на стабильный уровень, а частота сборок мусора не стала основным инструментом управления нативной памятью.
Следует ли ожидать, что System.gc() немедленно освободит прямой буфер?
Нет. Такой вызов лишь запрашивает выполнение сборки мусора и не является гарантией ни запуска GC, ни немедленного освобождения конкретного буфера. Даже после обнаружения недостижимости освобождение может быть связано с последующей обработкой очереди очистки. Кроме того, если буфер всё ещё достижим через кэш или очередь, GC не имеет права его освобождать.
Почему увеличение лимита максимальной прямой памяти не исправляет рост RSS?
Лимит только определяет, сколько памяти JVM разрешено использовать для соответствующего класса аллокаций. Он не заставляет приложение переиспользовать буферы и не освобождает уже удерживаемые объекты. Увеличение лимита может лишь отложить ошибку выделения или приблизить процесс к нехватке физической памяти.
Может ли стабильный объём прямых буферов всё равно сопровождаться ростом RSS?
Да. RSS включает не только память прямых буферов, но и стеки потоков, библиотеки, JNI-аллокации, файловые отображения и страницы, выделенные аллокатором процесса. Кроме того, освобождённая нативная память может оставаться у системного аллокатора и не сразу возвращаться операционной системе. Поэтому нужно сравнивать несколько источников метрик, а не делать вывод только по числу занятых прямых буферов.