Як може статися витік пам'яті в Java?
Витік пам'яті виникає, коли посилання на об'єкт залишається, хоча об'єкт вже не потрібен.
🟢 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
- ThreadLocal.remove() у
finallyзавжди - Static collections → ліміт + eviction policy
- Inner classes — робіть вкладений клас
static, якщо йому НЕ потрібен доступ до полів зовнішнього класу. Якщо доступ потрібен — залишайте нестатичним, але контролюйте час життя. - try-with-resources для всіх Closeable
- Unsubscribe від listeners при знищенні
- Кешуйте проксі та скомпільовані скрипти
- MaxDirectMemorySize для контролю Native Memory
- 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]]