Почему Java-код течёт? Создаём и ловим утечки памяти в 2025

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: «Сломать — не построить»

Каждый Java-разработчик хоть раз проклинал утечку памяти (memory leak) в своём production-приложении. Приложение падает с OutOfMemoryError, сервер уходит в ребут, пользователи пишут гневные письма. Но есть и обратная сторона медали: как создать утечку памяти в Java намеренно? Звучит безумно, но именно этот вопрос (оригинал с Stack Overflow) собрал тысячи просмотров.

Зачем это знать? Чтобы понимать механику сборщика мусора (GC) и антипаттерны, которые превращают ваш код в решето. В 2025 году, когда Java 21 с виртуальными потоками и Loom уже стандарт, старые утечки никуда не делись — они просто маскируются под новые фичи.

В этой статье мы не только покажем, как «испортить» Java-приложение, но и дадим лучшие практики обнаружения утечек в 2025 году. Готовы? Поехали!

Немного теории: почему Java не «вечная»

Многие новички свято верят, что сборщик мусора (GC) автоматически удаляет всё, что не нужно. Реальность жестока: утечка памяти в Java возможна, если объект остаётся достижимым (reachable) из корневых ссылок (GC roots), но при этом больше не нужен приложению.

В 2025 году, с появлением Value Objects (Project Valhalla) и улучшенных ZGC (сборщик с временем паузы < 1 мс), ситуация с производительностью улучшилась, но логика утечек осталась прежней. GC не телепат — он не знает, что вы забыли обнулить ссылку в HashMap.

Способ №1: Классика — «забывчивый» HashMap

Самый популярный антипаттерн, который кочует из статьи в статью. Но без него — никуда.

Как работает утечка

Вы кладёте объекты в HashMap (или HashSet) в качестве ключей, но забываете их удалить. Если ключи — mutable-объекты, которые меняют хеш-код после вставки, то вы гарантированно получите утечку памяти Java.

import java.util.HashMap;
import java.util.Map;

public class LeakyMapExample {
    public static void main(String[] args) {
        Map<StringBuilder, String> map = new HashMap<>();

        for (int i = 0; i < 100_000; i++) {
            StringBuilder key = new StringBuilder("key_" + i);
            map.put(key, "value_" + i);
            // "Забыли" удалить — ключи висят вечно
        }
        System.out.println("Размер карты: " + map.size());
        // Утечка: 100_000 объектов StringBuilder + String в куче
    }
}

Почему это утечка? Пока жива ссылка на map, все ключи и значения остаются в heap. Даже если логика приложения больше не использует эти данные, GC их не тронет.

Лучшая практика 2025

  • Используйте WeakHashMap, если ключи — временные объекты.
  • Для кэширования — Caffeine или Guava Cache с лимитами по размеру/времени.
  • Никогда не используйте StringBuilder как ключ — только immutable-типы.

Способ №2: Статические коллекции — тихий убийца

Статические поля живут до конца работы JVM. Если вы складываете данные в static List или static Map и не чистите их, вы получаете утечку памяти Java на уровне класса.

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

public class StaticLeak {
    private static final List<byte[]> CACHE = new ArrayList<>();

    public static void addData() {
        // Каждый вызов добавляет 10 МБ в статический список
        CACHE.add(new byte[10 * 1024 * 1024]);
    }

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1_000; i++) {
            addData();
            Thread.sleep(100);
        }
        // OutOfMemoryError неизбежен
    }
}

В 2025 году эта проблема особенно актуальна для виртуальных потоков (Virtual Threads). Если вы используете ThreadLocal с виртуальными потоками, а пул потоков бесконечен, каждый новый поток может создать новый объект в статической карте — привет, утечка!

Способ №3: Callback-ад и слушатели

Вы подписались на событие, но забыли отписаться. Классика жанра.

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

public class CallbackLeak {
    interface Listener {
        void onEvent();
    }

    private static final List<Listener> listeners = new ArrayList<>();

    public static void register(Listener l) {
        listeners.add(l);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            final int id = i;
            register(() -> System.out.println("Event " + id));
            // Никто не вызывает listeners.clear()
        }
        // Каждый лямбда-объект захватывает id — утечка!
    }
}

Нюанс: лямбды в Java захватывают переменные. Каждая лямбда — это отдельный объект в куче. Если слушателей миллион, а вы не отписываетесь, GC будет держать их вечно.

Способ №4: Внутренние классы и неявные ссылки

Нестатический внутренний класс (inner class) всегда содержит неявную ссылку на внешний класс. Если вы создаёте экземпляр внутреннего класса и сохраняете его, внешний объект никогда не будет собран GC.

public class OuterLeak {
    private byte[] bigData = new byte[100 * 1024 * 1024]; // 100 МБ

    class Inner {
        void doSomething() {
            System.out.println("I'm leaking!");
        }
    }

    public static void main(String[] args) {
        OuterLeak outer = new OuterLeak();
        Inner inner = outer.new Inner();
        // outer = null; // Закомментировано — утечка!
        // inner всё ещё держит ссылку на outer
        System.gc(); // Не поможет — inner жива
    }
}

Решение: делайте внутренние классы статическими (static class Inner), если они не должны иметь доступ к полям внешнего класса. Или используйте record (Java 16+), которые по умолчанию immutable.

Инструменты поиска утечек в 2025 году

Ручной поиск утечек — прошлый век. Вот лучшие практики для современного Java-разработчика:

1. JProfiler и YourKit

Классические профилировщи

Блоги

Рекомендаций по книгам