Диагностика утечек памяти в Java
Процесс с -Xmx128m: в куче занят 1 МиБ, а RSS (resident set size, реально занятая память) - 168 МиБ. Если контейнер прибил такой процесс, снимок кучи не покажет ничего - искать надо там, куда -Xmx не смотрит.
Сеньорская часть JVM-собеса состоит из сценариев, а не определений: «падает с OutOfMemoryError раз в сутки», «латентность выросла втрое», «потоки кончились». Проверяют порядок действий и владение инструментами.
// Формулировки: «как найдёшь утечку памяти?», «что смотришь в thread dump?», «чем профилировать прод?», «куча в норме, а под убивают - что дальше?».
Почерк утечки и подготовка заранее
Мой замер с загрузчиками - готовый образец. Держим по одной ссылке на объект, и число классов в памяти растёт с 722 до 3982, Metaspace с 635 до 5607 КиБ. Отпустили ссылки - 3000 классов выгрузились, Metaspace откатился к 1382 КиБ. Утечка и не-утечка отличались одной живой ссылкой.
На графике кучи это выглядит как пила с растущими минимумами: после каждой полной сборки освобождается всё меньше, дно ползёт вверх, финал - OutOfMemoryError. Ровная пила с постоянным дном - нормальное дыхание сборщика, её трогать не надо.
// Готовиться нужно до инцидента: -XX:+HeapDumpOnOutOfMemoryError и -XX:HeapDumpPath должны стоять заранее. После падения без дампа анализировать нечего, а воспроизвести суточную утечку на стенде получается редко.
- heap dump
- снимок кучи для анализа того, кто удерживает память
- пила с растущими минимумами
- почерк утечки: дно после каждой сборки выше предыдущего
Дампы: кучи и потоков
Свёл два потока во взаимную блокировку и спросил JVM через ThreadMXBean. Ответ: найдено 2 потока, дальше поимённо - «заказы-1» в состоянии BLOCKED ждёт монитор (встроенный замок объекта, который берёт synchronized), а держит этот замок «склад-2»; и симметрично наоборот. Такие циклы JVM находит сама, руками сличать стеки не нужно.
Снимок кучи открывают в Eclipse MAT или VisualVM и смотрят два экрана. Dominator tree - кто эксклюзивно удерживает сколько памяти, виновник обычно торчит первой строкой. Path to GC roots - цепочка ссылок от корней сборщика мусора (GC, garbage collector), из-за которой объект не собирается, она и называет виноватого. Если по одному снимку неочевидно, берут два с интервалом в часы и смотрят разницу.
Снимок потоков делают через jstack или jcmd Thread.print, и обязательно СЕРИЕЙ - три-пять штук с интервалом. Одиночный снимок не отличает «зависло» от «медленно движется», различие видно только в динамике. Сотня потоков в BLOCKED на одном мониторе - бутылочное горлышко, весь пул в WAITING - выжран ожиданием внешнего сервиса.
- dominator tree
- кто эксклюзивно удерживает сколько памяти
- thread dump
- стеки всех потоков: дедлоки, блокировки, зависания
Когда куча ни при чём
Тот же процесс по шагам: старт - RSS 36 МиБ; сто direct-буферов по мегабайту - RSS 140 МиБ при занятой куче в 1 МиБ; плюс 300 потоков - 168 МиБ. Куча всё это время оставалась практически пустой.
Значит, при OOMKilled от контейнера первым делом выясняют, куча ли это вообще. Сравнивают RSS процесса с занятой кучей и включают NMT (native memory tracking) флагом -XX:NativeMemoryTracking=summary, дальше jcmd VM.native_memory summary разложит потребление по категориям: куча, классы, стеки потоков, скомпилированный код, сам сборщик.
// Нативной зовут память, которую процесс берёт у операционной системы напрямую, мимо кучи: сборщик её не трогает, -Xmx её не считает. Постоянные виновники: direct-буферы, чей лимит по умолчанию равен -Xmx и добавляет столько же сверху, библиотеки на C и тысячи потоков по мегабайту стека каждый. jcmd тут швейцарский нож: GC.heap_info, Thread.print, VM.flags, VM.native_memory, JFR.start.
- NMT
- native memory tracking: разбивка нативной памяти по категориям
- jcmd
- универсальная утилита к живой JVM: дампы, флаги, запись
JFR и флеймграфы: смотреть, а не гадать
Прогнал свой счётный цикл дважды: без записи - 6719 мкс на десятом заходе, с включённым JFR в профиле profile - 5710 мкс. Разница утонула в разбросе между заходами, который у меня и так гулял примерно на 400 мкс. Вот и ответ на вопрос «можно ли включать запись в проде».
JFR (Java Flight Recorder) - встроенный самописец JVM: аллокации, паузы сборщика, борьба за локи, операции ввода-вывода. Запись держат постоянно и снимают по факту инцидента - это и есть ответ на «что происходило в три ночи». Смотрят результат в JMC (Java Mission Control).
Профили процессора и аллокаций читают флеймграфом: ширина полосы - доля времени или выделенной памяти, высота - глубина стека. Оптимизация без такого профиля превращается в гадание, а самая широкая полоса регулярно оказывается не в твоём коде, а в ожидании базы.
- JFR
- штатная запись событий JVM, пригодная для прода
- флеймграф
- профиль в виде полос: ширина - доля времени или памяти
Как отвечать: «Сервис падает с OutOfMemoryError раз в сутки. Как будешь искать?»
Сначала убеждаюсь, что снимок кучи вообще будет: HeapDumpOnOutOfMemoryError с путём для файла. Если флага не стояло - ставлю и жду следующий цикл, а пока смотрю график кучи: пила с растущими минимумами после полных сборок подтверждает утечку, ровная пила говорит, что дело в другом. Снимок открываю в MAT: dominator tree показывает, кто держит память, обычно виновник в первой строке, а path to GC roots объясняет, почему объект не собирается. Типовые подозреваемые - кэш без выселения, статическая коллекция, неотписанные листенеры, ThreadLocal в пуле потоков. Если по одному снимку неочевидно, беру два с интервалом и смотрю разницу. И обязательно проверяю, куча ли это: у меня был случай, когда в куче занят мегабайт, а RSS процесса 168 мегабайт - тогда течёт нативная память, и это уже NMT, direct-буферы и число потоков.
Почему это сильный ответ: описан полный цикл - подготовка, подтверждение симптома, инструмент, типовые виновники, приём с разницей двух снимков, - плюс развилка на нативную память, о которой забывает большинство.
На чём валят
- −Лечить утечку увеличением -Xmx. Падение придёт позже, а снимок кучи станет только больше и тяжелее для анализа.
- −Не поставить HeapDumpOnOutOfMemoryError заранее. После падения анализировать нечего, а воспроизвести суточную утечку на стенде выходит редко.
- −Один снимок потоков вместо серии. «Зависло» и «медленно движется» различимы только в динамике между снимками.
- −Профилировать на деве с игрушечными данными. Профиль прода другой, и для него есть штатная запись JFR.
- −OOMKilled при нормальной куче списывать на сборщик. Течёт нативная память вне -Xmx: смотри NMT и RSS процесса.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.
- Чем SoftReference отличается от WeakReference по поведению при GC?A)Soft собирается немедленно на первом GC, а weak, наоборот, удерживает объект до самого OutOfMemoryB)Обе идентичны: разница лишь в названии, оставшемся от разных версий Java по историческим причинамC)Soft держится, пока есть память (кэш); weak собирается при первом же GCD)Soft и weak запрещают сборку объекта: пока такая ссылка существует, объект остаётся жив
показать ответ и разбор
+C)Soft держится, пока есть память (кэш); weak собирается при первом же GC// разбор: Сила ссылок: strong (обычная) — объект жив, пока достижим. SoftReference — объект удерживается, ПОКА ХВАТАЕТ ПАМЯТИ; перед выбросом OOM сборщик очищает soft-ссылки — идеально для кэшей «сколько влезет». WeakReference — объект очищается при БЛИЖАЙШЕМ GC, как только на него нет strong-ссылок (основа WeakHashMap, канонизации). PhantomReference — для пост-сборочной очистки через ReferenceQueue (замена finalize). Выбор силы ссылки — способ подсказать GC, насколько объект «нужен».
- Каким инструментом снимают снимок кучи (heap dump) для поиска утечки?A)Jstack — он делает снимок кучи и показывает, какие объекты держат больше всего памяти в приложенииB)Javac с флагом отладки — он при компиляции встраивает в.class полную карту будущих аллокацийC)Обычный System.out.println всех объектов в цикле — простой переносимый способ увидеть кучуD)jmap / -XX:+HeapDumpOnOutOfMemoryError, анализ в MAT или VisualVM
показать ответ и разбор
+D)jmap / -XX:+HeapDumpOnOutOfMemoryError, анализ в MAT или VisualVM// разбор: Heap dump снимают jmap -dump:live,format=b,file=... по PID или автоматически при падении с -XX:+HeapDumpOnOutOfMemoryError; современный способ — jcmd <pid> GC.heap_dump. Анализируют в Eclipse MAT или VisualVM: смотрят гистограмму классов, крупнейших «держателей» (dominators) и цепочки ссылок от GC-корней (кто не даёт собрать объект) — это и вскрывает утечку. Для потоков (deadlock/зависания) — отдельный инструмент jstack (thread dump), а для метрик в реальном времени — jstat/JFR/профайлеры.
- Что показывает jstack?A)Снимок стеков всех потоков (thread dump) — для поиска зависаний/deadlockB)Полный снимок кучи со списком всех объектов и занимаемой ими памяти в разбивке по классамC)Историю сборок мусора с длительностью каждой паузы Stop-the-World за всё время работы приложенияD)Скомпилированный JIT-ом машинный код каждого горячего метода для ручного анализа оптимизаций
показать ответ и разбор
+A)Снимок стеков всех потоков (thread dump) — для поиска зависаний/deadlock// разбор: jstack (или jcmd <pid> Thread.print) делает THREAD DUMP: стеки вызовов и состояния всех потоков в момент снятия. По нему видно, где потоки «висят» (какой метод, какой лок ждут), обнаруживается deadlock (jstack прямо помечает «Found one Java-level deadlock»), выявляются заблокированные пулы. Это первый инструмент при зависании/100% CPU/тормозах. Для памяти нужен heap dump (jmap), для динамики GC/аллокаций — jstat/JFR. Часто снимают несколько thread dump подряд, чтобы увидеть движение.
- Что означает OutOfMemoryError: unable to create new native thread?A)Кончилось место в куче Java под объекты Thread, поэтому новые потоки создать не получитсяB)ОС/лимиты не дают создать ещё поток — слишком много платформенных потоковC)Metaspace переполнен классами потоков, и загрузчик не может подгрузить класс нового потокаD)Сборщик мусора занят и временно запрещает создание потоков до завершения текущего цикла сборки
показать ответ и разбор
+B)ОС/лимиты не дают создать ещё поток — слишком много платформенных потоков// разбор: Каждый платформенный поток требует нативных ресурсов ОС и памяти под стек (~1МБ). Ошибка «unable to create new native thread» значит, что достигнут ЛИМИТ: число потоков ОС (ulimit -u), исчерпание нативной памяти под стеки, или слишком мало памяти вне кучи. Причина почти всегда — лавина потоков: непулящийся код, растущий/зависший пул, утечка потоков. Лечение: пулы с ограничением, виртуальные потоки для блокирующих задач, аудит числа потоков (jstack/метрики). Куча и -Xmx тут ни при чём — увеличение -Xmx не помогает.
- Какой инструмент даёт непрерывную низконакладную запись поведения JVM в проде?A)System.gc() в цикле — он периодически печатает подробный отчёт о состоянии кучи в стандартный выводB)Javap — он в реальном времени показывает нагрузку на процессор и память работающего приложенияC)JFR (Java Flight Recorder) — события GC, аллокаций, локов с малым оверхедомD)Отладчик в режиме пошагового исполнения, подключённый к продакшену на всё время его работы
показать ответ и разбор
+C)JFR (Java Flight Recorder) — события GC, аллокаций, локов с малым оверхедом// разбор: Java Flight Recorder (JFR) — встроенный в JDK профайлер, который с ОЧЕНЬ малым оверлеем (единицы процентов) непрерывно пишет события JVM: паузы и циклы GC, аллокации, блокировки, исключения, I/O, hot-методы. Запись (.jfr) анализируют в JDK Mission Control. В отличие от тяжёлых профайлеров или отладчиков, JFR безопасен для ПРОДА и часто держится включённым постоянно — по нему разбирают редкие/плавающие проблемы производительности постфактум. Дополняет jmap/jstack (точечные снимки).
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.