Утечка потоков в Java: поиск проблемы и фикс
Как я столкнулся с утечкой памяти, не связанной с heap или GC.
🧵
Введение
Проект — крупный клиент из e-commerce и healthcare в США (сори, NDA, название не покажу).
От стабильности этого модуля зависела важная часть бизнес-процессов, поэтому тест имел весомую цель — убедиться, что система выдерживает длительную и высокую нагрузку без деградации.
На практике всё пошло немного интереснее. Во время прогона на перф-окружении сервис, работающий в Kubernetes, стал периодически падать с ошибкой:
fatal Java Runtime Environment error
Kubernetes перезапускал его, но спустя некоторое время история повторялась.
Сначала это выглядело как классическая утечка памяти, но heap оказался чистым. Проблема пряталась глубже — в нативной памяти и потоках.
Содержание
- Контекст и условия теста
- Анализ и первые гипотезы
- Поиск причины
- Исправление и выбор решения
- Повторная проверка
- Что мы вынесли из этого
Контекст и условия теста
| Параметр | Значение |
|---|---|
| Платформа: | Kubernetes |
| Язык: | Java 17 |
| Сервис: | компонент расчёта цен (🔒 название скрыто) |
| Тип теста: | Capacity |
| Цель: | постепенно увеличивать нагрузку и найти capacity point — момент, когда система достигает пика производительности перед деградацией |
Анализ и первые гипотезы
CPU увеличивался, но линейно и несильно, а вот память уверенно росла вверх к лимитам каждый раз.

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

Ага, дело не в инфраструктуре, а в нативной памяти. Логи 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();
}

После фикса количество потоков стабилизировалось, память перестала расти, и JVM наконец-то успокоилась.
Повторная проверка
Повторный тест подтвердил результат:
✅ память вышла на стабильный уровень;
✅ число потоков перестало увеличиваться;
✅ ошибок JVM больше не наблюдалось.
Сервис стал предсказуем и устойчив даже при длительной нагрузке.
Что мы вынесли из этого
Эта история — напоминание, что иногда проблема кроется в самом очевидном месте. Один не закрытый ExecutorService — и система теряет память, пока не упадёт.
💡 Что стоит помнить:
- Ошибки JVM, связанные с native memory, часто указывают на утечки потоков, а не heap.
- Каждый
ExecutorServiceдолжен иметь понятный жизненный цикл: создан → использован → закрыт. - Capacity-тесты и длительные прогоны помогают поймать такие ошибки задолго до релиза.