🧠 Раздел 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).

Связанные темы