Не секрет, что 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: мониторинг использования памяти в реальном времени; обращайте внимание на стабильно растущее потребление кучи.
Шаги диагностики:
Мониторинг использования кучи: используйте VisualVM, чтобы отслеживать размер кучи во времени. Утечка будет выглядеть как рост потребления кучи до запуска GC, затем «плато», но без возврата к исходному уровню.
Снятие дампов кучи: сделайте дамп, когда потребление памяти высокое (jmap -dump:format=b,file=heap_dump.hprof).
Анализ в MAT: откройте дамп кучи в MAT. Используйте отчёт «Leak Suspects», чтобы найти крупные кластеры объектов и цепочки ссылок, которые их удерживают. Ищите неожиданные ссылки (например, статические коллекции, долгоживущие внутренние классы).
Предотвращение утечек памяти: проактивные практики
Избегайте утечек, придерживаясь следующих привычек:
Ограничивайте статические коллекции: используйте
static-коллекции только для действительно глобальных, долгоживущих данных. Всегда добавляйте методы очистки.Отдавайте предпочтение статическим вложенным классам: используйте
staticnestedclassesвместо нестатических внутренних классов, чтобы избежать неявных ссылок на внешний экземпляр.Отписывайте слушателей: всегда удаляйте слушателей, когда объекты больше не используются (например,
removeListenerв методахdestroy()).Очищайте
ThreadLocal: применяйтеtry-finallyдля удаления значенийThreadLocal, особенно при работе с пулами потоков.Используйте ограниченные кэши: библиотеки вроде Guava Cache или Caffeine принудительно применяют политики вытеснения (максимальный размер, TTL).
Закрывайте ресурсы через
try-with-resources: никогда не рассчитывайте на финализаторы для закрытия ресурсов.Тестируйте на утечки: используйте юнит-тесты вместе с инструментами вроде JUnit + VisualVM, чтобы убедиться, что потребление памяти стабилизируется.
Заключение
Утечки памяти в Java чаще всего возникают из-за тонких ошибок в управлении ссылками, а не из-за работы GC в JVM. Самые простые и распространённые утечки связаны со статическими коллекциями, ссылками внутренних классов на внешний объект, забытыми слушателями, неправильным использованием ThreadLocal, неограниченными кэшами и незакрытыми ресурсами.
Понимая эти ловушки и следуя проактивным практикам — таким как очистка ссылок, использование ограниченных кэшей и автоматическое закрытие ресурсов, — вы сможете писать код без утечек. И обязательно проверяйте всё с помощью инструментов мониторинга, чтобы находить утечки как можно раньше!

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