Що таке витік пам'яті в Java
Витоки в Java поділяються на три принципово різні категорії залежно від області пам'яті:
🟢 Junior Level
Витік пам’яті в Java (Memory Leak) — це ситуація, за якої об’єкти, що логічно завершили свою роботу і більше не потрібні застосунку, продовжують ненавмисно утримуватися сильними посиланнями від активних коренів (GC Roots). У результаті збирач сміття (Garbage Collector) не може звільнити займану ними пам’ять, доступний обсяг купи безперервно скорочується, що неминуче призводить до аварійної зупинки застосунку з помилкою java.lang.OutOfMemoryError.
Принципова різниця: Витік у C++ vs Витік у Java
- У C / C++ (Втрата покажчика): Програміст виділив пам’ять через
malloc()/new, але втратив покажчик на неї, забувши викликатиfree()/delete. До цих байтів неможливо звернутися, але ОС вважає їх зайнятими. - У Java (Ненавмисне утримання посилання): Пам’ять фізично досяжна для віртуальної машини, посилання на об’єкт існує, але бізнес-логіці об’єкт більше не потрібен. GC бачить активний шлях від GC Root і не має права видалити цей об’єкт.
Побутова аналогія
- Готельний номер із табличкою «Не турбувати»: Постоялець виїхав з готелю та полетів в іншу країну, але перед виходом забув зняти табличку «Не турбувати» з дверей. Покоївка (Garbage Collector) регулярно проходить повз номер, бачить табличку (активне посилання) і не має права прибрати номер. Готель поступово заповнюється «порожніми», але недоступними для заселення номерами.
Найпростіший приклад витоку
public class MemoryLeakDemo {
// Статична колекція прив'язана до GC Root (об'єкта Class) і живе вічно
private static final List<byte[]> EVENT_LOG = new ArrayList<>();
public void processEvent() {
byte[] payload = new byte[1024 * 1024]; // 1 МБ даних
EVENT_LOG.add(payload); // Об'єкт додано до статичного списку і ніколи не видаляється!
// Після виходу з методу корисна робота завершена, але 1 МБ залишився в Heap назавжди
}
}
🟡 Middle Level
Класифікація витоків пам’яті в JVM
Витоки в Java поділяються на три принципово різні категорії залежно від області пам’яті:
1. Витоки в купі (On-Heap Leaks)
- Безконтрольні статичні колекції: кеші на
HashMapбез політики витіснення (eviction) та обмеження за розміром. - Забуті слухачі та зворотні виклики (Listeners / Callbacks): реєстрація підписників в UI або event-шинах без виклику
removeListener(). - Нестатичні внутрішні класи (Non-static Inner Classes): неявне посилання
Outer.thisна зовнішній важкий об’єкт:public class HeavyWindow { private byte[] pixels = new byte[100_000_000]; public ActionListener createListener() { return new ActionListener() { // ❌ Тримає неявне посилання на HeavyWindow.this! public void actionPerformed(ActionEvent e) { } }; } }
2. Витоки в метаданих (Metaspace / ClassLoader Leaks)
- Відбуваються під час гарячого перезавантаження застосунків (Hot Redeploy) у сервлет-контейнерах (Tomcat, Jetty).
- Якщо кастомний
ClassLoaderне може бути зібраний збирачем сміття (наприклад, потік пулу тримає посилання на клас із застосунку), усі завантажені класи, їхній байткод та статичні поля назавжди застрягають у пам’яті, викликаючиOutOfMemoryError: Metaspace.
3. Витоки поза купою (Off-Heap / Native Memory Leaks)
- Прямі буфери
ByteBuffer.allocateDirect()або виклики нативного C-коду через JNI / Foreign Function API. - Нативна пам’ять не обмежується прапорцем
-Xmx. Якщо застосунок створює буфери швидше, ніж GC запускає очищення phantom-посилань, процес аварійно завершується системним OOM Killer.
Симптоми та графік «Пили пам’яті» (Sawtooth Pattern)
У здоровому застосунку графік використання купи нагадує рівномірну пилку: пам’ять плавно зростає в міру створення об’єктів і після кожного збирання сміття різко падає до стабільної нижньої базової лінії (Baseline).
Здоровий Heap: Витік пам'яті (Memory Leak):
Пам'ять Пам'ять
│ /\ /\ /\ /\ │ /\ /\ /\ /\ OOM!
│ / \ / \ / \ / \ │ / \ / \ / \ / \ ▲
│/ \/ \/ \/ \ │/ \/ \/ \/ \ /
└──────────────────────── База └─/─────/─────/─────/───── База росте!
Час Час
Ознаки витоку в метриках:
- Нижня межа пилки (пам’ять, що залишається після повного Full GC) безперервно повзе вгору.
- GC Thrashing: Збирач сміття починає працювати 90–99% процесорного часу, намагаючись вивільнити бодай кілька мегабайтів, а час відповіді застосунку (Latency) прямує до нескінченності.
🔴 Senior Level
Витік через ThreadLocal у пулах потоків
Найбільш підступний та поширений enterprise-витік у мікросервісах:
public class SecurityContextHolder {
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
public static void set(UserContext user) { CTX.set(user); }
public static UserContext get() { return CTX.get(); }
public static void clear() { CTX.remove(); }
}
Механізм пастки:
- Кожен потік
Threadмістить усередині асоціативний масивThreadLocal.ThreadLocalMap. - У
ThreadLocalMapключ — цеWeakReference<ThreadLocal<?>>, а значення — сильне посилання (Strong Reference) на об’єктUserContext. - У веб-серверах (Tomcat, Netty) потоки ніколи не вмирають: вони беруться з пулу потоків (
ThreadPoolExecutor) і перевикористовуються роками. - Якщо програміст забув викликати
CTX.remove()у блоціfinally:- Значення
UserContextзалишається висіти в пам’яті потока назавжди. - Виникає Memory Leak: контекст користувача утримує клас
UserContext, той утримуєAppClassLoader, блокуючи вивантаження мегабайтів коду. - Виникає Data Leak (Пролом безпеки): наступний запит від іншого користувача, який потрапив на цей самий потік пулу, отримає чужі права доступу!
- Значення
// ✅ Єдино правильний патерн використання ThreadLocal:
try {
SecurityContextHolder.set(currentUser);
filterChain.doFilter(request, response);
} finally {
SecurityContextHolder.clear(); // КРИТИЧНО ВАЖЛИВО!
}
Аналіз Heap Dump через Eclipse MAT: Dominator Tree
Для виявлення витоку на production-контурі обов’язковими є прапорці:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/dumps/oom.hprof
Під час відкриття файлу .hprof в аналізаторі пам’яті (Eclipse Memory Analyzer Tool — MAT) ключовим інструментом є Dominator Tree (Дерево домінаторів):
- Об’єкт $A$ домінує над об’єктом $B$, якщо будь-який шлях від будь-якого GC Root до об’єкта $B$ зобов’язаний проходити через $A$.
- Retained Heap: Якщо видалити об’єкт-домінатор $A$, вся підсистема об’єктів під ним гарантовано звільниться.
- У MAT виконується команда «Path to GC Roots» $\rightarrow$ «Exclude all phantom/weak/soft references». Аналізатор миттєво будує ланцюжок сильних посилань: від статичного поля через список/мапу безпосередньо до витеклого бізнес-об’єкта.
OOM Killer у Docker / Kubernetes (Exit Code 137)
Класична аварія в cloud-інфраструктурі: Java-застосунку виділено ліміт контейнера в 4 ГБ RAM, а прапорець JVM виставлено в -Xmx3g. Застосунок падає з кодом Exit Code 137 (SIGKILL), але файл Heap Dump не генерується!
Чому впав процес? Ліміт cgroup у Linux відстежує Resident Set Size (RSS) — всю фізичну пам’ять процесу, яка складається з багатьох зон: \(\text{Total Memory} = \text{Heap } (-Xmx) + \text{Metaspace} + \text{Thread Stacks } (N \times -Xss) + \text{CodeCache} + \text{Direct Buffers} + \text{GC Overhead}\)
Якщо у вас 1000 потоків (1 ГБ на стеки) + 300 МБ Metaspace + 500 МБ Netty DirectMemory + 3 ГБ Heap = 4.8 ГБ RAM. Ядро Linux примусово вбиває процес за лімітом 4 ГБ.
- Інструмент діагностики: Native Memory Tracking (NMT).
Запуск з
-XX:NativeMemoryTracking=summaryта читання звіту:jcmd <pid> VM.native_memory summary.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«Витік пам’яті в Java — це ненавмисне утримання посилань на логічно непотрібні об’єкти, через що GC не може їх видалити. Головні осередки витоків: статичні колекції без лімітів, забуті слухачі подій, нестатичні внутрішні класи та
ThreadLocalу перевикористовуваних пулах потоків сервлет-контейнерів. Витоки поділяються на On-Heap (купа), Metaspace (невивантажувані ClassLoader) та Off-Heap (прямі буфериDirectByteBuffer). Для локалізації витоків на проді використовують прапорець-XX:+HeapDumpOnOutOfMemoryErrorз подальшим аналізом Dominator Tree та метрики Retained Size в Eclipse MAT».
3–4 каверзних запитання з відповідями
- Чим витік пам’яті в Java принципово відрізняється від витоку в C++?
- Відповідь: У C++ витік пам’яті — це втрата адреси виділеного блоку пам’яті за невиклику
free(), коли покажчик втрачено, а пам’ять залишилася зайнятою в ОС. У Java витік — це, навпаки, ненавмисне збереження сильного посилання на об’єкт, який бізнесу більше не потрібен. Пам’ять фізично доступна, але GC не має права її очистити, доки зберігається ланцюжок до GC Root.
- Відповідь: У C++ витік пам’яті — це втрата адреси виділеного блоку пам’яті за невиклику
- Чому
ThreadLocalпризводить до витоку пам’яті та даних у веб-застосунках?- Відповідь: У сервлет-контейнерах (Tomcat) потоки не вмирають після обробки HTTP-запиту, а повертаються в пул. Внутрішній асоціативний масив потоку
ThreadLocalMapмістить сильне посилання на об’єкт-значення. Якщо не викликатиthreadLocal.remove()у блоціfinally, об’єкт утримуватиметься потоком нескінченно довго. Це призводить не лише до витоку пам’яті, а й до вразливості: наступний користувач на цьому потоці може прочитати дані попередньої сесії.
- Відповідь: У сервлет-контейнерах (Tomcat) потоки не вмирають після обробки HTTP-запиту, а повертаються в пул. Внутрішній асоціативний масив потоку
- Що таке пилкоподібний графік пам’яті (Sawtooth) і як за ним діагностувати витік?
- Відповідь: У нормальному режимі після кожної фази збирання сміття (Major/Full GC) нижня межа споживання пам’яті падає до одного й того самого базового рівня. За наявності витоку пам’яті нижня межа графіка після кожного циклу GC планомірно зростає вгору. Це сигналізує про накопичення об’єктів, які переживають регулярні цикли збирача.
- Чому Java-контейнер у Kubernetes може померти з Exit Code 137, навіть якщо купа зайнята наполовину?
- Відповідь: Exit Code 137 означає, що процес було завершено сигналом SIGKILL від Linux OOM Killer через перевищення ліміту пам’яті cgroup. Пам’ять Java-процесу (RSS) складається не лише з купи (
-Xmx), але й з нативної пам’яті Metaspace, стеків потоків (-Xss$\times$ кількість потоків), CodeCache, структур даних GC та нативних буферів (DirectByteBuffer). Якщо сумарний обсяг нативної пам’яті перевищить ліміт пода, ОС вб’є JVM миттєво без генерації Java Heap Dump.
- Відповідь: Exit Code 137 означає, що процес було завершено сигналом SIGKILL від Linux OOM Killer через перевищення ліміту пам’яті cgroup. Пам’ять Java-процесу (RSS) складається не лише з купи (
Типові помилки та Red Flags
- 🚩 Твердження: «У Java немає витоків пам’яті, тому що є Garbage Collector» (головний маркер недосвідченості кандидата).
- 🚩 Спроба лікувати витік пам’яті простим збільшенням
-Xmx(це лише відтягує момент падіння та подовжує паузи GC). - 🚩 Використання
ThreadLocalбез обов’язкового виклику.remove()у секціїfinally. - 🚩 Орієнтація лише на Shallow Size під час пошуку винуватця витоку в дампі пам’яті (необхідно дивитися на Retained Size у Dominator Tree).
Пов’язані теми
- Коли об’єкт стає кандидатом на видалення GC — досяжність від GC Roots
- Як може статися витік пам’яті в Java — розбір конкретних кейсів витоків
- Що таке Metaspace (або PermGen) — витоки метаданих класів
- Що таке heap dump — створення та аналіз дампів пам’яті
- Що таке GC roots — джерела коренів досяжності