← назад к разделу

Есть класс проблем, который проходит мимо всей остальной наблюдаемости. Тесты зелёные, логи чистые, ошибок нет — а сервис через три дня после выката перезапускается сам. Или отвечает всё медленнее, хотя запросов столько же.

Метрики этот случай видят, но объясняют плохо. График памяти показывает, что она растёт; график задержки — что стало медленнее. На вопрос кто именно держит память и где именно тратится процессор метрика не отвечает по своей природе: она агрегат, в ней нет ни объектов, ни строк кода.

Отвечают на него два инструмента — снимок памяти и запись профилировщика. Про них и статья.

Как отличить утечку от нормальной работы

Прежде чем что-то снимать, полезно убедиться, что проблема вообще есть. Растущий график памяти сам по себе ни о чём не говорит: JVM берёт память у операционной системы охотно и отдаёт неохотно, так что «занято много» — обычное состояние здорового сервиса.

Смотреть надо на другое — на уровень, до которого память падает после сборки мусора. Здоровый сервис даёт картинку в форме пилы: память растёт, приходит сборщик, память падает примерно к одному и тому же уровню, и так по кругу. Утечка выглядит как та же пила, у которой нижние зубцы медленно ползут вверх: после каждой уборки остаётся чуть больше, чем в прошлый раз.

Второй признак — поведение сборщика. При утечке он работает всё чаще и всё дольше, а толку всё меньше: доля времени, потраченного на сборку, растёт. В какой-то момент JVM сдаётся и падает с OutOfMemoryError, но до этого сервис успевает долго и мучительно тормозить.

Посмотреть текущее состояние кучи можно, ничего не устанавливая, — утилитой jcmd, которая идёт в комплекте с JDK:

jcmd <pid> GC.heap_info

Она печатает размер кучи, сколько занято, и отдельно — Metaspace, куда JVM складывает метаданные классов. Про Metaspace вспоминают редко, а он утекает в приложениях, которые много генерируют классов на лету.

Первый взгляд: кто занимает память

Прежде чем снимать полный дамп, стоит посмотреть на гистограмму классов. Она гораздо дешевле и часто сразу отвечает на вопрос:

jcmd <pid> GC.class_histogram

Вывод — список классов, отсортированный по занимаемым байтам:

 num     #instances         #bytes  class name
   1:       8620094     7935600288  [B
   2:        470564      488822744  [F
   3:       8344625      200271000  java.lang.String
   4:        952060       83781280  java.lang.reflect.Method

Читается это так: [B — массивы байтов, [F — массивы чисел с плавающей точкой. Восемь миллионов массивов байтов на восемь гигабайт — это не «Java прожорливая», это конкретные данные, которые кто-то держит в памяти.

Гистограмма показывает что лежит, но не кто держит. Строк в памяти всегда много, и сами по себе они ни в чём не виноваты. Чтобы найти держателя, нужен полный снимок.

Снимок памяти: как снять и не уронить сервис

Полный снимок кучи — это файл со всеми объектами и ссылками между ними:

jcmd <pid> GC.heap_dump /var/tmp/heap.hprof

Здесь важно понимать цену. Файл получится примерно размером с занятую кучу: на сервисе из примера выше это девять гигабайт. Пока снимок пишется, приложение остановлено — не «немного подтормаживает», а стоит целиком. На большой куче это десятки секунд, за которые оркестратор успеет решить, что сервис умер, и перезапустить его.

Отсюда правила, которые стоит соблюдать, а не узнавать на практике:

Снимайте с одного экземпляра, а не со всех сразу, и лучше с того, который выведен из-под нагрузки. Проверьте, что на диске есть место — иначе получите сервис без места под журналы. Увеличьте на время порог проверки живости, чтобы оркестратор не убил процесс посреди снимка. И помните, что в дампе лежат реальные данные пользователей: токены, телефоны, содержимое запросов. Это файл с персональными данными, и обращаться с ним нужно соответственно — не класть в общую папку и не отправлять в переписку.

Есть и способ получить снимок автоматически ровно в момент падения:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/tmp

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

Смотрят снимок в отдельном инструменте — Eclipse MAT, VisualVM или профилировщике из IDE. Ключевое, что в них ищут, — не «сколько объектов», а кто их удерживает: цепочка ссылок от корня до объекта. Именно она отвечает на вопрос «почему это не собирается».

Пять утечек, которые встречаются чаще остальных

Полезно знать типовые случаи: в девяти расследованиях из десяти находится один из них.

Кэш без ограничения. Обычная HashMap, в которую складывают результаты «чтобы быстрее». Пока ключей мало, всё хорошо; когда ключом становится идентификатор пользователя или запроса, карта растёт вечно. Лечится кэшем с ограничением по размеру и времени жизни.

ThreadLocal в пуле потоков. Поток в пуле не умирает после запроса, а возвращается в пул вместе со всем, что в нём оставили. Если значение положили и не убрали, оно живёт до конца жизни приложения — и таких значений столько, сколько потоков. Ровно поэтому в статье про проброс контекста очистка стоит в блоке finally.

Подписки и слушатели, которые никто не отменяет. Компонент подписался на события, потом был пересоздан — а подписка осталась и держит ссылку на старый экземпляр. Со временем в памяти живут десятки поколений одного и того же объекта.

Статические коллекции. Любое static поле живёт столько же, сколько приложение. Складывать туда что-то в рантайме — почти гарантированная утечка, потому что удалять оттуда обычно забывают.

Незакрытые ресурсы. Соединения, потоки, курсоры. Утечка тут двойная: держится и память, и ограниченный ресурс на той стороне — и второе обычно бьёт раньше.

Профилирование процессора: где тратится время

Вторая половина темы — не память, а скорость. Метрика говорит, что метод стал медленнее, но не говорит, на чём именно.

Здесь работает выборочное профилирование: профилировщик много раз в секунду смотрит, какой код выполняется прямо сейчас, и складывает статистику. Метод, который попадается в большинстве снимков, и есть тот, где проводится время. Накладные расходы у такого подхода небольшие, поэтому его допустимо включать в продакшене — в отличие от подробной трассировки каждого вызова, которая замедляет приложение в разы.

В JDK встроен Flight Recorder, для которого ничего ставить не нужно:

jcmd <pid> JFR.start name=probe settings=profile duration=60s filename=/var/tmp/probe.jfr
jcmd <pid> JFR.check

Через минуту получится файл, который открывается в JDK Mission Control. Минутная запись профиля на живом сервисе занимает порядка мегабайта — это не тот объём, из-за которого стоит переживать.

Смотреть в записи стоит четыре вещи: какие методы чаще всего оказываются на процессоре; кто больше всех выделяет память (частые короткие аллокации — типичная причина частых сборок мусора); сколько времени ушло на сборку мусора; и где потоки простаивают в блокировках.

Отдельный инструмент, который стоит знать, — async-profiler. Он даёт более честную картину, чем встроенные средства, потому что видит и нативный код, и умеет строить диаграмму-«пламя»: широкая полоса на такой диаграмме — это метод, в котором проведено много времени. Ставится отдельно, подключается к работающему процессу.

Ещё две команды из того же набора пригодятся раньше профилировщика. Дамп потоков показывает, чем заняты все потоки в данную секунду, — незаменим, когда сервис «завис» и не отвечает:

jcmd <pid> Thread.print

А если подозрение падает на память вне кучи — буферы, нативные библиотеки, — её учёт включается флагом при старте, иначе команда честно скажет, что учёт выключен:

-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary

Порядок действий, когда «сервис ест память»

Чтобы не метаться, полезно держать в голове последовательность.

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

Когда виновник найден, проверьте его по списку типовых утечек выше: почти всегда это кэш, ThreadLocal или подписка. И поставьте -XX:+HeapDumpOnOutOfMemoryError, если его ещё нет, — чтобы в следующий раз снимок появился сам.

Отдельно стоит сказать, зачем это в мире, где заметную часть кода пишет модель. Утечка — дефект, который не ловится ни тестами, ни ревью: код выглядит правильным, кэш работает, ошибок нет. Проявляется он только на длинной дистанции в проде, и найти его можно исключительно инструментами из этой статьи.

Коротко

  • Метрики показывают, что память растёт; кто её держит — отвечают только снимок кучи и профилировщик.
  • Утечка видна не по высокому потреблению, а по тому, что нижняя точка после сборки мусора ползёт вверх.
  • jcmd <pid> GC.heap_info и GC.class_histogram — бесплатный первый взгляд, часто достаточный.
  • Снимок кучи размером с занятую память и останавливает приложение на время записи: снимать с одного экземпляра, следить за местом и порогом проверки живости.
  • В снимке ищут не крупные объекты, а цепочку ссылок, которая мешает их собрать.
  • Типовые утечки: кэш без ограничения, ThreadLocal в пуле потоков, неотменённые подписки, статические коллекции, незакрытые ресурсы.
  • Выборочное профилирование (JFR, async-profiler) достаточно дёшево для продакшена; Thread.print выручает, когда сервис завис.
  • -XX:+HeapDumpOnOutOfMemoryError ставят заранее: первое падение обычно случается ночью.

Что почитать дальше

  • Метрики — что снимать постоянно, чтобы заметить проблему до падения.
  • Проброс контекста — почему очистка ThreadLocal стоит в finally.
  • От алерта до строки лога — как связать метрику, трассировку и журнал в одном расследовании.
  • Настройка наблюдаемости — почему /actuator/heapdump не должен быть открыт наружу.