Утечки памяти в Java: причины и способы обнаружения

Backend2026-09-27TryQuickToolBox

Ваше Java-приложение быстро запускается, но через часы или дни замедляется до ползания, выбрасывает OutOfMemoryError или убивается контейнером. Вы перезапускаете его, и цикл повторяется. Это классический признак утечки памяти в Java: объекты, которые больше не нужны, но остаются достижимыми, поэтому сборщик мусора не может их освободить. В отличие от C/C++, сборщик мусора Java автоматически управляет большей частью памяти, но утечки всё равно случаются, когда ссылки удерживаются непреднамеренно. В этой статье объясняются наиболее распространённые причины и даётся практический рабочий процесс для их обнаружения и устранения.

Что такое утечка памяти в Java?

Утечка памяти происходит, когда объекты больше не используются приложением, но всё ещё имеют ссылки, что препятствует сборке мусора. Со временем использование кучи растёт, сборка мусора запускается чаще, и в конечном итоге JVM выбрасывает OutOfMemoryError: Java heap space. Утечки могут быть небольшими и медленными (несколько КБ на запрос) или большими и быстрыми (кэширование целых наборов результатов).

Распространённые причины утечек памяти в Java

1. Статические коллекции, которые растут бесконечно

Статические поля живут в течение всего времени работы JVM. Статический Map, используемый как кэш без вытеснения, — классическая утечка. Например:

public class UserCache {
    private static final Map<Long, User> CACHE = new HashMap<>();

    public static void put(Long id, User user) {
        CACHE.put(id, user); // никогда не удаляется
    }
}

Каждый добавленный пользователь остаётся навсегда. Решение: используйте ограниченный кэш, например Caffeine или Guava, с ограничениями по размеру и временем жизни.

2. Незакрытые ресурсы

Потоки, соединения и читатели должны быть закрыты. Если вы забудете, нативные ресурсы и их Java-обёртки утекают. Всегда используйте try-with-resources:

try (InputStream in = new FileInputStream(file)) {
    // используем in
} // автоматически закрывается

Это также относится к соединениям с базой данных, HTTP-клиентам и пулам потоков.

3. Слушатели и обратные вызовы, которые не удаляются

Когда вы регистрируете слушателя (например, addListener), но никогда не удаляете его, издатель содержит ссылку на слушателя, который может содержать ссылку на весь ваш граф объектов. Это часто встречается в GUI, шинах событий и долгоживущих контекстах.

4. Переменные ThreadLocal

Значения ThreadLocal хранятся в ThreadLocalMap потока. В пулах потоков потоки переиспользуются, поэтому если вы не вызываете remove(), значение остаётся привязанным к потоку и утекает. Всегда очищайте:

try {
    threadLocal.set(context);
    // работа
} finally {
    threadLocal.remove();
}

5. Внутренние классы, удерживающие ссылки на внешние

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

6. Неправильные equals() и hashCode()

Если вы используете объекты в качестве ключей в HashMap, но переопределяете equals() без hashCode() (или наоборот), поиск не работает, и записи накапливаются. Всегда переопределяйте оба согласованно.

7. Интернирование строк и подстроки

До Java 7 String.substring() использовал общий исходный массив символов, поэтому небольшая подстрока могла удерживать большой массив. В современной Java происходит копирование, но интернирование множества уникальных строк (например, String.intern()) всё ещё может заполнить пул строк.

Как обнаружить утечки памяти в Java

Обнаружение следует систематическому процессу. Вот пошаговый подход:

  1. Следите за использованием кучи с течением времени. Используйте JVisualVM, JConsole или ваш APM. Здоровое приложение показывает пилообразный шаблон: куча растёт, GC освобождает, куча падает. При утечке наблюдается рост базовой линии после каждой сборки мусора.
  2. Включите логирование GC. Добавьте -Xlog:gc*:file=gc.log:time,uptime,level,tags (Java 9+) или -XX:+PrintGCDetails (Java 8). Анализируйте логи с помощью таких инструментов, как GCeasy, чтобы увидеть, продолжает ли расти old gen.
  3. Снимите дамп кучи. Когда куча заполнена, выполните jmap -dump:live,format=b,file=heap.hprof <pid> или используйте jcmd <pid> GC.heap_dump heap.hprof. Вы также можете вызвать дамп при OutOfMemoryError с помощью -XX:+HeapDumpOnOutOfMemoryError.
  4. Проанализируйте дамп кучи. Откройте его в Eclipse MAT или VisualVM. Ищите дерево доминаторов, чтобы найти объекты, удерживающие больше всего памяти. Проверьте подозрительные коллекции с большим объёмом удерживаемой памяти.
  5. Снимите второй дамп и сравните. Если одни и те же объекты растут между дампами, вы нашли утечку.
  6. Используйте профилировщик для анализа в реальном времени. Такие инструменты, как YourKit, JProfiler или async-profiler, могут отслеживать места выделения памяти и цепочки ссылок без остановки приложения.

Вот краткое сравнение распространённых инструментов:

ИнструментТипЛучше всего подходит для
Eclipse MATАнализатор дампа кучиПоиск доминаторов и подозреваемых утечек
VisualVMМониторинг + дамп кучиБыстрые проверки в реальном времени и базовый анализ
JProfiler / YourKitПрофилировщикОтслеживание выделения памяти и цепочки ссылок
async-profilerПрофилировщик с низкими накладными расходамиПрофилирование в production с flame-графиками

Исправление и предотвращение утечек

Как только вы обнаружили утечку, исправьте её, удалив непреднамеренную ссылку. Распространённые исправления:

Предотвращение лучше лечения. Добавьте мониторинг памяти в ваш CI/CD конвейер, проводите нагрузочные тесты с проверкой кучи и устанавливайте -Xmx соответствующим образом. Используйте инструменты статического анализа, такие как SpotBugs, для выявления распространённых паттернов.

Часто задаваемые вопросы

В чём разница между утечкой памяти и всплеском памяти?

Всплеск памяти — это временное увеличение использования кучи, которое сборщик мусора освобождает. Утечка — это устойчивый рост со временем, потому что объекты остаются достижимыми и не могут быть собраны.

Может ли утечка памяти в Java вызвать OutOfMemoryError, даже если куча большая?

Да. Если скорость утечки превышает способность GC освобождать память, куча в конечном итоге заполнится независимо от размера. Увеличение кучи только откладывает сбой.

Как найти утечку памяти в production без простоя?

Используйте инструменты с низкими накладными расходами, такие как async-profiler или JFR (Java Flight Recorder), для записи выделений памяти и статистики кучи. Вы также можете вызвать дамп кучи с помощью jcmd, пока приложение работает, хотя это может вызвать кратковременную паузу.

Нужно проанализировать GC-логи или другие текстовые логи? Попробуйте наш Nginx Log Analyzer, чтобы быстро разобрать и визуализировать шаблоны логов.