🧠 Раздел 3 · Вопрос #7

Как может возникнуть утечка памяти в Java

Утечка памяти в Java возникает всякий раз, когда на объект, фактически завершивший свою полезную работу, продолжает указывать хотя бы одна сильная ссылка (Strong Reference) от а...


🟢 Junior Level

Утечка памяти в Java возникает всякий раз, когда на объект, фактически завершивший свою полезную работу, продолжает указывать хотя бы одна сильная ссылка (Strong Reference) от активных корней (GC Roots). Сборщик мусора считает такой объект живым и не освобождает занимаемую им память.


Топ-4 типичных сценария утечек для начинающих

1. Статические коллекции без ограничения размера

Статические поля (static) привязаны к объекту java.lang.Class, который загружен в систему и является корнем GC Root. Они живут всё время работы JVM.

// ❌ УТЕЧКА: список растет с каждым запросом и никогда не очищается
public class OrderService {
    private static final List<Order> CACHE = new ArrayList<>();

    public void process(Order order) {
        CACHE.add(order); // Объект Order осел в памяти навсегда
    }
}

2. Забытые слушатели событий и обратные вызовы (Listeners / Callbacks)

Если зарегистрировать слушатель в долгоживущем объекте (сервисе или UI-компоненте), но забыть отписаться:

// ❌ УТЕЧКА: window держит сильную ссылку на shortLivedComponent
eventBroadcaster.registerListener(this);
// Если не вызвать eventBroadcaster.unregisterListener(this), объект не будет собран GC!

3. Незакрытые системные ресурсы

Потоки ввода-вывода, сокеты и соединения с БД удерживают нативные дескрипторы ОС и внутренние буферы:

// ❌ УТЕЧКА: при ошибке поток останется открытым в памяти
FileInputStream fis = new FileInputStream("large_data.bin");
fis.read();
// Забыли fis.close()!

Решение: Всегда использовать конструкцию try-with-resources.

4. ThreadLocal переменные без вызова .remove()

В пулах потоков (ThreadPoolExecutor) потоки не уничтожаются после завершения задачи, а возвращаются в пул, сохраняя все привязанные к ним данные ThreadLocal.


🟡 Middle Level

1. Нестатические внутренние классы (Inner Classes) против Lambdas

Любой нестатический внутренний класс (включая анонимные классы) содержит синтетическое (неявное) поле компилятора this$0, ссылающееся на экземпляр внешнего класса:

public class HeavyReportGenerator {
    private byte[] rawData = new byte[50_000_000]; // 50 МБ

    public Runnable getTask() {
        return new Runnable() { // ❌ Анонимный класс неявно хранит HeavyReportGenerator.this
            @Override
            public void run() {
                System.out.println("Задача запущена");
            }
        };
    }
}

Если передать такой Runnable в планировщик задач или фоновый поток, тяжелый объект HeavyReportGenerator (50 МБ) не сможет быть удален сборщиком мусора, пока фоновая задача не завершится.

💡 Преимущество Lambda: В отличие от анонимных классов, лямбда-выражение захватывает внешний экземпляр this только тогда, когда оно явно обращается к полям или методам внешнего класса. Если лямбда не использует поля this, ссылка на внешний объект не создается вовсе.


2. Мутация объектов-ключей в HashSet и HashMap

Для корректной работы хэш-таблиц методы equals() и hashCode() должны опираться на неизменяемые (immutable) поля объекта:

public class MutableKeyDemo {
    public static void main(String[] args) {
        Set<User> users = new HashSet<>();
        User user = new User("Alice", 20);
        users.add(user); // Вычислен hashCode(), объект помещен в бакет корзины

        user.setAge(21); // ❌ МУТАЦИЯ КЛЮЧА: hashCode изменился!

        // Попытка удалить объект завершится неудачей:
        users.remove(user); // По новому hashCode JVM ищет объект в ДРУГОМ бакете!
        
        System.out.println(users.size()); // Размер = 1! Объект "потерян" в сете навсегда.
    }
}

Объект остался внутри HashSet, но его невозможно найти, обновить или удалить штатными методами. При регулярном повторении коллекция бесконечно раздувается.


3. Неконтролируемое интернирование String.intern()

Таблица StringTable в HotSpot JVM представляет собой хэш-таблицу фиксированного размера.

  • Если вызывать .intern() на миллионах динамических, уникальных строк (UUID, тела входящих JSON, логи), цепочки коллизий в StringTable катастрофически удлиняются.
  • Это приводит к деградации производительности вставки строковых литералов и риску переполнения кучи OutOfMemoryError: Java heap space.

🔴 Senior Level

Утечки ClassLoader-ов в Metaspace (Hot Redeploy Leaks)

Самая труднодиагностируемая утечка в серверных средах (Tomcat, WebLogic, OSGi). При горячей перезагрузке веб-приложения создается новый экземпляр WebAppClassLoader, а старый должен утилизироваться сборщиком мусора вместе со всеми классами, байткодом и статическими полями.

Bootstrap ClassLoader
        ▲
System ClassLoader (AppClassLoader)
        ▲
WebAppClassLoader_v1 (ДОЛЖЕН УМЕРЕТЬ) ◄─── Удерживается ссылкой!
        │
    Loaded Classes (OrderController.class)
        │
   Static Fields (OrderService, Caches)

Почему ClassLoader не выгружается:

  1. Регистрация в системных реестрах: JDBC-драйвер зарегистрировался в системном java.sql.DriverManager (который загружен системным загрузчиком) и не вызвал deregisterDriver().
  2. ThreadLocal с объектом приложения: В пуле потоков Tomcat остался висеть ThreadLocal<MyContext>. Класс MyContext ссылается на WebAppClassLoader, удерживая в памяти гигабайты метаданных старой версии приложения.
  3. Демон-потоки: Фоновый планировщик (например, Timer или незавершенный ExecutorService), запущенный внутри приложения, продолжает работать. Живой поток является GC Root, держит свой контекстный загрузчик и блокирует выгрузку.

Утечки вне кучи (Off-Heap): DirectByteBuffer и Zip Deflater

Прямые буферы выделяют нативную память операционной системы через вызов malloc() в обход ограничений -Xmx.

// Выделение 1 ГБ нативной памяти
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);

Механизм ловушки:

  • На стороне Java Heap объект DirectByteBuffer занимает всего около 64 байт (заголовок + адрес указателя на нативную память).
  • Освобождение нативной памяти происходит через ассоциированный PhantomReference и Cleaner только тогда, когда сам объект DirectByteBuffer будет собран сборщиком мусора в Heap.
  • Катастрофический сценарий: Если куча практически пуста (приложение перекладывает сетевые пакеты), сборщик мусора GC не запускается часами. В нативной памяти скапливаются десятки гигабайт DirectByteBuffer, операционная система исчерпывает RAM, и процесс немедленно уничтожается системным демоном Linux OOM Killer без генерации Heap Dump!

⚠️ Zip/Gzip Deflater Gotcha: При работе с java.util.zip.Deflater или GZIPOutputStream выделяется нативная память библиотеки zlib. Если не вызвать явно метод deflater.end(), нативная память утекает до перезапуска JVM.


🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Утечка памяти в Java возникает из-за непреднамеренного сохранения путей достижимости от корней GC Roots к ненужным объектам. Основные практические причины делятся на:

  1. Коллекции: статические кэши без лимитов (Caffeine решает проблему) и мутация ключей в HashSet/HashMap.
  2. Контексты потоков: ThreadLocal без .remove() в пулах потоков веб-серверов.
  3. Внутренние классы: неявное удержание внешнего Outer.this нестатическими анонимными классами.
  4. Metaspace: неудаляемые ClassLoader из-за висячих JDBC-драйверов или daemon-потоков.
  5. Off-Heap: накопление DirectByteBuffer при пустой куче и незакрытые нативные ресурсы (Deflater.end())».

3–4 каверзных вопроса с ответами

  1. Почему мутация полей объекта-ключа в HashMap или HashSet приводит к утечке памяти?
    • Ответ: При вызове map.put(key, val) бакет для вставки вычисляется на основе текущего hashCode(). Если после этого изменить поля ключа, влияющие на хэш-код, его hashCode() изменится. При последующей попытке вызвать map.remove(key) метод будет искать ключ в другом бакете и не найдет его. Объект останется внутри таблицы навсегда, его невозможно удалить или получить штатным API.
  2. В чем разница между анонимным классом и лямбда-выражением с точки зрения удержания ссылок?
    • Ответ: Нестатический анонимный класс всегда генерирует скрытое поле ссылки на внешний класс (this$0), даже если внутри своего тела он вообще не обращается к полям или методам родителя. Лямбда-выражение создается через invokedynamic и захватывает ссылку на внешний this только при фактическом обращении к состоянию внешнего класса. Если лямбда не использует поля this, ссылка на внешний объект не захватывается, что исключает утечку памяти.
  3. Почему DirectByteBuffer может вызвать падение от OOM Killer при практически пустой куче (Heap)?
    • Ответ: Память для DirectByteBuffer выделяется вне кучи (Off-Heap). В самой куче живет лишь миниатюрный Java-объект-обертка с адресом нативного буфера, освобождаемый через Cleaner. Если куча пуста, порог для запуска Garbage Collector не достигается, и сборка мусора не стартует. В результате нативные буферы не финализируются, суммарный объем Resident Set Size (RSS) процесса превышает лимит контейнера, и операционная система убивает JVM по сигналу SIGKILL (Exit Code 137).
  4. Каким образом невыгруженный JDBC-драйвер в DriverManager вызывает утечку Metaspace при передеплое веб-приложения?
    • Ответ: Класс java.sql.DriverManager загружается системным Bootstrap ClassLoader и живет всё время работы сервера. Когда приложение регистрирует свой драйвер, DriverManager сохраняет сильную ссылку на экземпляр драйвера. Этот экземпляр помнит свой загрузчик (WebAppClassLoader). В итоге старый ClassLoader приложения не может быть собран GC, удерживая в памяти все классы, аннотации и статические структуры предыдущей версии приложения.

Типичные ошибки и Red Flags

  • 🚩 Использование ThreadLocal без обязательного блока finally { tl.remove(); } (гарантированная утечка в Tomcat/Netty).
  • 🚩 Создание нестатических внутренних классов для долгих фоновых задач (неявное удержание внешнего контекста).
  • 🚩 Мутирование полей объектов, уже помещенных в качестве ключей в HashMap/HashSet.
  • 🚩 Игнорирование закрытия нативных ресурсов (Deflater, DirectByteBuffer, пулы Netty).

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