В новом переводе от команды Spring АйО разберем тему раздувания памяти в JDK 17. Апгрейд микросервисов с JDK 8 на JDK 17 прошел dev и QA спокойно, но в проде через 2-3 часа все начало падать. Утилизация памяти выросла в 4 раза, контейнеры стали ловить OOMKill и перезапускаться, Uptime SLA просел, массовый инцидент.
Раньше JVM использовала около 50% памяти контейнера и обслуживала ~400 потоков. После релиза стало 95-100% и 1600+ соответственно.
При этом heap выглядел нормально, около Xmx, а раздувалась нативная память: ~800 MB -> 3,4-3,6 GB. Виноваты несколько эффектов, которые в контейнерах усиливаются: JVM начала создавать намного больше потоков, OS стала выделять JVM гораздо больше, а дефолтный GC в JDK 17 добавил накладные расходы.
Всё это из-за простого бага в JDK, который при миграции утащил за собой весь production.
Когда инженерные команды модернизируют Java-приложения, переход с JDK 8 на более новые версии с долгосрочной поддержкой (LTS) — такие как JDK 11, 17 и вскоре 21 — поначалу может казаться простым. Поскольку Java сохраняет обратную совместимость, легко предположить, что поведение рантайма в целом останется прежним. К сожалению, это не всегда так.
В 2025 году наша команда завершила крупную инициативу по модернизации и мигрировала все Java-микросервисы с JDK 8 на JDK 17. Этапы разработки и QA прошли гладко, без каких-либо серьёзных проблем. Но уже через несколько часов после выката в продакшен мы столкнулись с полным отказом системы.
Потребление памяти, которое годами было стабильно предсказуемым, выросло в четыре раза. Контейнеры, которые ранее работали без каких-либо сбоев, начали постоянно перезапускаться. Наш uptime заметно просел, а уровни критичности инцидентов повысились. Это запустило многодневную диагностическую работу с участием нескольких команд — включая экспертов по платформе, специалистов по Java Virtual Machine (JVM) и владельцев сервисов.
Этот пост-мортем охватит следующее:
Ключевые различия между JDK 8 и JDK 17
Как контейнеризованные среды усиливают скрытые особенности поведения JVM
Различия между нативной памятью и heap-памятью
Причины разрастания числа потоков и его влияние на память
Конкретные команды, флаги и переменные окружения, которые устранили наши проблемы
Проверенный чек-лист для всех, кто обновляется до JDK 17 (или 21)
Проблемы, с которыми мы столкнулись, были тонкими и почти незаметными для стандартных инструментов мониторинга Java. Однако извлечённые уроки изменили наш подход к обновлению версий JVM и трансформировали наше понимание потребления памяти в контейнеризованных средах.
Инцидент
Мы развернули версию нашего основного сервиса на JDK 17 в Kubernetes. Выкат прошёл гладко: health-check’и стали зелёными, задержки запросов оставались стабильными, а логи не показывали ошибок.
Однако через 2–3 часа наши дашборды начали «загораться».
Наблюдаемые симптомы
Метрика | JDK 8 (До) | JDK 17 (После) |
Использование памяти | ~50% контейнера | 95–100% (частые OOMKill) |
Число потоков | ~400 | 1600+ потоков |
Суммарная нативная память | ~800 MB | 3,4–3,6 GB |
Перезапуски контейнеров | Нет | Несколько/час |
Поведение GC | Стабильное | Скачки накладных расходов G1GC |
Сервисы, которые годами оставались стабильными, внезапно начали непредсказуемо падать.
Проблема: мониторинг heap ввёл нас в заблуждение
Каждый Java-инженер знает, что нужно следить за потреблением heap. Поначалу с heap всё выглядело идеально: он оставался стабильным около настроенного Xmx. Однако стремительно росла именно нативная память.
Нативная память включает:
Стеки потоков
Арены malloc в glibc
Вспомогательные структуры сборщика мусора (GC)
Буферы JIT-компилятора
Metaspace, Code Cache
Буферы NIO
Внутренние C++-структуры JVM
Комментарий от Михаила Поливаха
Арены malloc в glibc - вот тут важный момент. Мы ранее писали про Project Panama и про Arena-ы в рамках него. Наверное, ещё скоро напишем. Но дело вот в чем.
Концептуально "Arena" это просто последовательный участок памяти. Это верно как для Project Panama, так и для ОС и syscall-а malloc.
Тем не менее, если мы говорим про ОС, то под arena там имеют в виду такую большую арену или пул, откуда где вызовы malloc непосредственно и проводят аллокации. Это сделано специально, чтобы уменьшить контеншен.Практика кстати распространенная, в HotSpot например память выделятся TLAB-ами, что в целом преследует ту же цель, что и нативная арена.
К сожалению, это не видно через инструменты heap-дампов и не фиксируется стандартным Java-мониторингом. Именно это и приводило к OOMKill наших контейнеров.
Анализ первопричин
В ходе расследования мы выяснили, что три независимых поведения JVM, усилившиеся в контейнерах, сформировали «идеальный шторм памяти».
После трёх дней тщательного анализа — изучения данных по heap, использования трекинга нативной памяти (jcmd VM.native_memory), выборочного снятия thread dump’ов, анализа GC-логов и проверки container cgroups — мы выявили три первопричины.
Первопричина №1: разрастание числа потоков из-за неверного определения CPU
Что произошло
В JDK 17 были внесены изменения в то, как работает Runtime.availableProcessors(). В частности, в версиях 17.0.5 и выше регрессия привела к тому, что Java Virtual Machine (JVM) стала игнорировать лимиты CPU в cgroup и вместо этого считывать физическое количество CPU на хосте.
Комментарий от Михаила Поливаха
Это прямо известный баг, на нём многие полегли.
Особенно эта проблема критична на мощных виртуальных машинах для прода, где там реально десятки и десятки CPU cores.
P.S: Насколько я знаю, этот баг уже бекпортнули в патчевые версии 17, 11 и 8. Это я о важности патчевых релизов.
Пример:
Лимит CPU контейнера: 2 vCPU CPU хост-машины: 96 JVM определила: 96 CPU ❌
Эта ошибка привела к тому, что различные части JVM начали масштабировать создание потоков, опираясь на завышенное число CPU, включая:
Потоки воркеров GC
Потоки JIT-компилятора
Общий пул ForkJoin
Потоки JVMTI
Потоки асинхронного логирования
Комментарий от Михаила Поливаха
Это нормально. Например ForkJoinPool в OpenJDK отталкивается от кол-ва доступных ядер CPU при расчете размера пула.
То есть вместо ~50–80 системных потоков JVM
JVM создавала 300–400+ потоков
А с учётом потоков приложения (асинхронные задачи, thread pool’ы, I/O-потоки) общее число выросло до 1600+ потоков
Почему потоки важны для памяти
Каждый поток по умолчанию обычно резервирует около ~2 MB стека (в нативной памяти). Следовательно:
1600 потоков × 2 MB = ~3,2 GB нативной памяти под стеки
Даже если эти потоки простаивают, стек всё равно зарезервирован. Уже одно это раздувание по потокам опасно приблизило нас к лимиту памяти контейнера.
Первопричина №2: фрагментация malloc-арен glibc
Взрыв количества потоков сделал ситуацию значительно хуже. Glibc управляет памятью через malloc-арены и по умолчанию выделяет 8 × CPU_COUNT арен
Из-за того, что JVM ошибочно определила 96 CPU, glibc создала 8 × 96 = 768 арен
Типичная арена может потреблять 10–30 MB в зависимости от характера фрагментации. Даже при разреженном использовании арены всё равно занимают виртуальную память и увеличивают Resident Set Size (RSS). В нашем случае это привело к ~1,5–2,0 GB, занятых аренами glibc.
Это было невидимо для Java-инструментов мониторинга и анализа heap.
Первопричина №3: нативные накладные расходы G1GC (на 800–1000 MB выше)
Ещё один фактор — переход на Garbage-First Garbage Collector (G1GC) в JDK 17, тогда как в JDK 8 часто использовался ParallelGC. Известно, что G1GC потребляет заметно больше нативной памяти:
Компонент | Примерный объём нативной памяти |
Remembered Sets | 300–400 MB |
Card Tables | 100–200 MB |
Метаданные регионов | 200 MB |
Битмапы маркировки | 150+ MB |
Буферы concurrent refinement | 100 MB |
Итого для G1GC ~800–1000 MB нативной памяти
ParallelGC в JDK 8 ~150–200 MB
Разница +650–800 MB
Это вывело нас далеко за пределы лимита контейнера в 4 GB.
Совокупная модель роста памяти
Посмотрим на комбинированный эффект трёх первопричин:
Под JDK 8 (~2,8 GB всего)
Heap: 2048 MB Metaspace: 200 MB Code Cache: 240 MB Потоки: 80 MB Нативная память GC: 150 MB Другая нативная: 100 MB Итого: ~2,8 GB
Под JDK 17 (~5,4 GB всего)
Heap: 2048 MB Metaspace: 250 MB Code Cache: 240 MB Потоки: 200 MB G1GC: 1000 MB Арены glibc: 1500 MB Другая нативная: 150 MB Итого: ~5,4 GB ❌
Это на 1,4 GB превышает лимит контейнера. Никакая настройка heap не могла бы это исправить, потому что heap сам по себе не был корнем проблемы.
Исправление: решение из трёх частей
Исправление №1: явно задать количество CPU
-XX:ActiveProcessorCount=2
Это самый важный параметр для контейнеризованной Java на JDK 11 и выше. Он не даёт JVM масштабировать число потоков, исходя из количества CPU на узле.
Исправление №2: ограничить количество malloc-арен glibc
Задайте переменную окружения:
export MALLOC_ARENA_MAX=2
Это снизило накладные расходы на нативные арены примерно с 1,5 GB до уровня ниже 200 MB. Если вы работаете в условиях очень жёстких ограничений по памяти, рассмотрите вариант:
export MALLOC_ARENA_MAX=1
Исправление №3: настроить или заменить G1GC
Здесь есть два варианта:
Оставить G1GC, но настроить его, или
Перейти на ParallelGC, особенно для нагрузок, чувствительных к памяти.
Комментарий от Михаила Поливаха
Вот это не очень идея. Лучше не надо. Тут как правило экономия будет так себе, а вот p99 latency вы хорошенько так вверх унесёте
ParallelGC остаётся сборщиком с самым низким нативным «следом» памяти в современной Java.
Наша настройка:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m
После внедрения этих исправлений мы увидели, что использование памяти стабилизировалось в диапазоне 65–70%.
Дополнительные улучшения по выявлению и наблюдаемости
Главный операционный вывод однозначен: полагаться только на мониторинг heap недостаточно. Обновления JVM требуют также мониторинга нативной памяти.
Вот что мы внедрили:
Native Memory Tracking (NMT)
Мы включили NMT командой:
-XX:NativeMemoryTracking=summary
Комментарий от Михаила Поливаха
Включать NMT можно, но она стабильно бьёт по перфу, хотя если включать в summary моде то не слишком сильно, но для продакшенов бывает заметно.
После чего использовали:
jcmd VM.native_memory summary
Это дало детальную разбивку потребления памяти по потокам, аренам, GC, компилятору и т. д.
Алерты по количеству потоков
Мы внедрили следующее:
Базовые значения числа потоков для каждого сервиса
Алерты на любое увеличение более чем на 50%
Дашборды, показывающие динамику роста потоков
Рост числа потоков часто сигнализирует о потенциальных утечках нативной памяти.
Мониторинг метрик памяти на уровне контейнера
Мы сместили фокус на мониторинг памяти на уровне контейнера вместо уровня pod, который агрегирует данные от нескольких контейнеров:
container_memory_working_set_bytes
Сосредоточившись на метриках контейнера, мы смогли раньше и точнее выявлять превышения по памяти.
Как мы воспроизвели проблему локально
Чтобы подтвердить, что проблема заложена в JDK 17, мы подняли локальное окружение, максимально повторяющее исходную конфигурацию.
Шаг 1: запустить приложение в Docker
$ docker run --cpus=2 --memory=4g -e MALLOC_ARENA_MAX=2 myservice:java17
Шаг 2: проверить определение CPU
$ docker exec -it bash $ java -XX:+PrintFlagsFinal -version | grep -i cpu
Что мы обнаружили. До исправления:
active_processor_count = 96
После исправления:
active_processor_count = 2
Шаг 3: проверить нативную память
jcmd VM.native_memory summary
Количество арен точно коррелировало с тем, сколько CPU «видела» JVM.
Почему эта проблема становится всё более распространённой
Ряд компаний, мигрирующих с Java 8 на Java 17 (или 21), сталкиваются с похожими трудностями. Причины этого следующие:
Контейнеризация проявляет ранее скрытые особенности поведения JVM.
Локальные машины разработки обычно имеют много RAM и CPU, в отличие от Kubernetes-контейнеров.
G1GC стал сборщиком по умолчанию, а его накладные расходы выше, чем у ParallelGC.
Многие серверы оснащены 64–128 CPU, и масштабирование потоков в JVM взрывается при неверном определении.
Нативная память в Java-приложениях редко мониторится, даже в крупных организациях.
Поведение malloc-арен glibc плохо понимается за пределами низкоуровневой системной инженерии.
Эта комбинация факторов создаёт «ловушку»: обновление JVM может пройти все QA-тесты, но мгновенно сломать систему сразу после выката в продакшен.
Что бы мы сделали иначе в следующий раз
Soak-тестирование версии JVM
Дальше мы будем вводить следующие требования:
48-часовой soak test под нагрузкой
24-часовой canary soak test в продакшене
Мониторинг количества потоков
Контроль нативной памяти
Анализ логов поведения GC
Мы усвоили: одного функционального набора тестов недостаточно.
Ранбуки для апгрейда JVM
Мы разработали runbook, который включает:
Обязательные флаги для контейнеров
Обязательные переменные окружения (
MALLOC_ARENA_MAX)Мониторинговые дашборды, которые нужно проверить перед промоушеном
Дерево решений по откату
Жёсткое формирование базовых линий
Для каждого сервиса мы зафиксируем базовые значения по:
Потреблению heap
Нативной памяти
Количеству потоков
Накладным расходам GC
После определения этих базовых линий сравнивать JDK 8 и JDK 17 станет просто.
Чек-лист для апгрейда
Шаги до обновления
Явно установить
-XX:ActiveProcessorCountУстановить
MALLOC_ARENA_MAX=1 или 2Выбрать метод сборки мусора: G1GC или ParallelGC
Включить Native Memory Tracking
Зафиксировать базовые значения по памяти — и heap, и нативной
Зафиксировать базовые значения по числу потоков
Включить метрики памяти на уровне контейнера
Провести soak-тесты на 24–48 часов
Под нагрузкой мониторить и валидировать паузы GC
Действия после выката
Наблюдать за количеством потоков 2–6 часов
Сравнить нативное потребление памяти с базовой линией
Проверить и подтвердить количество арен
Убедиться, что определение CPU корректно
Немедленно откатываться, если нативная память растёт более чем на 10–15% относительно базовой линии
Заключение
Апгрейд до JDK 17 стал одним из самых поучительных инцидентов, с которыми сталкивалась наша команда.
Он подсветил несколько ключевых моментов:
Нативная память доминирует в поведении JVM в контейнерах
Ошибки определения CPU могут незаметно «калечить» сервисы
Изменения GC между релизами JDK могут добавить 500 MB+ накладных расходов
malloc-арены glibc могут разрастаться из-за чрезмерного количества потоков
Мониторинговые эвристики из эпохи JDK 8 становятся менее надёжными при переходе на JDK 17
Апгрейд JVM нужно воспринимать с той же осторожностью, что и крупную перестройку инфраструктуры, а не как минорное обновление версии
Хорошая новость?
После применения рекомендованных исправлений наши сервисы теперь работают на JDK 17 эффективнее, чем когда-либо на JDK 8. Мы наблюдаем лучшую пропускную способность GC, более короткие паузы и улучшенную общую производительность.
Однако этот опыт — важное напоминание:
Современная Java быстрая и мощная, но только при настройке с пониманием того, как JVM взаимодействует с контейнерными рантаймами, нативными системами памяти и Linux-аллокаторами.
Если вы планируете апгрейд до JDK 17, используйте это руководство, проверяйте свои предположения и внимательно мониторьте нативную память наряду с heap-памятью.

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