🧠 Розділ 3 · Питання #6

Що таке витік пам'яті в 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!
 │ /  \  /  \  /  \  /  \        │ /  \  /  \  /  \  /  \   ▲
 │/    \/    \/    \/    \       │/    \/    \/    \/    \ /
 └──────────────────────── База  └─/─────/─────/─────/───── База росте!
           Час                             Час

Ознаки витоку в метриках:

  1. Нижня межа пилки (пам’ять, що залишається після повного Full GC) безперервно повзе вгору.
  2. 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(); }
}

Механізм пастки:

  1. Кожен потік Thread містить усередині асоціативний масив ThreadLocal.ThreadLocalMap.
  2. У ThreadLocalMap ключ — це WeakReference<ThreadLocal<?>>, а значення — сильне посилання (Strong Reference) на об’єкт UserContext.
  3. У веб-серверах (Tomcat, Netty) потоки ніколи не вмирають: вони беруться з пулу потоків (ThreadPoolExecutor) і перевикористовуються роками.
  4. Якщо програміст забув викликати 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 каверзних запитання з відповідями

  1. Чим витік пам’яті в Java принципово відрізняється від витоку в C++?
    • Відповідь: У C++ витік пам’яті — це втрата адреси виділеного блоку пам’яті за невиклику free(), коли покажчик втрачено, а пам’ять залишилася зайнятою в ОС. У Java витік — це, навпаки, ненавмисне збереження сильного посилання на об’єкт, який бізнесу більше не потрібен. Пам’ять фізично доступна, але GC не має права її очистити, доки зберігається ланцюжок до GC Root.
  2. Чому ThreadLocal призводить до витоку пам’яті та даних у веб-застосунках?
    • Відповідь: У сервлет-контейнерах (Tomcat) потоки не вмирають після обробки HTTP-запиту, а повертаються в пул. Внутрішній асоціативний масив потоку ThreadLocalMap містить сильне посилання на об’єкт-значення. Якщо не викликати threadLocal.remove() у блоці finally, об’єкт утримуватиметься потоком нескінченно довго. Це призводить не лише до витоку пам’яті, а й до вразливості: наступний користувач на цьому потоці може прочитати дані попередньої сесії.
  3. Що таке пилкоподібний графік пам’яті (Sawtooth) і як за ним діагностувати витік?
    • Відповідь: У нормальному режимі після кожної фази збирання сміття (Major/Full GC) нижня межа споживання пам’яті падає до одного й того самого базового рівня. За наявності витоку пам’яті нижня межа графіка після кожного циклу GC планомірно зростає вгору. Це сигналізує про накопичення об’єктів, які переживають регулярні цикли збирача.
  4. Чому 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.

Типові помилки та Red Flags

  • 🚩 Твердження: «У Java немає витоків пам’яті, тому що є Garbage Collector» (головний маркер недосвідченості кандидата).
  • 🚩 Спроба лікувати витік пам’яті простим збільшенням -Xmx (це лише відтягує момент падіння та подовжує паузи GC).
  • 🚩 Використання ThreadLocal без обов’язкового виклику .remove() у секції finally.
  • 🚩 Орієнтація лише на Shallow Size під час пошуку винуватця витоку в дампі пам’яті (необхідно дивитися на Retained Size у Dominator Tree).

Пов’язані теми