🧠 Розділ 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-компоненті), але забути відписатися:

// ❌ ВИТІК: eventBroadcaster тримає сильне посилання на 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).

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