JavaRush /Курси /JAVA 25 SELF /Розбір типових помилок під час роботи з пам’яттю

Розбір типових помилок під час роботи з пам’яттю

JAVA 25 SELF
Рівень 64 , Лекція 4
Відкрита

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.

1
Опитування
Пам’ять і збірка сміття, рівень 64, лекція 4
Недоступний
Пам’ять і збірка сміття
Пам’ять і збірка сміття
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ