Як може статися витік пам'яті в 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 не вивантажується:
- Реєстрація в системних реєстрах: JDBC-драйвер зареєструвався в системному
java.sql.DriverManager(який завантажений системним завантажувачем) і не викликавderegisterDriver(). ThreadLocalз об’єктом застосунку: У пулі потоків Tomcat залишився висітиThreadLocal<MyContext>. КласMyContextпосилається наWebAppClassLoader, утримуючи в пам’яті гігабайти метаданих старої версії застосунку.- Демон-потоки: Фоновий планувальник (наприклад,
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 до непотрібних об’єктів. Основні практичні причини поділяються на:
- Колекції: статичні кеші без лімітів (Caffeine вирішує проблему) та мутація ключів у
HashSet/HashMap.- Контексти потоків:
ThreadLocalбез.remove()у пулах потоків веб-серверів.- Внутрішні класи: неявне утримання зовнішнього
Outer.thisнестатичними анонімними класами.- Metaspace: невивантажувані
ClassLoaderчерез висячі JDBC-драйвери або daemon-потоки.- Off-Heap: накопичення
DirectByteBufferпри порожній купі та незакриті нативні ресурси (Deflater.end())».
3–4 каверзних запитання з відповідями
- Чому мутація полів об’єкта-ключа в
HashMapабоHashSetпризводить до витоку пам’яті?- Відповідь: Під час виклику
map.put(key, val)бакет для вставки обчислюється на основі поточногоhashCode(). Якщо після цього змінити поля ключа, які впливають на геш-код, йогоhashCode()зміниться. При подальшій спробі викликатиmap.remove(key)метод шукатиме ключ в іншому бакеті й не знайде його. Об’єкт залишиться всередині таблиці назавжди, його неможливо видалити чи отримати штатним API.
- Відповідь: Під час виклику
- У чому різниця між анонімним класом та лямбда-виразом з точки зору утримання посилань?
- Відповідь: Нестатичний анонімний клас завжди генерує приховане поле посилання на зовнішній клас (
this$0), навіть якщо всередині свого тіла він взагалі не звертається до полів чи методів батька. Лямбда-вираз створюється черезinvokedynamicі захоплює посилання на зовнішнійthisлише за фактичного звернення до стану зовнішнього класу. Якщо лямбда не використовує поляthis, посилання на зовнішній об’єкт не захоплюється, що унеможливлює витік пам’яті.
- Відповідь: Нестатичний анонімний клас завжди генерує приховане поле посилання на зовнішній клас (
- Чому
DirectByteBufferможе спричинити падіння від OOM Killer за практично порожньої купи (Heap)?- Відповідь: Пам’ять для
DirectByteBufferвиділяється поза купою (Off-Heap). У самій купі живе лише мініатюрний Java-об’єкт-обгортка з адресою нативного буфера, який звільняється черезCleaner. Якщо купа порожня, поріг для запуску Garbage Collector не досягається, і збирання сміття не стартує. У результаті нативні буфери не фіналізуються, сумарний обсяг Resident Set Size (RSS) процесу перевищує ліміт контейнера, і операційна система вбиває JVM за сигналом SIGKILL (Exit Code 137).
- Відповідь: Пам’ять для
- Яким чином невивантажений 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).
Пов’язані теми
- Що таке витік пам’яті в Java — базове визначення та діагностика
- Коли об’єкт стає кандидатом на видалення GC — втрата досяжності від GC Roots
- Що таке Metaspace (або PermGen) — архітектура пам’яті класів
- Що таке heap dump — аналіз витоків через Eclipse MAT
- Що таке GC roots — класифікація коренів графа об’єктів