1. Типові помилки під час роботи з пам’яттю
Час поглянути на зворотний бік «магії» автоматичного керування пам’яттю. Навіть якщо ви не пишете на C, де за кожним байтом доводиться стежити особисто, у Java можна припуститися грубих помилок так, що застосунок споживатиме пам’ять, як ненажерливий кіт — ковбасу. Розберімо найпоширеніші помилки та способи їх уникнути.
Забуті слухачі (listeners)
У Java часто використовується патерн «слухач» — об’єкт, який підписується на події іншого об’єкта. Наприклад, ви створили кнопку та додали до неї обробник кліку:
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// обробка кліку
}
});
Проблема: якщо ви забули видалити цього слухача (removeActionListener), коли кнопка або вікно вже не потрібні, слухач продовжує висіти в пам’яті. Навіть якщо ви закрили вікно і всі посилання на нього обнулили, об’єкт‑слухач усе ще тримає посилання на вікно (або навпаки), не даючи збирачу сміття звільнити пам’ять.
Аналогія: Уявіть, що ви переїхали, але забули відписатися від розсилки піцерії — вам і далі надсилають рекламу на стару адресу.
Статичні колекції, які не очищаються
Статичні поля живуть стільки ж, скільки й клас (а інколи — до кінця життя застосунку). Якщо у вас є статична колекція:
public class Cache {
public static final List<String> globalList = new ArrayList<>();
}
і ви додаєте туди об’єкти, але не видаляєте їх, вони висітимуть у пам’яті вічно. Навіть якщо на самі об’єкти більше ніде немає посилань, посилання зі статичної колекції не дасть GC їх прибрати.
Реальний приклад: Кеш фотографій у десктопному застосунку, який ніколи не чиститься. Через кілька годин роботи — OutOfMemoryError.
Незвільнення ресурсів (файли, потоки, з’єднання)
Хоч Java й звільняє пам’ять, вона не займається автоматичним закриттям файлових дескрипторів, мережевих з’єднань та інших зовнішніх ресурсів. Якщо забути закрити файл або потік, ресурс залишиться висіти, і в якийсь момент система скаже: «Усе, більше файлів не дам!» (IOException: Too many open files).
Порада: Завжди використовуйте try-with-resources:
try (FileInputStream in = new FileInputStream("data.txt")) {
// читаємо файл
} // in.close() буде викликано автоматично!
Великі об’єкти, що довго затримуються в пам’яті
Іноді ви створюєте великий масив або колекцію, використовуєте її, а потім «забуваєте відпустити». Наприклад:
List<byte[]> bigList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
bigList.add(new byte[1024 * 1024]); // 1 МБ кожен
}
// ... забули очистити bigList
Якщо ця колекція живе в статичному полі або в об’єкті, який довго не видаляється, вся ця пам’ять залишиться зайнятою.
Внутрішні й анонімні класи: захоплення зовнішніх посилань
Анонімні (і внутрішні) класи в Java зберігають неявне посилання на зовнішній об’єкт:
public class Outer {
void doSomething() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello from inner!");
}
};
// r десь зберігається
}
}
Якщо об’єкт r потрапить у статичну колекцію або кеш, він «триматиме» посилання на екземпляр Outer, навіть якщо той уже не потрібен. У результаті — витік пам’яті. З лямбда‑виразами ситуація трохи краща, але якщо лямбда використовує поля зовнішнього класу, посилання все одно зберігається.
2. Помилки під час роботи зі збирачем сміття
Примусовий виклик System.gc()
Багато новачків думають: «Пам’ять закінчується — викличу System.gc() і все вирішиться!». Насправді це лише прохання до JVM, а не гарантія негайного збирання. Часте використання може різко погіршити продуктивність, спричинити довгі паузи та зависання. У реальних застосунках краще довіряти JVM — вона сама вирішить, коли збирати сміття. До речі, деякі JVM можуть ігнорувати явні виклики GC (наприклад, з опцією -XX:+DisableExplicitGC).
Ігнорування логів GC
У логах GC видно, коли відбуваються збирання, скільки часу вони займають і скільки пам’яті звільняється. Якщо не дивитися ці логи, можна пропустити сигнали про проблеми: довгі паузи, часті Full GC, витоки пам’яті.
Як увімкнути логи GC:
java -Xlog:gc* -jar MyApp.jar
або для старих JVM:
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar MyApp.jar
Неправильний вибір GC для завдання
Вибір збирача впливає на затримки та стабільність. Для низької затримки (біржі, онлайн‑ігри) паралельний «стоп‑світ» Parallel GC — погана ідея: він може «заморожувати» всі потоки на час збирання. Розгляньте G1 GC, ZGC або Shenandoah.
3. Помилки з колекціями
Використання HashMap замість WeakHashMap для кешів.
Якщо ви робите кеш, де об’єкти мають автоматично видалятися, коли на них більше немає «живих» посилань, використовуйте WeakHashMap:
Map<Key, Value> cache = new WeakHashMap<>();
Зі звичайним HashMap об’єкти житимуть, доки кеш не очиститься вручну, що призведе до витоку пам’яті.
Забуті remove() для елементів.
Якщо ви додаєте об’єкти в колекції (наприклад, до списків слухачів), але не видаляєте їх, коли вони більше не потрібні, ці об’єкти житимуть вічно, особливо в довгоживучих колекціях (наприклад, статичних).
4. Найкращі практики: як уникати проблем
Завжди видаляйте слухачів.
Якщо об’єкт підписався на події, обов’язково відпишіть його, коли він більше не потрібен. Зручно робити це в методі dispose() або під час закриття вікна/екрана.
button.removeActionListener(myListener);
Використовуйте слабкі посилання для кешів.
Якщо в кеші можна обійтися без гарантії збереження об’єкта, використовуйте WeakReference або колекції на їх основі (WeakHashMap). Так GC зможе звільнити пам’ять, коли вона знадобиться.
Моніторте пам’ять у продакшені.
Використовуйте jvisualvm, jconsole або APM‑системи. Це допомагає виявити витоки до скарг користувачів.
Аналізуйте heap dump у разі підозри на витік
Якщо застосунок став споживати більше пам’яті, ніж зазвичай, зніміть heap dump (наприклад, через jmap або jvisualvm) і перегляньте, які об’єкти займають найбільше місця. Часто винуватець знаходиться за кілька хвилин.
Налаштовуйте параметри JVM.
- -Xmx — максимальний розмір купи
- -Xms — початковий розмір купи
Розумні ліміти допомагають уникнути OutOfMemoryError і пришвидшують діагностику.
5. Практика: приклад коду з витоком пам’яті та його виправлення
Приклад 1: Витік через статичну колекцію
public class MemoryLeakDemo {
// Статична колекція — живе вічно
private static final List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
leakyList.add(new byte[1024 * 1024]); // 1 МБ щоразу
System.out.println("Додано " + (i + 1) + " МБ");
}
// OutOfMemoryError!
}
}
Виправлення: Використовуйте локальну змінну або очищайте колекцію, коли вона більше не потрібна.
public class MemoryLeakFixed {
public static void main(String[] args) {
List<byte[]> tempList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
tempList.add(new byte[1024 * 1024]);
System.out.println("Додано " + (i + 1) + " МБ");
}
// tempList = null; // Можна явно обнулити
// Тепер об’єкти доступні для GC після виходу з методу
}
}
Приклад 2: Витік через слухача
public class Window {
private final List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener l) {
listeners.add(l);
}
// Немає методу removeListener!
}
Виправлення: Додайте метод для видалення слухача та викликайте його під час закриття вікна.
public void removeListener(EventListener l) {
listeners.remove(l);
}
Приклад 3: Кеш із HashMap замість WeakHashMap
Map<Object, Object> cache = new HashMap<>();
// ... додаємо об’єкти
Виправлення: Перейдіть на WeakHashMap:
Map<Object, Object> cache = new WeakHashMap<>();
Поради з налаштування JVM для моніторингу пам’яті
- Увімкайте GC‑логи: -Xlog:gc* або -XX:+PrintGCDetails
- Обмежте максимальний розмір купи: -Xmx512m
- Якщо використовуєте кеші — стежте за їхнім розміром і застосовуйте слабкі посилання, якщо це припустимо
- Експериментуйте з GC: -XX:+UseG1GC, -XX:+UseZGC, -XX:+UseShenandoahGC
7. Типові помилки під час роботи з пам’яттю
Помилка № 1: Забуті слухачі та підписки. Якщо ви додали слухача до об’єкта, але забули його видалити, об’єкт‑слухач (і все, на що він посилається) залишиться в пам’яті. Класика для GUI та подієвих систем. Використовуйте removeListener/removeActionListener.
Помилка № 2: Статичні колекції без очищення. Статичні поля живуть найдовше. Якщо ви кладете в них об’єкти й не чистите колекцію, ці об’єкти залишаться в пам’яті назавжди. Особливо підступно для безрозмірних кешів.
Помилка № 3: Незвільнення зовнішніх ресурсів. Залишили відкритим потік, файл або з’єднання? Ви втрачаєте пам’ять і впираєтеся в ліміти ОС. Використовуйте try-with-resources та закривайте ресурси.
Помилка № 4: Примусовий виклик System.gc(). Це не панацея, а лише прохання до JVM. Часто призводить до пауз і деградації продуктивності.
Помилка № 5: Звичайні колекції для кешів. Якщо об’єкти в кеші мають видалятися самі, застосовуйте слабкі/soft‑посилання (WeakHashMap, SoftReference). Інакше отримаєте витік.
Помилка № 6: Внутрішні й анонімні класи, що захоплюють зовнішні посилання. Внутрішні класи та лямбди можуть неявно утримувати посилання на зовнішній об’єкт. Якщо їх зберегти в довгоживучій колекції — це витік.
Помилка № 7: Ігнорування логів GC. Якщо ви не дивитеся логи GC, ви не дізнаєтеся про довгі паузи або часті Full GC — а користувачі дізнаються через пригальмовування та зависання. Увімкайте -Xlog:gc* або -XX:+PrintGCDetails.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ