Питання 7 · Розділ 3

Як може статися витік пам'яті в Java?

Витік пам'яті виникає, коли посилання на об'єкт залишається, хоча об'єкт вже не потрібен.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Витік пам’яті виникає, коли посилання на об’єкт залишається, хоча об’єкт вже не потрібен.

Найчастіші причини:

1. Статичні колекції:

// ❌ Колекція росте нескінченно
static List<String> log = new ArrayList<>();
log.add("message");  // Ніколи не очищується!

2. ThreadLocal без очищення:

// ❌ Забули видалити
ThreadLocal<User> user = new ThreadLocal<>();
user.set(currentUser);
// У thread pool потік перевикористовується → старий User залишається!

3. Не закриті ресурси:

// ❌ Забули close()
FileInputStream fis = new FileInputStream("file.txt");
// Буфери залишилися в пам'яті

Як уникнути:

  • Очищуйте колекції
  • Викликайте ThreadLocal.remove()
  • Використовуйте try-with-resources

🟡 Middle Level

1. ThreadLocal у Thread Pools

// ❌ Проблема: потоки перевикористовуються
public class UserContext {
    private static ThreadLocal<User> context = new ThreadLocal<>();

    public static void set(User user) {
        context.set(user);
    }
}

// Request 1: User A
UserContext.set(userA);
// ... обробка ...
// Забули UserContext.remove()!

// Request 2 (той самий потік): User B
// context.get() → повертає userA! ← Баг + витік

Рішення:

// ✅ Завжди видаляйте у finally
try {
    context.set(user);
    // обробка
} finally {
    context.remove();  // Обов'язково!
}

2. Inner Classes (неявні посилання)

// ❌ Нестатичний внутрішній клас
public class Outer {
    private byte[] data = new byte[1_000_000];  // 1 МБ

    class Inner {  // Неявно тримає посилання на Outer.this
        void doWork() { }
    }

    public void leak() {
        Inner inner = new Inner();
        executor.submit(inner::doWork);  // Inner переданий в інший потік
        // Outer (1 МБ) не видалиться, поки Inner живий!
    }
}

// ✅ Статичний вкладений клас
static class Inner {  // Немає посилання на Outer
    void doWork() { }
}

3. Static Collections

// ❌ Кеш без ліміту
static Map<String, Data> cache = new HashMap<>();

public Data getData(String key) {
    if (!cache.containsKey(key)) {
        Data data = loadFromDb(key);
        cache.put(key, data);  // Росте нескінченно!
    }
    return cache.get(key);
}

// ✅ Кеш з лімітом (Caffeine)
static Cache<String, Data> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build();

4. Зловживання String.intern()

// ❌ Інтернування мільйонів унікальних рядків
while (readingLogs) {
    String uniqueLog = readLine();
    uniqueLog.intern();  // У Java 7+ рядки з StringTable можуть бути видалені GC (якщо ClassLoader, що завантажив їх, зібраний).
                         // Але при інтенсивному intern() таблиця росте швидше, ніж очищується.
}

// StringTable у Heap → забивається → OOM

String.intern() корисний для дедуплікації відомого набору повторюваних рядків (enum-подібні значення, ключі). Шкідливий для унікальних рядків (логи, UUID).

5. Listeners та Observers

// ❌ Підписались, але не відписались
eventBus.subscribe(listener);
// Об'єкт listener не видалиться, поки живий eventBus

🔴 Senior Level

ClassLoader Leak (Metaspace)

Динамічна генерація класів:
  Spring → проксі через CGLIB
  Hibernate → проксі для Entity
  Groovy/JavaScript → компіляція скриптів

Кожен згенерований клас → Metaspace
Класи очищаються тільки з ClassLoader

Якщо ClassLoader не GC:
  → Всі класи залишаються
  → Metaspace росте → OOM

Причини витоку ClassLoader:
  1. ThreadLocal зберігає об'єкт з ClassLoader додатку
  2. DriverManager зберігає посилання на JDBC driver
  3. LogManager зберігає посилання на Logger додатку
  4. java.beans.Introspector кешує BeanInfo

Діагностика:

# Перевірка Metaspace
jcmd <pid> VM.metaspace

# Відстеження завантаження класів
-Xlog:class+load=info
-XX:+TraceClassLoading
-XX:+TraceClassUnloading

# Якщо класів завантажується більше, ніж вивантажується → витік!

Off-Heap Memory Leaks

// DirectByteBuffer — пам'ять поза Heap
ByteBuffer buf = ByteBuffer.allocateDirect(100 * 1024 * 1024);
// 100 МБ поза -Xmx!

// Phantom Reference очищує буфер, коли ByteBuffer GC'd
// Але якщо GC не запускається (Heap порожній) → Native Memory росте

// NIO Cleaner працює асинхронно
// → Затримка між "об'єкт мертвий" і "пам'ять звільнена"

// Рішення:
// 1. Обмежити: -XX:MaxDirectMemorySize=2g
// 2. Ручне очищення: ((DirectBuffer) buf).cleaner().clean();
//    ⚠️ Увага: cleaner().clean() — внутрішній JDK API, не гарантується
//    між версіями. Використовуйте тільки як крайній захід.
// 3. Моніторинг: NMT

Dynamic Proxies та Metaspace

// Кожна генерація проксі → новий клас
for (int i = 0; i < 1_000_000; i++) {
    // Новий клас для кожного виклику!
    Object proxy = Proxy.newProxyInstance(
        classLoader,
        new Class<?>[] { Interface.class },
        handler
    );
}

// 1 млн класів → Metaspace OOM!
// Рішення: кешувати проксі

Production Experience

Реальний сценарій #1: Groovy скрипти

  • Додаток компілює Groovy скрипти на льоту
  • Кожен скрипт → новий ClassLoader → нові класи
  • За 2 тижні: Metaspace 256 МБ → 4 ГБ → OOM
  • Рішення: кешувати скомпільовані скрипти

Реальний сценарій #2: Netty Direct Buffers

  • Netty виділяє DirectByteBuffer для кожного з’єднання
  • 10,000 з’єднань × 1 МБ = 10 ГБ Native Memory
  • Heap порожній (дані одразу відправляються) → GC не запускається
  • OOM Killer убиває процес
  • Рішення: -XX:MaxDirectMemorySize + pool буферів

Best Practices

  1. ThreadLocal.remove() у finally завжди
  2. Static collections → ліміт + eviction policy
  3. Inner classes — робіть вкладений клас static, якщо йому НЕ потрібен доступ до полів зовнішнього класу. Якщо доступ потрібен — залишайте нестатичним, але контролюйте час життя.
  4. try-with-resources для всіх Closeable
  5. Unsubscribe від listeners при знищенні
  6. Кешуйте проксі та скомпільовані скрипти
  7. MaxDirectMemorySize для контролю Native Memory
  8. NMT для моніторингу поза-heap пам’яті

Резюме для Senior

  • ThreadLocal — причина #1 у web-додатках
  • Inner classes — неявне посилання на outer
  • ClassLoader leaks — Metaspace росте непомітно
  • DirectByteBuffer — витік поза Heap (потрібен NMT)
  • Dynamic proxies — кешуйте!
  • String.intern() — обережно з унікальними рядками
  • Listeners — завжди відписуйтесь
  • Native Memory — може вбити при порожньому Heap

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • ThreadLocal у thread pool: потоки перевикористовуються → .remove() у finally обов’язковий
  • Нестатичний inner class неявно тримає посилання на Outer.this — робіть static, якщо не потрібен доступ до outer
  • Static колекції без ліміту — нескінченне зростання → використовуйте Caffeine/Guava з maximumSize
  • String.intern() для унікальних рядків (логи, UUID) забиває StringTable → OOM
  • ClassLoader Leak: динамічна генерація класів (Spring/CGLIB/Groovy) → Metaspace росте
  • DirectByteBuffer — пам’ять поза Heap (-XX:MaxDirectMemorySize для контролю)
  • Listeners/Observers: підписались → відпишіться при знищенні

Часті уточнюючі запитання:

  • Чому Inner Class викликає витік? — Кожен нестатичний inner class має неявне поле Outer.this; якщо inner переданий в інший потік — весь outer не видалиться
  • Як запобігти ClassLoader Leak? — Кешувати проксі та скомпільовані скрипти; перевірити ThreadLocal, DriverManager, Introspector
  • Чому DirectByteBuffer небезпечний? — Пам’ять поза -Xmx; GC може не запускатися при порожньому Heap → Native Memory росте → OOM Killer уб’є процес
  • Коли String.intern() корисний? — Для дедуплікації відомого набору повторюваних рядків (enum-подібні значення); шкідливий для унікальних

Червоні прапорці (НЕ говорити):

  • «ThreadLocal безпечний у Tomcat» — без .remove() це причина #1 витоків та security breach
  • cleaner().clean() для DirectByteBuffer — це внутрішній JDK API, не гарантується між версіями
  • «Inner class — це просто синтаксис, ніякого витоку немає» — неявне посилання на outer існує

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

  • [[6. Що таке витік пам’яті в Java]]
  • [[11. Що таке Metaspace (або PermGen)]]
  • [[21. Що таке memory leak і як його виявити]]
  • [[2. Що зберігається в Heap]]
  • [[18. Що таке параметри -Xms та -Xmx]]