Не секрет, что GC освобождает только недостижимые объекты. Утечка в Java начинается там, где объект уже не нужен, но на него все еще есть цепочка ссылок от живого потока.

Симптомы обычно одинаковые: куча растет, GC срабатывает чаще, паузы увеличиваются, финал - java.lang.OutOfMemoryError: Java heap space.

  • Самые частые ловушки и фиксы:

  • static List/Map: добавили и не удалили - объекты остаются в памяти до выгрузки класса.

  • Нужны remove/очистка, лимиты, иногда WeakHashMap.

  • non-static inner/anonymous class: неявно держит ссылку на внешний объект, особенно если внутри бесконечный Thread.

  • listener/callback: подписались и не отписались - источник событий удерживает объект (UI, event bus).

  • ThreadLocal в пуле потоков: set() без remove().

  • кэш на HashMap без maxSize/TTL: используйте политики вытеснения (Guava Cache, Caffeine)

В целом вывод такой, что нужно смотреть график heap в VisualVM/JVisualVM/JConsole, снимать heap dump (jmap), в Eclipse MAT запускать Leak Suspects и проверять цепочки удерживающих ссылок.


Автоматический сборщик мусора (Garbage Collection, GC) в Java часто называют «страховочной сеткой»: разработчикам не нужно вручную управлять памятью. Но это не делает Java неуязвимой для утечек памяти. Утечка памяти в Java возникает, когда объекты, которые приложению больше не нужны, не освобождаются GC, потому что на них по-прежнему остаются ссылки. Со временем такие «зомби»-объекты занимают место в куче (heap), увеличивая потребление памяти, ухудшая производительность и в итоге приводя к падениям с OutOfMemoryError.

Комментарий от Рустама Курамшина

Множество статей про разбор OOM в java-сервисах приводит список причин, которые редко подтверждаются в реальных проектах. 

Например, использование map без вытеснения - это скорее логика разработчика, которые первые день пишет код в java-проекте. Обычно же используется кэш менеджеры и либы типа caffeine, где можно управлять вытеснением записей.

В реальной жизни мы имеем дело с неоптимально реализованной логикой обработки запросов. Разработчик может не предусмотреть ограничение на выборку из базы данных, сервис затягивает огромное кол-во записей из БД, и его прибивает kubernetes. Это характерно для проектов на любом стеке, не только spring/java.

Поэтому вам нужно уметь быстро диагностировать такие инциденты руководствуясь heap dump-ами и утилитами вроде Eclipse MAT.

Методология анализа дампа кучи понятно разобрана в докладе с JPoint "Где моя память, чувак?!" - https://www.youtube.com/watch?v=3UP0o2gkeRQ

Что удивительно, утечки памяти в Java нередко появляются из-за тонких, непреднамеренных ошибок в коде — даже у опытных разработчиков. В этой статье разбираются самые простые способы случайно «посадить» утечку памяти: с практическими примерами, объяснением причин и конкретными исправлениями. К финалу вы научитесь распознавать эти ловушки и писать более устойчивый Java-код.

Понимание утечек памяти в Java#

В низкоуровневых языках вроде C/C++ утечки памяти возникают, когда разработчики выделяют память через malloc/new, но забывают освободить её через free/delete. В Java всё иначе: GC автоматически освобождает память объектов, которые недостижимы (то есть ни один «живой» поток не держит на них ссылку).

Утечка памяти в Java происходит, когда:

  • Объект приложению больше не нужен.

  • Объект всё ещё достижим по цепочке ссылок от живого потока.
    GC не может собрать такие объекты, поэтому они остаются в куче и постепенно потребляют ресурсы.

Со временем это приводит к:

  • Росту накладных расходов на сборку мусора (GC запускается чаще).

  • Исчерпанию кучи (java.lang.OutOfMemoryError: Java heap space).

Распространённые ловушки: как случайно «утечь» память

Перейдём к самым простым способам спровоцировать утечки памяти — с примерами кода и пояснениями.

Статические коллекции: «тихий накопитель»

Проблема: статические коллекции (например, List, Map) привязаны к классу, а не к экземплярам. Если добавлять объекты в статическую коллекцию и никогда их не удалять, эти объекты (и всё, на что они ссылаются) никогда не будут собраны сборщиком мусора — даже если других ссылок на них больше нет.

Пример: утилитарный класс кэширует данные в статическом списке, но никогда его не очищает.

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

public class UserSessionCache {

    // Static list: lives as long as the class is loaded
    private static final List<UserSession> activeSessions = new ArrayList<>();

    public static void addSession(UserSession session) {
        activeSessions.add(session); // Add session...
    }

    // No method to remove sessions!
    // public static void removeSession(UserSession session) { 
    // activeSessions.remove(session);
    //}
}

 

class UserSession {

    private String userId;

    // ... other fields ...

}

Почему это ведёт к утечке: каждый вызов addSession(...) добавляет UserSession в activeSessions. Поскольку activeSessions — статическое поле, оно не будет собрано GC. Даже если объекты UserSession больше не нужны (например, пользователь вышел из системы), они остаются в списке и бесконечно «протекают» в памяти.

Исправление:

  • Добавьте метод removeSession, чтобы очищать данные при истечении сессий.

  • Используйте ограниченный по размеру кэш (например, LinkedHashMap с removeEldestEntry) или слабые ссылки (например, WeakHashMap).

Внутренние классы удерживают ссылки на внешний класс

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

Пример: внешний класс с нестатическим внутренним потоком, который работает бесконечно.

public class OrderProcessor {

    private final List<Order> pendingOrders = new ArrayList<>();

    public void startProcessing() {

        // Non-static inner class: holds implicit reference to OrderProcessor
        Thread processingThread = new Thread(new OrderProcessingTask());
        processingThread.start();
    }

    // Non-static inner class
    private class OrderProcessingTask implements Runnable {

        @Override
        public void run() {
            while (true) { // Runs forever!
                processOrders();
                try { Thread.sleep(1000); } catch (InterruptedException e) { /* ... */ }
            }
        }

        private void processOrders() { /* ... */ }
    }
}

Почему это ведёт к утечке: при вызове startProcessing() создаётся OrderProcessingTask (внутренний класс). Он держит сильную ссылку на экземпляр OrderProcessor. Поскольку processingThread работает бесконечно, экземпляр OrderProcessor (и его список pendingOrders) никогда не может быть собран GC — даже если приложению он больше не нужен.

Исправление:

  • Сделайте внутренний класс статическим (static nested classes не ссылаются на внешний класс).

  • Убедитесь, что внутренний класс не живёт дольше внешнего (например, останавливайте поток, когда внешний объект больше не нужен).

Забытые слушатели и колбэки

Проблема: при регистрации слушателей (например, UI-событий, событийных шин или колбэков фреймворков) отсутствие отписки оставляет у источника событий сильную ссылку на слушатель. В результате «утекает» сам слушатель (а вместе с ним — объект, в контексте которого он создан).

Пример: компонент Swing с «забытым» слушателем мыши.

import javax.swing.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

public class LeakyButton extends JButton {

    public LeakyButton() {
        addMouseListener(new MouseAdapter() { // Anonymous inner class (listener)
            @Override
            public void mouseClicked(MouseEvent e) {
                System.out.println("Button clicked!");
            }
        });
        // No removeMouseListener(...) call when the button is discarded!
    }
}

Почему это ведёт к утечке: экземпляр LeakyButton регистрирует анонимный слушатель MouseAdapter. Поток обработки событий Swing (EDT) хранит ссылки на все зарегистрированные слушатели. Если LeakyButton убрать из UI, слушатель (а значит, и LeakyButton) остаётся достижимым через источник событий, что блокирует сборку мусора.

Исправление: Явно удаляйте слушатели, когда объект больше не нужен (например, в методе destroy() или close()).

Неправильное использование переменных ThreadLocal

Проблема: ThreadLocal хранит данные «на поток». Если потоки переиспользуются (например, в пулах потоков) и значения ThreadLocal не удаляются, эти значения продолжают жить, вызывая утечки памяти.

Комментарий от Михаила Поливаха

Кстати эта проблема даже не только с утечкой, а это потенциальный Security Exploit. В условных ThreadLocal чатсо хранят какие-то сессии пользователей, security контексты, привязанные к потоку. И если как только поток выполнил работу и вернулся в пул ThreadLocal не отчистить, то это критическая CVE.

Вообще, лучше используйте ScopedValues. Они и более безопасны, и более устойчивы к мемори ликам.

Комментарий от Павла Кислова

Благо, стоит отметить, что с инфраструктурными объектами подобного рода такие фреймворки, как Spring разбираются сами и подобных моментов не возникает.

Пример: ThreadLocal кэширует пользовательский контекст, но никогда не очищается.

public class UserContext {

    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

    public static void setUser(User user) {
        currentUser.set(user); // Set user for the current thread
    }

    public static User getUser() {
        return currentUser.get();
    }
 
    // No remove() call!
    // public static void clear() { currentUser.remove(); }
}

Почему это ведёт к утечке: в пуле потоков (например, из ExecutorService) потоки переиспользуются. Если вызвать UserContext.setUser(...), но никогда не выполнять clear(), объект User останется «прикреплённым» к потоку. Со временем потоки будут накапливать объекты User, что приводит к утечкам.

Исправление: Всегда очищайте ThreadLocal в try-finally, чтобы гарантировать уборку:

try {
    UserContext.setUser(currentUser);
    // ... business logic ...

} finally {
    UserContext.clear(); // Critical for thread pool reuse!
}

Неограниченные кэши без политики вытеснения

Проблема: кэши, которые растут бесконечно (без ограничения размера, времени жизни или политики вытеснения), будут «утекать» по мере накопления неиспользуемых записей.
Пример: простой кэш на HashMap без вытеснения.

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

public class ProductCache {
  
    private final Map<String, Product> cache = new HashMap<>();

    public Product getProduct(String productId) {
        if (!cache.containsKey(productId)) {
            Product product = fetchProductFromDatabase(productId); // Expensive DB call
            cache.put(productId, product); // Cache forever!
        }
        return cache.get(productId);
    }
  
    private Product fetchProductFromDatabase(String productId) { /* ... */ }
  
}

Почему это ведёт к утечке: кэш растёт с каждым новым productId, сохраняя все продукты навсегда. Даже если продукт снят с продажи или почти не используется, он остаётся в кэше и продолжает занимать память.

Исправление: Используйте ограниченный кэш с вытеснением (например, Guava CacheBuilder или Caffeine):

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.concurrent.TimeUnit;

public class ProductCache {

    // Cache with max 1000 entries, evicting after 10 minutes of inactivity
    private final Cache<String, Product> cache = CacheBuilder.newBuilder()
        .maximumSize(1000)
        .expireAfterAccess(10, TimeUnit.MINUTES)
        .build();

    public Product getProduct(String productId) throws Exception {
        return cache.get(productId, () -> fetchProductFromDatabase(productId));
    }
}

Незакрытые ресурсы (косвенные утечки)

Проблема: такие ресурсы, как файловые дескрипторы, подключения к базе данных или сетевые сокеты, управляются ОС, а не JVM. Хотя это не всегда «утечки памяти» в строгом смысле, незакрытые ресурсы могут исчерпать ресурсы на уровне ОС (например, файловые дескрипторы) и привести к падениям. Косвенно это также может мешать сборке мусора буферов/кэшей, связанных с ресурсом.

Пример: файловый reader, который никогда не закрывается.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class FileProcessor {

    public String readFile(String path) throws IOException {
        BufferedReader reader = new BufferedReader(new FileReader(path));
        StringBuilder content = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            content.append(line);
        }
        // Oops! reader.close() is missing!
        return content.toString();
    }
}

Почему это ведёт к утечке: BufferedReader и базовый FileReader удерживают файловый дескриптор. Без close() ОС не может вернуть дескриптор, что приводит к ошибкам вида “Too many open files”. JVM также может откладывать сборку мусора для BufferedReader до запуска финализатора, из-за чего временные буферы «зависают» в памяти.

Исправление: Используйте try-with-resources (Java 7+), чтобы ресурсы закрывались автоматически:

try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
    // ... read file ...
} // reader is auto-closed here
} // reader закрывается автоматически здесь
Комментарий от Павла Кислова

Чаще всего это не классическая утечка в духе "GC не может освободить", аресурсы не освобождаются вовремя и память и дескрипторы копятся.  например про OkHttp.

Response response = client.newCall(request).execute();
String body = response.body().string(); 

Здесь мы не освободили ресурсы. Правильнее было бы написать вот такой трай

try (Response response = client.newCall(request).execute()) {
  String body = response.body().string();
}

Кроме того, мы часто не читаем  тела ответов. А объекты остаются. HTTP connection нельзя переиспользовать. клиент открывает новые соединения. растёт пул + память. Правильным было бы либо прочитать body, либо явно вызвать  discard. На самом деле типовые ошибки с синхронными клиентами, например в Spring Framework  сегодня скорее акт невнимательности и редкость. Единственное, что может правда беспокоить - это буферизация больших ответов в строки и их откидывание в логи. Это не совсем утечка, но может подьедать цпу

Выявление утечек памяти: инструменты и техники

Чтобы обнаружить утечки памяти, используйте следующие инструменты и шаги:

Инструменты:

  • VisualVM: бесплатный инструмент для мониторинга использования кучи, снятия дампов кучи и профилирования GC.

  • Eclipse MAT (Memory Analyzer Tool): анализирует дампы кучи, помогая находить подозрения на утечки и цепочки ссылок.

  • JConsole/JVisualVM: мониторинг использования памяти в реальном времени; обращайте внимание на стабильно растущее потребление кучи.

Шаги диагностики:

  1. Мониторинг использования кучи: используйте VisualVM, чтобы отслеживать размер кучи во времени. Утечка будет выглядеть как рост потребления кучи до запуска GC, затем «плато», но без возврата к исходному уровню.

  2. Снятие дампов кучи: сделайте дамп, когда потребление памяти высокое (jmap -dump:format=b,file=heap_dump.hprof).

  3. Анализ в MAT: откройте дамп кучи в MAT. Используйте отчёт «Leak Suspects», чтобы найти крупные кластеры объектов и цепочки ссылок, которые их удерживают. Ищите неожиданные ссылки (например, статические коллекции, долгоживущие внутренние классы).

Предотвращение утечек памяти: проактивные практики

Избегайте утечек, придерживаясь следующих привычек:

  • Ограничивайте статические коллекции: используйте static-коллекции только для действительно глобальных, долгоживущих данных. Всегда добавляйте методы очистки.

  • Отдавайте предпочтение статическим вложенным классам: используйте static nested classes вместо нестатических внутренних классов, чтобы избежать неявных ссылок на внешний экземпляр.

  • Отписывайте слушателей: всегда удаляйте слушателей, когда объекты больше не используются (например, removeListener в методах destroy()).

  • Очищайте ThreadLocal: применяйте try-finally для удаления значений ThreadLocal, особенно при работе с пулами потоков.

  • Используйте ограниченные кэши: библиотеки вроде Guava Cache или Caffeine принудительно применяют политики вытеснения (максимальный размер, TTL).

  • Закрывайте ресурсы через try-with-resources: никогда не рассчитывайте на финализаторы для закрытия ресурсов.

  • Тестируйте на утечки: используйте юнит-тесты вместе с инструментами вроде JUnit + VisualVM, чтобы убедиться, что потребление памяти стабилизируется.

Заключение

Утечки памяти в Java чаще всего возникают из-за тонких ошибок в управлении ссылками, а не из-за работы GC в JVM. Самые простые и распространённые утечки связаны со статическими коллекциями, ссылками внутренних классов на внешний объект, забытыми слушателями, неправильным использованием ThreadLocal, неограниченными кэшами и незакрытыми ресурсами.

Понимая эти ловушки и следуя проактивным практикам — таким как очистка ссылок, использование ограниченных кэшей и автоматическое закрытие ресурсов, — вы сможете писать код без утечек. И обязательно проверяйте всё с помощью инструментов мониторинга, чтобы находить утечки как можно раньше!

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.