Що таке витік пам'яті і як його виявити
На відміну від мов з ручним керуванням пам'яттю (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% без зупинки світу.
- Відповідь: Зняття дампу заморожує JVM у глобальній точці зупинки (Safepoint) на весь час послідовного скидання гігабайтів пам’яті на диск (десятки секунд або хвилини). Це призводить до протухання мережевих таймаутів, спрацьовування Kubernetes Liveness Probes та аварійного вбивства контейнера. Для безпечного дослідження потрібно: вивести вузол з-під балансувальника трафіку (drain pod) перед зняттям дампу; або використовувати легковаге безперервне профілювання через Java Flight Recorder (
- Чому під час дослідження Path to GC Roots обов’язково виключають Weak та Soft посилання?
- Відповідь: Тому що Weak посилання збираються при першому ж Minor GC, а Soft посилання гарантовано очищаються збирачем сміття перед тим, як JVM викине помилку
OutOfMemoryError. Отже, ані Weak, ані Soft посилання самі по собі не можуть бути причиною невиправного витоку пам’яті, що призводить до OOME. Витік утримують виключно Strong References.
- Відповідь: Тому що Weak посилання збираються при першому ж Minor GC, а Soft посилання гарантовано очищаються збирачем сміття перед тим, як JVM викине помилку
- Що таке 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, що неприпустимо під робочим навантаженням).