JVM в первую очередь процесс операционной системы. Но важно понимать, что требования к этому процессу на production отличатся от требований на workstation-е разработчика.
Если для разработчику важно, чтобы IDE работала быстро и не потребляла всю возможную память на машине, то для серверных JVM важно потреблять максимальный предоставленный ресурс и работать предельно быстро.
В новом переводе от команды Spring АйО разберем, какие существуют базовые настройки кучи, GC и CPU помогают синхронизировать JVM с лимитами контейнера и не переплачивать за простаивающие ресурсы.
Комментарий от Михаила Поливаха
Все оптимизации в статье применимы не только к ARM-овым CPU, но и к x86. Это важно, потому что большая часть Ваших серверных workload-ов, даже если Вы об этом не знаете, они бегут на x86_64. Поэтому, статья для Вас тоже актуальна.
В первой части этой серии вы узнаете, как настроить JVM под облачные нагрузки, оптимизировать размер кучи, использование CPU и другие параметры для повышения производительности Java.
Java ��стаётся одним из самых популярных языков для корпоративных приложений, работающих в облаке. Хотя такие языки, как Go, Rust, JavaScript и Python, заметны и востребованы у разработчиков облачных приложений, рейтинг языков RedMonk на протяжении всей своей истории неизменно относит Java к тройке самых популярных.
При развёртывании приложений в облаке есть несколько ключевых отличий между средами развёртывания и средами разработки. Запускаете ли вы микросервисное приложение в Kubernetes или поднимаете экземпляры виртуальных машин, важно настроить вашу виртуальную машину Java (JVM), чтобы получать максимум отдачи от расходов на облако. Полезно понимать, как JVM распределяет ресурсы, и убедиться, что вы используете их эффективно.
Большая часть информации и рекомендаций в этой серии не зависит от платформы и будет одинаково хорошо работать как на x86_64, так и на Arm64 CPU. Поскольку Java проектировалась как платформонезависимая, это неудивительно. По мере того как сообщество Java вкладывало усилия в оптимизацию JVM под Arm64 (также называемую aarch64 — «64-битная архитектура Arm»), разработчики Java должны видеть рост производительности своих приложений на этой архитектуре без каких-либо специальных действий.
Тем не менее мы отметим некоторые области, где архитектуры Arm64 и x86_64 различаются, и как использовать эти различия в ваших приложениях. Кроме того, мы в целом будем ссылаться только на версии инструментов Java с долгосрочной поддержкой. Например, G1GC был представлен в качестве сборщика мусора по умолчанию в цикле разработки Java 9, но не был доступен в JDK (Java Development Kit) с долгосрочной поддержкой до Java 11.
Комментарий от Михаила Поливаха
Тут автор имеет в виду, что ближайшая к Java 9 LTS версия, в рамках которой G1 стал GC по-умолчанию это 11. Хотя сам G1 появился ещё даже до JDK 8 (preview и incubating фазы)
Поскольку большинство корпоративных Java-разработчиков используют LTS-версии JDK, мы ограничим упоминания версий ими (на момент написания это Java 8, 11, 17, 21 и 25).
В этой двухчастной серии о настройке Java-приложений для облака мы подходим к задаче с двух разных точек зрения. В части 1 (эта статья) мы сосредоточимся на том, как JVM распределяет ресурсы, и рассмотрим некоторые параметры и настройки операционной системы, которые могут повысить производительность на экземплярах в облаке на базе Ampere или на выделенном bare-metal оборудовании. В части 2 мы подробнее разберём инфраструктурную сторону, уделив особое внимание Kubernetes и конфигурации ядра Linux. Мы рассмотрим некоторые архитектурные различия между Arm64 и x86 и покажем, как убедиться, что ваш Kubernetes, операционная система и JVM настроены так, чтобы максимально увеличить отдачу на каждый потраченный рубль/доллар от вашего Java-приложения.
Часть 1: Оптимизация JVM
При запуске Java-приложений в облаке настройка JVM не всегда находится в центре внимания команд, занимающихся развёртыванием, однако ошибки или работа с параметрами по умолчанию могут повлиять на производительность и стоимость облачных приложений.
В этой статье мы рассмотрим несколько наиболее полезных настраиваемых элементов JVM, включая:
Преимущества по производительности при использовании свежих версий Java
Ключевые различия между облачными экземплярами и средами разработчика
Установку правильного размера кучи и выбор подходящего сборщика мусора для вашего приложения
Параметры JVM, которые могут улучшить соотношение цена/производительность для экземпляров на базе Ampere
Не отставать от времени
Поддержка Arm64 впервые появилась в экосистеме Java в Java 8 и с тех пор постепенно улучшалась. Если вы всё ещё используете Java 8, ваши Java-приложения могут работать до 30% медленнее, чем при использовании более свежей версии Java — например, Java 21 или недавно выпущенной Java 25. Причина здесь двоякая:
Производительность Java стабильно росла на всех архитектурах
Существует ряд инициатив, которые специально улучшили производительность на Arm64
Стоит отметить, что можно разрабатывать приложения, используя синтаксис языка Java 8, и при этом пользоваться улучшениями производительности более новой JVM — с помощью Oracle Java SE Enterprise Performance Pack. Это (слегка упрощая) набор инструментов, который компилирует приложения Java 8 так, чтобы они работали на JVM из JDK Java 17. Тем не менее за последние 10 лет язык получил множество улучшений, и мы рекомендуем обновить ваши Java-приложения, чтобы они работали на более актуальном дистрибутиве Java.
Разница между облачными инстансами и workstashion-ами разработчиков
Стандартная «эргономика» JVM создавалась исходя из предположения, что ваше Java-приложение — лишь один из множества процессов, работающих на общем хосте. На ноутбуке разработчика или на многопользовательском сервере JVM намеренно «ведёт себя прилично», ограничивая себя относительно небольшой долей системной памяти и оставляя запас для всего остального. Это отлично работает на workstashion-е разработчика, где JVM конкурирует с вашей IDE, браузером и фоновыми сервисами, но в облачных средах ваше Java-приложение обычно будет единственным приложением, которое вас волнует в этом экземпляре VM или контейнера Docker (в более общем смысле — OCI).
По умолчанию, если вы явно не задаёте начальный и максимальный размер кучи, JVM использует многоуровневую формулу, чтобы подобрать размер кучи на основе «доступной памяти». Посмотреть, каким будет размер кучи по умолчанию для ваших облачных инстансов, можно через логирование Java:
java -Xlog:gc+heap=debug [0.005s][debug][gc,heap] Minimum heap 8388608 Initial heap 524288000 Maximum heap 8342470656
Параметры по умолчанию для расчёта размера кучи в зависим��сти от доступной системной RAM:
На небольших системах (≤ 384 МБ RAM) максимальная куча по умолчанию устанавливается на уровне 50% доступной памяти.
На системах с памятью от 384 МБ до 768 МБ максимальная куча фиксируется на 192 МБ независимо от того, сколько памяти фактически доступно в этом диапазоне.
На системах с доступной памятью более 768 МБ максимальная куча составляет 25% доступной памяти.
Начальная куча (-Xms) намного меньше: примерно 1/64 доступной памяти, но не более 1 ГБ.
Начиная с Java 11, при запуске в OCI-контейнерах JVM выполняет эти расчёты, исходя из лимита памяти контейнера (cgroup), а не памяти хоста, однако проценты и пороги остаются теми же. О «контейнерной осведомлённости» JVM мы поговорим в следующей статье.
Таким образом, для VM с 512 МБ RAM JVM всё равно выделит под кучу не более 192 МБ. На ноутбуке с 16 ГБ RAM предел по умолчанию составляет примерно ~4 ГБ. В контейнере с лимитом памяти 2 ГБ куча по умолчанию будет около ~512 МБ.
Это вполне разумный выбор, если ваша JVM делит машину с десятками других процессов. Но в облаке, когда вы поднимаете выделенную VM или отдельный контейнер, JVM часто оказывается единственным значимым процессом. Вместо того чтобы пытаться быть «хорошим соседом» и оставлять ресурсы для других приложений, вам нужно, чтобы она использовала большую часть выделенных вами ресурсов. Иначе вы платите за простаивающую память и недоиспользованный CPU.
Значения кучи JVM по умолчанию и рекомендации для облака
Этот сдвиг имеет два ключевых последствия:
Выделение памяти: Вместо того чтобы по умолчанию использовать 25–50% RAM, облачным нагрузкам обычно следует выделять под кучу 80–85% доступной памяти. Это позволяет максимально использовать память, за которую вы платите, при этом оставляя место для внутренних структур JVM (metaspace, стеки потоков, кэш кода) и накладных расходов ОС.
Использование CPU: Облачные инстансы почти всегда работают на нескольких ядрах, но лимиты ресурсов Kubernetes могут «сбивать» представление JVM о доступных вычислительных ресурсах. Если ваш контейнер запрашивает 1 CPU, планировщик обеспечивает этот лимит временными квантами, распределяя их по нескольким ядрам. Однако JVM будет считать, что работает на одноядерной системе, и из-за этого может принимать неэффективные решения. Это может привести к неудачному выбору параметров сборки мусора или размеров пулов потоков. Поэтому облачным разработчикам следует явно задавать -XX:ActiveProcessorCount значением больше 1 и выбирать сборщик мусора, который поддерживает несколько потоков сборки мусора.
Сценарий | Эргономика по умолчанию (без флагов) | Рекомендовано для облачных нагрузок |
Начальная куча (-Xms или -XX:InitialRAMPercentage) | ~1/64 от памяти (не более 1 ГБ) | Для стабильных долгоживущих сервисов подгоняйте начальную кучу близко к максимальной: - |
Максимальная куча (-Xmx или -XX:MaxRAMPercentage) | - ≤ 384 МБ RAM → 50% RAM \n- 384–768 МБ → фиксированные 192 МБ \n- ≥ 768 МБ → 25% RAM | Устанавливайте кучу на уровне 80–85% лимита контейнера/VM: - |
Выбор GC | G1GC (по умолчанию в Java 11+) или Parallel GC (Java 8), когда число процессоров ≥ 2; SerialGC, когда число процессоров < 2 |
|
Число CPU | JVM определяет ядра хоста и может «перелетать» квоту контейнера |
|
Осведомлённость о cgroup | Java 11+ определяет лимиты контейнера | Задавайте явные проценты так же, как и для VM |
Комментарий от Евгения Сулейманова
Поставить 80–85% под кучу - хорошая стартовая эвристика, но в контейнерах она часто заканчивается неожиданными OOMKill, потому что лимит памяти Kubernetes - это не только heap. В реальном проде память "съедают" metaspace, thread stacks, code cache/JIT, direct buffers (Netty/HTTP), TLS, mmap, плюс накладные расходы ОС/рантайма. Поэтому лучше действовать в два шага:
1. Основа - наблюдаем контейнерную память: ориентируйтесь на cgroup/RSS/working set и разницу RSS vs cache, чтобы понимать фактическое потребление процессом и не путать его с файловым кэшем.
2. Диагностика - точечно раскладываем native по категориям: если видно, что растет "не-heap" или ловите OOMKill без явного OutOfMemoryError, включайте NMT кратковременно (обычно summary) на канарейке/в staging и снимайте срезы/диффы через jcmd VM.native_memory. NMT полезен как инструмент расследования, но его не стоит воспринимать как постоянную продовую метрику: есть оверхед и есть области нативной памяти, которые он может не покрыть (например, часть сторонних нативных библиотек).
После этого MaxRAMPercentage перестает быть "магическим числом" и превращается в управляемый бюджет: heap + понятный выделенный ресурс под "вне-кучевую" память и пики.
Независимо от целевой архитектуры, если вы собираетесь подправить для облачных нагрузок всего несколько параметров JVM, начните с этих. Они предотвращают самые распространённые ошибки и «синхронизируют» JVM с ресурсами, которые вы явно выделили:
Сборщик мусора: для большинства облачных сервисов используйте G1GC (-XX:+UseG1GC). Он балансирует пропускную способность и задержки, хорошо масштабируется на кучах размером в несколько гигабайт и в последних релизах является сборщиком по умолчанию, если у вас больше одного CPU-ядра.
Число активных процессоров:
-XX:ActiveProcessorCount=<cpu_limit with minimum 2>
Сопоставьте это значение с количеством CPU или millicores, выделенных базовой вычислительной средой, на которой работает ваш контейнер. Например, даже если Kubernetes выдаёт вашему контейнеру квоту 1024 millicores, но он запускается внутри 16-ядерной виртуальной машины, вам следует выставлять ActiveProcessorCount равным 2 или больше. Это позволяет JVM корректно подбирать размеры пулов потоков и выбирать сборщик мусора, такой как G1GC, вместо SerialGC, который полностью останавливает приложение на время проходов GC.
Комментарий от Михаила Поливаха
На деле если опять же мы говорим про performance и его оптимизацию, то я бы начал не с того, что говорят парни из Ampere Computing, я бы начал с того, чтобы получить, если возможно, dedicated CPU core, т.е. ядра которые ваши ворклоады не будут делить с другими тенантами.
Это конечно дороже, спору нет. Но для HPC например это важно, просто если ваши потоки разбросало на 16 ядер условного 64-ядрового Intel Xeon, то у Вас будет постоянный контекст свич, и бенефиты параллелизма здесь могут быть сильно невелированы
Оптимальное значение будет зависеть от того, что ещё работает в этой виртуальной машине: если задать число слишком большим, вы получите эффект «шумного соседа» для других приложений на том же вычислительном узле.
Размер кучи:
-XX:InitialRAMPercentage=80 -XX:MaxRAMPercentage=85
Эти параметры говорят JVM масштабировать кучу, исходя из лимитов памяти контейнера, а не памяти хоста, и забирать более крупную долю, чем «настольные» значения по умолчанию. Используйте 80% как безопасную базовую настройку; приближайтесь к 85%, если ваша нагрузка стабильна в steady-state.
Согласованность между Init и Max: для долгоживущих сервисов выставляйте InitialRAMPercentage равным MaxRAMPercentage или чуть меньше. Это помогает избежать потерь производительности из-за постепенного расширения кучи под нагрузкой.
С этими тремя «ручками» большинство Java-приложений, работающих в Kubernetes или облачных VM, будут давать предсказуемую производительность и избегать аварий out-of-memory.
Параметры JVM, которые могут повысить производительность на Arm64
Помимо настройки размера кучи и корректного учёта CPU, несколько опций JVM способны дать измеримые улучшения на серверах с Arm64 CPU (в т.ч. от Ampere). Это не универсальные настройки «на все случаи». Эффект зависит от характеристик нагрузки — использования RAM, компромисса между задержками и пропускной способностью, сетевого I/O, — но их стоит протестировать, чтобы понять, улучшают ли они производительность вашего приложения.
Включение HugePages
Transparent Huge Pages выделяет крупный непрерывный блок памяти, состоящий из множества страниц ядра, за одну попытку и с точки зрения приложения рассматривает его как одну страницу памяти. Такой режим позволяет использовать большие страницы памяти: для этого нужно загрузиться с подходящим ядром Linux и включить Transparent Huge Pages в JVM с помощью -XX:+UseTransparentHugePages, чтобы выделять большие непрерывные блоки памяти. Это может дать существенный прирост производительности для нагрузок, которые умеют этим пользоваться.
Комментарий от Михаила Поливаха
Это кстати хороший совет, про который редко кто говорит. Об этом писал ещё кстати Стефан Йоханнсен (человек, который пилит ZGC и с которым я имею честь лично быть знакомым)
https://kstefanj.github.io/2021/05/19/large-pages-and-java.html
Там есть нюансы, но в целом эту тему имеет смысл поизучать, особенно (!) если у Вас большие хипы.
Для тех, кто не понял, что вообще автор говорит. Ядро ОС выделяет память процессам страничками. Их размер он как правило небольшой, 4 KB допустим.
И соль в том, что именно для серверных мощностей и для рантаймов в частности (не только HotSpot) часто имеет смысл эти страничи держать не в пару KB, а в пару MB, то есть условно в 1000 раз больше. Таким образом кол-во "инфраструктурных расходов", назовём их так, связанных с менеджментом memory pages оно сильно падает, и часто это отражается на лучшем throughput-е приложения.
Но там есть небольшие нюансы по ряду причин, особенно в сборке, собственно, почему Стефан про это и писал. В общем, почитайте.
Использование ядра с размером страницы 64K
Загрузка хостовой ОС с размером страницы ядра 64K гарантирует, что память будет выделяться и управляться ядром более крупными блоками, чем стандартные 4K. Это уменьшает число промахов TLB и ускоряет доступ к памяти для нагрузок, которые часто используют большие непрерывные участки памяти. Учтите, что загрузка ядра с заданным размером страницы и настройка TransparentHugePages требуют поддержки и конфигурации со стороны ОС, поэтому лучше делать это в координации с вашей операционной командой.
Предвыборка памяти
Некоторые нагрузки выигрывают от «предварительного касания» (pre-touch) страниц памяти при запуске. По умолчанию страницы виртуальной памяти не отображаются на физическую память, пока в них не возникнет необходимость. В первый раз, когда требуется физическая страница памяти, операционная система генерирует page fault, выделяет физическую страницу памяти, сопоставляет виртуальный адрес с физическим адресом и сохраняет эту пару адресов в таблице страниц ядра.
Комментарий от Михаила Поливаха
Это не совсем так, Page Fault тригерит MMU в ядре посредством интерапта, и уже в интерапт таблице процессора стоит хендлер на обработку page fault, и далее ядро Linux уже само решает, что делать - идём в swapspace, терминируем процесс, аллоцируем память и т.д.
Pre-touch заранее сопоставляет виртуальные адреса памяти с физическими адресами при старте, делая первый доступ к этим страницам во время выполнения быстрее. Добавление опции:
-XX:+AlwaysPreTouch
заставляет JVM при запуске закоммитить и отобразить все страницы кучи, избегая page fault’ов позже, уже под нагрузкой. Компромисс — немного более долгий старт, но более стабильная задержка после выхода на рабочий режим. Эта опция хороша для сервисов, чувствительных к задержкам и работающих долго без перезапуска. Дополнительный плюс — быстрый «фейл» при старте, если вы запрашиваете больше памяти, чем реально может быть выделено вашему приложению.
Tiered-компиляция против ahead-of-time JIT
Обычно JVM компилирует «горячие» участки кода постепенно во время выполнения. Опции вроде -XX:+TieredCompilation (включена по умолчанию) балансируют скорость старта и производительность в steady-state. Для облачных нагрузок, где время запуска менее важно, чем пропускная способность, можно сместить настройки в сторону более агрессивной компиляции заранее. В некоторых случаях компиляция (с использованием jaotc или архивов Class Data Sharing) JIT-профилей заранее может дополнительно снизить расход CPU во время выполнения.
Комментарий от Михаила Поливаха
jaotc (Java Ahead Of Time Compiler) был удален из JDK уже достаточно давно, в 17 по-моему, по ряду причин, он был экспирементальным и соот-но его дропнули.
Однако ahead-of-time компиляция несёт как риски, так и ограничения. Компиляция Just-In-Time (JIT, то есть во время выполнения) использует про��илирование, собираемое при работе приложения. Оно помогает выявлять горячие методы, вызовы методов, которые не обязательно должны оставаться виртуальными, вызовы, которые можно инлайнить, горячие циклы внутри методов, константные параметры, частоты ветвлений и т. д. Компилятору Ahead-Of-Time (AOT) вся эта информация недоступна, и он может сгенерировать код с не оптимальной производительностью. Кроме того, языковые возможности, связанные с динамической загрузкой классов — когда определения классов недоступны заранее или генерируются во время выполнения, — нельзя использовать при ahead-of-time компиляции.
Векторизация и intrinsic-оптимизации
Современные JVM на Arm64 включают оптимизированные intrinsics для математических вычислений, криптографии и векторных операций. Для их включения не нужны специальные флаги, но стоит убедиться, что вы используете как минимум Java 17+, чтобы воспользоваться этими оптимизациями.
Рекомендации по применению
Для короткоживущих batch-задач избегайте опций, которые замедляют запуск (
AlwaysPreTouch, агрессивный JIT).Для долгоживущих сервисов (API, веб-приложения) отдавайте предпочтение pre-touch памяти и стабильной настройке кучи.
Для сервисов, интенсивно работающих с памятью, настройте
TransparentHugePages, рассмотрите ядро с увеличенным размером страницы памяти относительно стандартных 4K и отслеживайте показатели TLB.
Заключение
У JVM долгая история консервативных предположений, рассчитанных скорее на ноутбуки разработчиков и многопользовательские серверы, чем на выделенные облачные инстансы. На VM и контейнерах эти значения по умолчанию часто оставляют часть памяти и CPU-циклов неиспользованными. Явно задавая проценты для кучи, число процессоров и выбирая правильный сборщик мусора, вы можете обеспечить, что приложения будут максимально использовать аппаратные ресурсы под ними. Используя более свежую версию JVM, вы также получаете выгоду от постепенных улучшений, внесённых с момента добавления поддержки Arm64 в Java 8.
Впрочем, это лишь начало. Флаги JVM и тюнинг дают реальные выигрыши, но общая картина включает операционную систему и сам Kubernetes. То, как Linux выделяет страницы памяти, как Kubernetes применяет квоты CPU и памяти и как контейнеры «видят» свою долю хоста, напрямую влияет на производительность JVM.
В следующей статье этой серии мы выйдем за пределы JVM и рассмотрим инфраструктурный слой:
Как взаимодействуют «осведомлённость» JVM о контейнерах и запросы/лимиты ресурсов Kubernetes
Что происходит, если квоты не задать явно
Как тюнинг на уровне ядра и кластера (параметры ядра, размеры страниц памяти, закрепление за ядрами) может открыть ещё больше эффективности

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