Что такое memory leak и как его обнаружить
В отличие от языков с ручным управлением памятью (C/C++), где утечка — это потерянный указатель на выделенный блок памяти (malloc без free), в Java утечка памяти — это логическа...
🟢 Junior Level
Утечка памяти (Memory Leak) в Java — это ситуация, при которой неиспользуемые приложением объекты продолжают удерживаться сильными ссылками (Strong References) из активной части программы, из-за чего Garbage Collector не может их удалить.
В отличие от языков с ручным управлением памятью (C/C++), где утечка — это потерянный указатель на выделенный блок памяти (malloc без free), в Java утечка памяти — это логическая ошибка удержания ссылок: указатель на объект всё ещё существует, но сам объект логически больше никогда не понадобится.
Основные внешние признаки утечки:
- Постепенный рост потребления памяти: после завершения циклов GC объем занятой памяти в старшем поколении (Old Gen) не возвращается к исходному уровню, а постоянно растет.
- Падение пропускной способности (Throughput): сборщик мусора запускается всё чаще, потребляя процессорные ресурсы и замедляя обработку запросов.
- Финал: приложение падает с ошибкой
java.lang.OutOfMemoryError: Java heap space.
// Простейший пример утечки памяти
public class MemoryLeakDemo {
// Статическая коллекция живёт всё время работы приложения
private static final List<byte[]> CACHE = new ArrayList<>();
public void processData() {
// Данные добавляются в статический список и никогда не удаляются
CACHE.add(new byte[1024 * 1024]); // 1 МБ
}
}
🟡 Middle Level
1. Метрика «Пилы» (Sawtooth Pattern)
В здоровом Java-приложении график использования памяти кучи напоминает равномерную пилу:
- Память заполняется новыми объектами (подъём графика).
- Срабатывает сборщик мусора и возвращает объем памяти к исходному базовому уровню (baseline).
Здоровое приложение (Baseline стабилен):
Занято ^
| /| /| /| /| /|
| / | / | / | / | / |
|/__|/__|/__|/__|/__|__ Baseline (стабильный уровень)
+---------------------> Время
Утечка памяти (Baseline монотонно растёт):
Занято ^
| /|
| /| / |
| /| / | / |
| /| / | / |_/ |__ Baseline растёт!
| / |/ |_/ |
+---------------------> Время
Если минимальная точка кучи после каждого Full GC («дно пилы») неуклонно поднимается вверх — в системе присутствует утечка памяти.
2. Shallow Heap vs Retained Heap
При анализе дампов памяти критически важно различать два понятия объема памяти:
[Объект A (Shallow: 32 B)]
│
▼
[Объект B (Shallow: 48 B)] ──► [Массив данных C (Shallow: 100 MB)]
Для объекта A:
- Shallow Heap = 32 байта
- Retained Heap = 32 B + 48 B + 100 MB = ~100 MB
- Shallow Heap (Собственный размер): количество памяти, необходимое для хранения самого объекта (заголовок объекта + поля примитивов + ссылки на другие объекты). Для обычных объектов это скромные 24–64 байта.
- Retained Heap (Удерживаемый размер): суммарный размер всех объектов, которые станут недостижимы для GC и будут удалены, если данный объект будет уничтожен.
- Правило анализа: В профилировщиках сортировку всегда нужно выполнять по Retained Heap, так как объект с собственным размером в 32 байта может удерживать за собой миллионы объектов и гигабайты данных.
3. Методология Delta Analysis (Сравнение двух снимков)
Анализ одного изолированного дампа кучи часто не даёт ответа, так как сложно отличить легитимные долгоживущие кэши от реальной утечки. Проверенная методология расследования — Delta Analysis:
- Снимок 1 (Baseline): Приложение запускается, проходит этап прогрева (warmup), после чего снимается первый дамп кучи.
- Нагрузочный прогон: На сервис подается тестовая нагрузка (или серия бизнес-операций), которая должна циклически создаваться и завершаться.
- Охлаждение (Cooldown): Потоки завершают работу, ожидается очистка временных объектов.
- Снимок 2 (Target): Снимается второй дамп кучи.
- Сравнение (Diff): В Eclipse MAT открываются оба снимка через функцию Compare Snapshot. Классы, количество экземпляров которых линейно увеличилось пропорционально числу запросов и не уменьшилось после ожидания — являются источником утечки.
🔴 Senior Level
1. Анализ через граф доминаторов (Dominator Tree)
В Eclipse MAT ключевым представлением является Dominator Tree:
- Из теории графов: узел $X$ доминирует над узлом $Y$, если любой путь от любого GC Root к узлу $Y$ обязательно проходит через $X$.
- Дерево доминаторов группирует объекты по их логическим владельцам. В корне дерева отображаются объекты с наибольшим
Retained Heap. - Раскрывая дерево доминаторов сверху вниз, инженер сразу видит, какой именно компонент системы удерживает львиную долю памяти.
Class Name | Shallow | Retained | Percentage
------------------------------------------------+---------+----------+-----------
com.example.OrderService @ 0x7fa281c00 | 48 | 2.150 MB | 78.4%
└── java.util.concurrent.ConcurrentHashMap | 64 | 2.149 MB | 78.3%
└── java.util.concurrent.ConcurrentHashMap$Node[] | 128 | 2.149 MB | 78.3%
2. Поиск цепочки удержания: Path to GC Roots
Чтобы понять, почему GC не удаляет объект, в анализаторе вызывают функцию Path to GC Roots:
[!IMPORTANT] При построении пути к корням всегда выбирайте фильтр: Exclude soft/weak/phantom references. Мягкие и слабые ссылки не препятствуют сбору мусора при исчерпании памяти. Утечку всегда вызывают только сильные ссылки (Strong References).
В 95% случаев корнем утечки в Java оказываются:
Thread: живой поток удерживает локальные переменные в фреймах стека или данные вThreadLocalMap.System Class / Static Field: статическое поле класса, загруженного системным загрузчиком, удерживает коллекции, слушатели или реестры.JNI Global Reference: нативный C++ код создал глобальную ссылку на Java-объект и не освободил её.
3. Опасность Heap Dump в Production и альтернатива JFR
Снятие дампа памяти (jcmd <pid> GC.heap_dump) на боевом инстансе с большой кучей (64–256 ГБ) — экстремально рискованная операция:
- JVM выполняет обязательный перевод всех потоков в глобальный Safepoint (Stop-The-World).
- Запись файла дампа размером 100 ГБ на диск при скорости записи 300 МБ/с заморозит процесс на 30–60 секунд.
- За это время Kubernetes объявит под неработоспособным по таймауту Liveness Probe и принудительно убьет контейнер сигналом SIGKILL, оставив битый файл дампа.
Безопасное решение: Java Flight Recorder (JFR) OldObjectSample
Вместо полной выгрузки кучи в production используется механизм JFR Old Object Profiling:
- HotSpot непрерывно отслеживает выборку долгоживущих объектов (обычно фиксированный буфер на 256–512 объектов-кандидатов) с накладными расходами процессора менее 1%.
- При продвижении объектов в старшее поколение JFR сохраняет стек вызова момента аллокации и путь до GC Roots.
- В JDK Mission Control (JMC) инженер видит точное место аллокации утекающих объектов без единой миллисекунды STW-паузы!
# Запуск профилирования утечек через JFR без остановки приложения:
jcmd <pid> JFR.start name=LeakCheck settings=profile duration=30m filename=/dumps/leak.jfr
4. Язык запросов OQL (Object Query Language)
Для сложных случаев в Eclipse MAT используется язык запросов OQL (похож на SQL к памяти):
-- Найти все экземпляры ThreadLocal, содержащие непустые значения
SELECT t
FROM java.lang.ThreadLocal t
-- Найти классы ClassLoader, удерживающие более 500 загруженных классов
SELECT c, c.@retainedHeapSize
FROM ".*ClassLoader.*" c
WHERE c.classes.size() > 500
-- Поиск дубликатов строк, расходующих память
SELECT toString(s) AS val, count(s) AS cnt, sum(s.@retainedHeapSize) AS totalSize
FROM java.lang.String s
GROUP BY toString(s)
HAVING count(s) > 100
ORDER BY totalSize DESC
🎯 Шпаргалка для интервью
30-секундный ответ для собеседования
«Утечка памяти в Java — это удержание ненужных объектов живыми сильными ссылками от GC Roots, из-за чего сборщик мусора не может освободить память. На графиках мониторинга утечка проявляется в виде монотонного роста нижней границы пилы использования памяти (Tenured baseline) после циклов Full GC. Обнаруживают утечки анализом дампов памяти в Eclipse MAT или JProfiler, исследуя дерево доминаторов (Dominator Tree) и показатель Retained Heap, а также прослеживая путь до корней (Path to GC Roots с исключением weak/soft ссылок). На больших production-кучах вместо тяжелого снятия дампа, вызывающего длительный Stop-The-World, применяют JFR с событием
OldObjectSample».
Вопросы повышенной сложности с ответами
-
В чём разница между Shallow Heap и Retained Heap? Может ли Retained Heap быть меньше Shallow Heap? Ответ: Shallow Heap — это собственный размер самого объекта в памяти (его заголовок и поля). Retained Heap — это объем памяти, который освободится, если данный объект будет уничтожен сборщиком мусора (то есть собственный размер объекта плюс размер всех объектов, достижимых исключительно через него). Retained Heap никогда не может быть меньше Shallow Heap, так как объект как минимум включает свой собственный размер.
-
Чем опасен вызов снятия Heap Dump на проде с кучей 128 ГБ и как безопасно исследовать утечку? Ответ: Снятие дампа замораживает JVM в глобальной точке останова (Safepoint) на всё время последовательного сброса гигабайтов памяти на диск (десятки секунд или минуты). Это приводит к протуханию сетевых таймаутов, срабатыванию Kubernetes Liveness Probes и аварийному убийству контейнера. Для безопасного исследования нужно: вывести узел из-под балансировщика трафика (drain pod) перед снятием дампа; либо использовать легковесное непрерывное профилирование через Java Flight Recorder (
jdk.OldObjectSample), дающее накладные расходы менее 1% без остановки мира. -
Почему при исследовании Path to GC Roots обязательно исключают Weak и Soft ссылки? Ответ: Потому что Weak ссылки собираются при первом же Minor GC, а Soft ссылки гарантированно очищаются сборщиком мусора перед тем, как JVM выбросит ошибку
OutOfMemoryError. Следовательно, ни Weak, ни Soft ссылки сами по себе не могут быть причиной неразрешимой утечки памяти, приводящей к OOME. Утечку удерживают исключительно Strong References. -
Что такое Dominator Tree в Eclipse MAT и как оно помогает находить утечки? Ответ: Дерево доминаторов строится на основе графа ссылок: объект $A$ доминирует над объектом $B$, если каждый путь от корней GC к объекту $B$ проходит через $A$. Это преобразует сложный циклический граф взаимосвязей в иерархическое дерево прямого владения. Вершина дерева сразу показывает объекты, единолично контролирующие наибольшие объёмы памяти (Retained Heap), что позволяет за один клик выявить виновника утечки, даже если сам по себе объект-виновник мал.
Типичные ошибки и Red Flags
- ❌ Утверждать, что утечка памяти в Java невозможна благодаря Garbage Collector. (Red Flag: непонимание концепции GC Roots и недостижимости объектов).
- ❌ Ориентироваться при поиске утечки на Shallow Heap вместо Retained Heap. (Shallow Heap самого корневого объекта коллекции — это всего лишь пара десятков байт массива).
- ❌ Рекомендовать вызов
System.gc()на боевом сервере перед снятием дампа. (ВызовSystem.gc()провоцирует тяжелый синхронный Full GC Stop-The-World, что недопустимо под рабочей нагрузкой).