Утечка потоков в Java: поиск проблемы и фикс

10.10.2025

Как я столкнулся с утечкой памяти, не связанной с heap или GC.

багиjava

🧵

Введение

Проект — крупный клиент из e-commerce и healthcare в США (сори, NDA, название не покажу).
От стабильности этого модуля зависела важная часть бизнес-процессов, поэтому тест имел весомую цель — убедиться, что система выдерживает длительную и высокую нагрузку без деградации.

На практике всё пошло немного интереснее. Во время прогона на перф-окружении сервис, работающий в Kubernetes, стал периодически падать с ошибкой:

fatal Java Runtime Environment error

Kubernetes перезапускал его, но спустя некоторое время история повторялась.

Сначала это выглядело как классическая утечка памяти, но heap оказался чистым. Проблема пряталась глубже — в нативной памяти и потоках.

Содержание


Контекст и условия теста

ПараметрЗначение
Платформа:Kubernetes
Язык:Java 17
Сервис:компонент расчёта цен (🔒 название скрыто)
Тип теста:Capacity
Цель:постепенно увеличивать нагрузку и найти capacity point — момент, когда система достигает пика производительности перед деградацией

Анализ и первые гипотезы

CPU увеличивался, но линейно и несильно, а вот память уверенно росла вверх к лимитам каждый раз.

Dynatrace

Чтобы исключить влияние внешних факторов, я запустил сервис локально в Docker Desktop и прогнал тот же тест.

Результат повторился: память растёт линейно, CPU — стабильно. То есть сервис не перегружен вычислениями, но память утекает.

Docker

Ага, дело не в инфраструктуре, а в нативной памяти. Логи JVM это подтвердили:

A fatal error has been detected by the Java Runtime Environment:
Native memory allocation (mprotect) failed to map memory for guard stack pages
Attempt to protect stack guard pages failed
os::commit_memory(...): error='Not enough space' (errno=12)

Проще говоря:

Потоков стало слишком много, и в оперативной памяти не осталось места для размещения стеков новых потоков.


Поиск причины

Просмотр кода быстро вывел на подозрительный участок: внутри метода enrich() создавался новый ExecutorService при каждом вызове.

Всё выглядело аккуратно — CompletableFuture, асинхронные вызовы, знакомая схема. Но пул потоков нигде не закрывался.

❌ До фикса

ExecutorService executor = Executors.newFixedThreadPool(WORKERS);
CompletableFuture<Void> prices = CompletableFuture.runAsync(
    () -> enrichPrices(context, productIds), executor);
CompletableFuture<Void> products = CompletableFuture.runAsync(
    () -> enrichProducts(context, productIds), executor);
joinAndRethrow(prices, products);

Каждый запрос создавал новый пул потоков. Потоки оставались живыми и постепенно заполняли память, пока JVM не достигала лимита.


Исправление и выбор решения

Фикс оказался простым: добавить try/finally и закрывать пул после завершения работы.

Почему именно shutdown()? Он даёт задачам возможность корректно завершиться без прерываний. shutdownNow() останавливает потоки мгновенно, что может привести к потере данных. А переиспользование пула здесь не требовалось — задачи короткие и независимые.

✅ После фикса

ExecutorService executor = Executors.newFixedThreadPool(WORKERS);
try {
    CompletableFuture<Void> prices = CompletableFuture.runAsync(
        () -> enrichPrices(context, productIds), executor);
    CompletableFuture<Void> products = CompletableFuture.runAsync(
        () -> enrichProducts(context, productIds), executor);
    joinAndRethrow(prices, products);
} finally {
    executor.shutdown();
}

git

После фикса количество потоков стабилизировалось, память перестала расти, и JVM наконец-то успокоилась.


Повторная проверка

Повторный тест подтвердил результат:

✅ память вышла на стабильный уровень;
✅ число потоков перестало увеличиваться;
✅ ошибок JVM больше не наблюдалось.

Сервис стал предсказуем и устойчив даже при длительной нагрузке.


Что мы вынесли из этого

Эта история — напоминание, что иногда проблема кроется в самом очевидном месте. Один не закрытый ExecutorService — и система теряет память, пока не упадёт.

💡 Что стоит помнить:

  • Ошибки JVM, связанные с native memory, часто указывают на утечки потоков, а не heap.
  • Каждый ExecutorService должен иметь понятный жизненный цикл: создан → использован → закрыт.
  • Capacity-тесты и длительные прогоны помогают поймать такие ошибки задолго до релиза.