Как создать утечку памяти в 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.