Avatar ·

Как создать утечку памяти в Java? Примеры и причины

Всем привет. Я начинающий Java-разработчик, и я постоянно слышу, что в Java есть сборщик мусора, который автоматически очищает память. Но мне сказали, что даже с GC можно получить утечку памяти (memory leak). Я пытался понять, как это возможно, но все примеры, которые я нашел, слишком сложные или используют старые версии Java. Мне нужно написать небольшой учебный проект, который демонстрирует утечку памяти, чтобы лучше понять, как работает сборщик мусора и как избегать таких проблем в реальной работе. Я пробовал просто создавать много объектов в цикле без сохранения ссылок — но GC их подчищает. Как сделать, чтобы объекты оставались в памяти и не удалялись? Вот пример, который я попробовал, но он не приводит к утечке: ```java public class MemoryLeakTest { public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 1000000; i++) { String s = new String("test" + i); } System.gc(); Thread.sleep(10000); } } ``` Память не растет. Подскажите, как реально создать утечку? Какие механизмы в Java могут привести к тому, что объекты остаются живыми, хотя они уже не нужны? Интересуют примеры с коллекциями, слушателями, классами-загрузчиками или чем-то подобным. Заранее спасибо! <<>>

Что такое утечка памяти в Java и как её воспроизвести

Утечка памяти в Java возникает, когда объекты больше не нужны приложению, но сборщик мусора (GC) не может их удалить, потому что на них остаются активные ссылки. В отличие от C++, где утечка — это потерянный указатель на выделенную память, в Java утечка проявляется как постепенное увеличение используемой памяти (heap) без возможности её освободить, что в итоге приводит к OutOfMemoryError.

Самый простой и наглядный способ создать утечку — использовать статическую коллекцию (например, HashMap или ArrayList), в которую вы постоянно добавляете объекты, но никогда не удаляете их. GC считает такие объекты "живыми", потому что на них ссылается статическая переменная, которая живёт всё время работы приложения.

Пример 1: Утечка через статическую коллекцию


import java.util.ArrayList;
import java.util.List;

public class StaticCollectionLeak {
    // Статическая коллекция живёт вечно
    private static List<byte[]> leakHolder = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException {
        System.out.println("Запуск утечки...");
        while (true) {
            // Добавляем массив по 1 МБ
            leakHolder.add(new byte[1024 * 1024]);
            Thread.sleep(50); // небольшая задержка, чтобы увидеть рост
            System.out.println("Добавлен 1 МБ. Всего: " + leakHolder.size() + " МБ");
        }
    }
}

Запустите этот код с параметром -Xmx256m (ограничение кучи 256 МБ). Через некоторое время вы получите OutOfMemoryError: Java heap space. Это классическая утечка: объекты добавляются, но никогда не удаляются.

Пример 2: Утечка через внутренние классы и слушатели

Другая распространённая причина — забытые подписки на события. Если вы регистрируете слушатель (listener), но не отписываете его, объект-слушатель не может быть собран GC, потому что на него ссылается объект, который управляет событиями.


import java.util.ArrayList;
import java.util.List;

public class ListenerLeak {
    public static void main(String[] args) {
        List<Runnable> listeners = new ArrayList<>();

        for (int i = 0; i < 100_000; i++) {
            // Анонимный класс неявно хранит ссылку на внешний объект (если есть)
            Runnable listener = () -> System.out.println("Event");
            listeners.add(listener); // регистрируем, но никогда не удаляем
        }
        // listeners живёт, все 100 000 объектов Runnable остаются в памяти
        System.out.println("Создано " + listeners.size() + " слушателей. Они не будут собраны GC.");
    }
}

Пример 3: Утечка через ThreadLocal

ThreadLocal хранит данные для каждого потока. Если вы используете пул потоков (например, Executors.newFixedThreadPool) и не удаляете значения из ThreadLocal после использования, они остаются в памяти до тех пор, пока жив поток. В пуле потоки не умирают, поэтому утечка будет накапливаться.


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalLeak {
    private static ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                threadLocal.set(new byte[1024 * 1024]); // 1 МБ на поток
                // Забыли вызвать threadLocal.remove()
            });
        }
        executor.shutdown();
        // Потоки в пуле живы, threadLocal хранит ссылки на массивы — утечка!
    }
}

Как обнаружить утечку?

  • Используйте профилировщики (VisualVM, JProfiler, YourKit) для анализа heap dump.
  • Включите логирование GC с флагами -XX:+PrintGCDetails -Xloggc:gc.log.
  • Следите за паттерном: если после полного GC (Full GC) память не возвращается к исходному уровню — это утечка.

Вывод

Утечка памяти в Java — это всегда результат непреднамеренного удержания ссылок. Чтобы избежать её:

  • Всегда очищайте коллекции, если они используются как кеш (используйте WeakHashMap или Cache с ограничением размера).
  • Отписывайте слушатели (listeners) в методе removeXxxListener().
  • Вызывайте ThreadLocal.remove() после завершения обработки в потоке.
  • Не храните большие объекты в статических полях без необходимости.
<<>>

Альтернативный подход: утечка через кастомный класс-загрузчик (ClassLoader)

Ещё один интересный, но более редкий способ — утечка через ClassLoader. Если вы динамически загружаете классы (например, в серверных приложениях или плагинах) и не даёте сборщику мусора удалить загрузчик, то все классы, загруженные им, и их статические поля остаются в памяти. Это часто встречается в старых версиях Tomcat или при горячей перезагрузке модулей.


import java.net.URL;
import java.net.URLClassLoader;

public class ClassLoaderLeak {
    public static void main(String[] args) throws Exception {
        while (true) {
            // Создаём новый загрузчик классов
            URLClassLoader cl = new URLClassLoader(new URL[]{new URL("file:///tmp/")});
            // Загружаем класс (например, MyClass)
            Class<?> clazz = cl.loadClass("MyClass");
            // Не закрываем загрузчик — он остаётся в PermGen/Metaspace
            // Если держать ссылку на cl, утечка будет накапливаться
            Thread.sleep(100);
        }
    }
}

В современных версиях Java (8+) этот эффект менее выражен из-за Metaspace (память вне кучи), но всё равно может привести к проблемам, если загрузчиков создаётся много. Решение: всегда закрывайте URLClassLoader через cl.close() и обнуляйте ссылки на него.

Этот пример хорош для демонстрации того, что утечки бывают не только в куче (heap), но и в Metaspace/PermGen.