openjdk.ruOpenJDK на русском

JEP 545: Faster Startup and Warmup with ZGC

Более быстрые запуск и прогрев с ZGC

ОтветственныйErik Österlund
ТипFeature
ОбластьImplementation
СтатусCandidate
Компонентhotspot / gc
Обсуждениеhotspot dash gc dash dev at openjdk dot org
ТрудоёмкостьM
ДлительностьM
Связан сJEP 546: Adaptive Heap Sizing for ZGC
РецензентыAlex Buckley, Dan Heidinga, Mark Reinhold
Создан2024/04/05 09:47
Обновлён2026/09/21 15:46
Задача8329758

Аннотация

Ускорить запуск и прогрев приложений за счёт того, что Z Garbage Collector будет быстрее и эффективнее получать и подготавливать физическую память в ответ на потребности приложения.

Цели

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

  • Сократить время прогрева, то есть время, необходимое для достижения пиковой производительности.

Что не является целью

  • Использовать одни и те же приёмы управления памятью во всех операционных системах не является целью. Например, мы можем по умолчанию использовать большие страницы в Linux, но не в Windows.

Мотивация

Z Garbage Collector (ZGC), появившийся в JDK 15, рассчитан на низкую задержку и высокую масштабируемость. ZGC выполняет большую часть работы, пока работают потоки приложения, и приостанавливает эти потоки лишь ненадолго. Время пауз ZGC не зависит от размера кучи: приложения могут использовать кучу размером от нескольких сотен мегабайт до нескольких терабайт и всегда получать короткие паузы.

Начальный размер кучи, который можно задать параметром -Xms, сильно влияет и на время запуска, и на время прогрева. Дело в том, что в ZGC получение сырой памяти от операционной системы и её подготовка к использованию в качестве памяти кучи — дорогая операция. Маленький начальный размер кучи даёт быстрый запуск, но медленный прогрев, так как ZGC приходится постепенно, по мере необходимости, получать и подготавливать всё больше памяти, пока приложение создаёт всё больше объектов. Большой начальный размер кучи, напротив, даёт медленный запуск, но быстрый прогрев, так как ZGC получает и подготавливает больше памяти заранее, до старта приложения.

На практике разница может быть огромной: на типичном современном оборудовании HotSpot JVM с ZGC запускается примерно за десятую долю секунды при начальной куче 2 МБ, но требует больше десяти секунд при начальной куче 32 ГБ.

(Время прогрева можно ещё улучшить, указав параметр -XX:+AlwaysPreTouch, но это ещё больше замедлит запуск.)

В идеале вам не нужно выбирать между быстрым запуском и быстрым прогревом. ZGC должен уметь быстро расширять кучу от мегабайт до терабайт, сохраняя низкую задержку во время запуска, прогрева и после них.

Описание

Мы предлагаем улучшить ZGC так, чтобы HotSpot JVM

  • запускалась быстрее, запрашивая по умолчанию для кучи всего 2 МБ;

  • прогревалась быстрее, агрессивно расширяя кучу; и

  • управляла памятью эффективнее, единицами по 2 МБ, а не по 4 КБ.

Вместе с адаптивным выбором размера кучи эти изменения сделают ненужным и нежелательным задание начального размера кучи (-Xms), максимального размера кучи (-Xmx) или параметра -XX:+AlwaysPreTouch. При запуске приложения достаточно выбрать ZGC и включить адаптивный выбор размера кучи:

$ java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing ...

Выигрыш от этих улучшений значителен: в приведённом ниже примере, где рассматривается типичное серверное приложение, эти изменения сокращают время запуска почти на 40 %, а время прогрева почти на 90 %.

Прежде чем подробно описать улучшения, мы сначала напомним основы современного управления памятью.

Введение в современное управление памятью

Java Virtual Machine создаёт иллюзию бесконечной памяти кучи. Задача сборщика мусора, такого как ZGC, — реализовать эту иллюзию на конечной физической памяти компьютера, на котором работает JVM, с помощью средств оборудования и операционной системы.

Виртуальная и физическая память

HotSpot JVM работает в процессе операционной системы (ОС) с 64-битным виртуальным адресным пространством. Она не может обращаться к физической памяти напрямую: при каждом обращении к памяти процессор преобразует виртуальные адреса в физические.

Блок управления памятью процессора делит виртуальное и физическое адресные пространства на страницы, обычно размером 4 КБ. ОС помогает процессору преобразовывать виртуальные адреса в физические, поддерживая таблицу страниц, которая сопоставляет виртуальные страницы физическим. Это сопоставление может быть произвольным: соседние виртуальные страницы не обязательно отображаются на соседние физические. ZGC с помощью API ОС изменяет таблицу страниц и тем самым управляет тем, как куча, расположенная в виртуальных страницах процесса JVM, отображается на физические страницы.

Резервирование виртуальной памяти

При запуске ZGC резервирует в виртуальном адресном пространстве непрерывный диапазон, в 16 раз превышающий максимальный размер кучи (-Xmx). Например, если максимальный размер кучи равен 1 ГБ, ZGC резервирует 16 ГБ виртуальной памяти, что составляет 4M виртуальных страниц. Резервирование виртуальных страниц — лишь учётная операция ОС, поэтому оно выполняется быстро. Эта операция не записывает в таблицу страниц отображение виртуальных страниц на физические. (Огромное виртуальное адресное пространство позволяет ZGC лучше справляться с фрагментацией, вызванной большими объектами, см. ниже.)

Выделение физической памяти

При запуске ни одной из огромного числа зарезервированных виртуальных страниц физическая память не назначена. Чтобы получить начальную физическую память для кучи, ZGC просит ОС выделить (commit) процессу JVM некоторое число физических страниц. Это число соответствует начальному размеру кучи (-Xms). Например, если начальный размер кучи равен 2 МБ, ZGC создаёт кучу из 512 виртуальных страниц, попросив ОС выделить 512 физических страниц.

В отличие от резервирования виртуальных страниц, выделение физических страниц — не просто учётная операция. Сначала ОС должна найти неиспользуемые физические страницы, для чего может понадобиться выгрузить на диск виртуальные страницы других процессов или, в худшем случае, завершить другие процессы. Найдя физические страницы, ОС обнуляет их, чтобы избежать утечки данных. Только после этого ОС может отобразить эти страницы на виртуальные страницы кучи.

Расширение кучи

Во время работы Java-приложение создаёт объекты в куче. В какой-то момент приложение может создать объект, для которого не найдётся места. Чтобы не выбрасывать OutOfMemoryError, ZGC расширяет кучу, попросив ОС выделить процессу JVM больше физических страниц. Например, если ZGC расширяет кучу с 2 МБ до 16 МБ, ему нужно 3584 новых виртуальных страницы (из 4M, зарезервированных ранее), чтобы отобразить их на 3584 новых физических страницы.

Как и начальное создание кучи, расширение кучи может быть медленным, потому что выделение физических страниц — не просто учётная операция.

Сжатие кучи

ZGC группирует виртуальные страницы кучи в регионы размером от 2 МБ до 32 МБ. В процессе работы ZGC определяет, когда в регионе больше нет живых объектов. Такой регион, содержащий теперь только мусор, называют эвакуированным. Виртуальные страницы эвакуированного региона больше не нужны; в регионе размером 32 МБ таких страниц может быть до 8192.

Виртуальные страницы эвакуированных регионов могут использоваться для последующего выделения памяти под объекты или же возвращаться ОС, что фактически сжимает кучу. Сжатие кучи увеличивает частоту сборки мусора, поэтому ZGC эвристически решает сжать кучу, когда и заполненность кучи, и частота сборки мусора низки. Чтобы сжать кучу, ZGC просит ОС снять отображение физических страниц, назначенных сейчас эвакуированным виртуальным страницам, а затем освободить эти физические страницы из процесса JVM.

Повторное использование памяти

ZGC может повторно использовать физические страницы, выделенные эвакуированным регионам, чтобы ускорить создание объектов. Например, выражение new int[1024*1024] создаёт массив, которому нужно 4 МБ памяти, и ZGC должен найти для него непрерывное пространство в куче. Даже если в куче в сумме столько свободного места есть, оно может быть разбросано по несмежным виртуальным страницам. В такой ситуации большинство сборщиков мусора уплотняют кучу, перемещая объекты из одного региона в другой и с большим трудом пытаясь собрать регион, в котором не меньше 1024 подряд идущих пустых виртуальных страниц по 4 КБ. Однако для этого обычно нужен цикл сборки мусора — обход кучи, пометка живых объектов и их перемещение, — а это дорого.

Вместо этого ZGC использует средство ОС, переотображение (remapping), чтобы собрать регион с достаточным непрерывным пространством. Переотображение изменяет таблицу страниц так, чтобы некоторые виртуальные страницы отображались на физические страницы, которые раньше стояли за другими виртуальными страницами. Эвакуированный регион кучи содержит только мусор, поэтому составляющие его виртуальные страницы больше не нужны, а физические страницы, на которые они отображены, можно безопасно использовать для других виртуальных страниц. Соответственно, ZGC забирает (harvests) эти физические страницы, отображая на них непрерывный диапазон виртуальных страниц из огромного виртуального адресного пространства, зарезервированного при запуске.

Повторное использование позволяет ZGC находить место для большого объекта на несколько порядков быстрее, чем при выполнении цикла сборки мусора для уплотнения кучи. Повторное использование не меняет объём памяти, занимаемой JVM, так как новые физические страницы не выделяются — уже выделенные физические страницы просто используются по-новому.

Более быстрый запуск с маленькой начальной кучей

Традиционно ZGC при запуске выделял под начальную кучу 1/64 физической памяти. При такой политике получалась, например, начальная куча 16 МБ на машине с 1 ГБ физической памяти и 2 ГБ на машине со 128 ГБ физической памяти.

Как правило, приложения не определяют при запуске, сколько памяти доступно, и не подстраивают под это выделение памяти. Вместо этого те, кто развёртывает приложение, опытным путём наблюдают за его производительностью при разных начальных размерах кучи и затем выбирают значение параметра -Xms в скрипте запуска приложения, возможно, жертвуя более медленным прогревом ради более быстрого запуска или наоборот, как описано выше.

Оценить влияние начального размера кучи на запуск несложно: можно легко измерить время от старта JVM до первого ответа приложения. А вот оценить влияние начального размера кучи на прогрев труднее: нужно измерять время не только до первого ответа, но и, возможно, для множества ответов, пока время ответа не стабилизируется. На практике так делают редко, поэтому лучше всего вообще избавиться от необходимости в таких оценках.

Мы предлагаем, чтобы ZGC по умолчанию выделял под начальную кучу всего 2 МБ. Выделение памяти для ОС — не просто учётная операция, и оно может быть медленным, поэтому выделение минимально возможного объёма — ровно на один регион размером 2 МБ — сокращает время запуска.

Более быстрый прогрев за счёт агрессивного расширения кучи

С маленькой начальной кучей приложения будут запускаться быстро, но обычно её не хватит. По мере того как приложение создаёт всё больше объектов, JVM будет запускать циклы сборки мусора всё чаще. Поскольку собирать нужно лишь маленькую кучу, эти циклы будут короткими, а ZGC использует выделенные рабочие потоки, чтобы собирать мусор, не блокируя потоки приложения. Однако в конце концов скорость сборки мусора в ZGC перестанет поспевать за скоростью создания объектов приложением. В этом случае либо ZGC должен выделить больше физической памяти, либо JVM должна приостановить создание объектов до конца следующего цикла сборки; в обоих случаях в работе приложения возникнет пауза.

Мы предлагаем, чтобы ZGC расширял кучу агрессивнее, чем сейчас. Впервые обнаружив высокую частоту циклов сборки мусора, он будет упреждающе расширять кучу. Со временем ZGC будет оценивать обычную скорость размещения объектов приложением и на её основе вычислять, когда упреждающе расширять кучу. ZGC будет быстро расширять кучу, каждый раз на всё больший объём; это обеспечит логарифмическую границу числа расширений, а значит, и числа циклов сборки, необходимых для достижения стабильного размера кучи. Наконец, ZGC будет расширять кучу конкурентно, используя собственные рабочие потоки, а не потоки приложения.

Агрессивное расширение кучи позволит ZGC снизить частоту сборок. Это, в свою очередь, уменьшит накладные расходы процессора на сборку мусора и повысит пропускную способность приложения. Время ожидания, пока ОС выделит физические страницы, будут тратить рабочие потоки ZGC, а не потоки приложения, поэтому выполнение станет более равномерным, а пауз будет меньше. Чтобы потоки приложения никогда не испытывали нехватки ресурсов, ZGC никогда не будет использовать для своих рабочих потоков более 25 % доступных аппаратных потоков процессора.

Более быстрый прогрев за счёт конкурентного предварительного касания (pre-touching)

Когда процесс просит ОС отобразить диапазон виртуальных страниц на диапазон физических страниц, ОС не сразу обновляет таблицу страниц, чтобы отразить это отображение. Это могло бы оказаться напрасной работой, поскольку некоторые процессы никогда не используют все свои виртуальные страницы. Поэтому ОС обновляет таблицу страниц постепенно, по требованию: при первом чтении или записи только что отображённой виртуальной страницы процессор генерирует ошибку страницы (page fault). Из-за неё ОС обновляет таблицу страниц и отображает эту страницу на соответствующую физическую страницу.

В процессе JVM ошибки страниц часто возникают во время прогрева, поскольку приложение создаёт много объектов, а JVM впервые записывает во множество виртуальных страниц. Чтобы ускорить прогрев в JDK 25, мы предлагали использовать параметр -XX:+AlwaysPreTouch. С ним ZGC выполняет предварительное касание начальной кучи, то есть записывает в каждую её виртуальную страницу сразу после отображения на только что выделенные физические страницы, и тем самым заставляет ОС немедленно обновить таблицу страниц.

Цена такого подхода — увеличение времени запуска JVM, поскольку все ошибки страниц для начальной кучи теперь происходят во время запуска. Если задать начальный размер кучи (-Xms) небольшим, вы можете не заметить влияния. Но если задать начальный размер кучи равным максимальному (-Xmx), запуск, скорее всего, станет намного медленнее. Вот сравнение реального времени запуска JVM с -XX:+AlwaysPreTouch при различных начальных и максимальных размерах кучи:

-Xms2M   -Xmx2M     0.034s
-Xms20M  -Xmx20M    0.041s
-Xms200M -Xmx200M   0.110s
-Xms2G   -Xmx2G     0.634s
-Xms20G  -Xmx20G    5.672s

Мы предлагаем, чтобы ZGC выполнял предварительное касание кучи не при запуске, а при её расширении. Каждый раз, когда рабочий поток ZGC расширяет кучу, выделяя дополнительные физические страницы, он будет записывать в только что отображённые виртуальные страницы. Так ОС будет вынуждена сразу обновить таблицу страниц для этих страниц, а не обновлять её постепенно, по мере их первого использования.

Более быстрый доступ к памяти за счёт больших страниц

Современные приложения обычно обрабатывают гигабайты данных, поэтому организовывать виртуальную память в виде страниц по 4 КБ неэффективно. Linux и Windows поддерживают большие страницы (large pages), иногда называемые огромными страницами (huge pages), размер которых обычно составляет 2 МБ. Например, массив размером 8 МБ можно разместить в четырёх больших страницах вместо 2048 маленьких.

Меньшее число страниц большего размера ускоряет доступ. Поскольку виртуальное адресное пространство огромно, таблица страниц — это не простая таблица поиска, а иерархия таблиц. Когда JVM разыменовывает виртуальный адрес, процессор последовательно просматривает уровни иерархии, чтобы определить физический адрес. Большие страницы позволяют процессору пропустить один уровень поиска. Кроме того, процессор поддерживает кэш недавних отображений виртуальных адресов на физические, и большая страница занимает в нём только одну запись вместо 512 записей для соответствующих маленьких страниц; благодаря этому попадание в кэш становится вероятнее для более широкого диапазона адресов.

Исторически в ядре Linux большие страницы были по умолчанию отключены. Чтобы включить их, суперпользователь должен был настроить ядро, но во многих средах развёртывания это было непрактично.

Начиная с Linux 6.1, выпущенного в 2022 году, любой процесс может попросить ОС преобразовать заданный диапазон маленьких виртуальных страниц в большие страницы; настраивать ядро для этого не требуется. ZGC будет использовать этот API для преобразования маленьких страниц в большие при расширении кучи, чтобы в итоге вся куча размещалась в больших страницах. Преобразование может быть дорогостоящей операцией и потенциально может потребовать дефрагментации физической памяти, поэтому его будут выполнять рабочие потоки ZGC, чтобы свести к минимуму влияние на приложение.

Пример: более быстрые запуск и прогрев

Рассмотрим запуск и прогрев приложения Spring PetClinic на машине с 32 ГБ физической памяти.

Чтобы количественно оценить прогрев, мы измеряем время отклика приложения в период сразу после запуска JVM. В частности, мы измеряем время отклика P99, то есть 99-й процентиль времени отклика, который характеризует хвостовую задержку. Мы исходим из того, что приемлемое время отклика для этого приложения — 20 мс.

Для нагрузки приложения мы используем собственный инструмент генерации нагрузки. Сначала инструмент генерирует один запрос, имитируя проверку работоспособности, которую выполняет балансировщик нагрузки. Затем он генерирует 1000 запросов в секунду и линейно увеличивает их число до 30 000 запросов в секунду в течение десятисекундного периода наращивания нагрузки. Период наращивания имитирует балансировщик нагрузки, настроенный исходя из того, что приложение не достигает пиковой производительности, пока JVM не скомпилирует JIT-компилятором все горячие пути в машинный код. Если бы приложение сразу получало 30 000 запросов в секунду, время отклика P99 измерялось бы секундами, а не миллисекундами. Постепенно увеличивая частоту запросов, мы можем всё время сохранять приемлемую задержку. После периода наращивания генератор нагрузки продолжает отправлять 30 000 запросов в секунду в течение двух минут.

Мы считаем приложение прогретым, когда период наращивания нагрузки завершился, а время отклика P99 стабильно остаётся ниже 20 мс.

Сценарий 1: JDK 25 с маленькой начальной кучей и без предварительного касания

Чтобы минимизировать время запуска, мы настраиваем JVM из JDK 25 с маленькой начальной кучей (2 МБ) и большим максимальным размером кучи (20 ГБ):

$ java -XX:+UseZGC -Xms2M -Xmx20G -jar petclinic.jar

Время от запуска JVM до первого ответа — 5,86 с. К сожалению, время отклика P99 в течение десятков секунд многократно подскакивает выше 20 мс, поскольку ZGC должен расширять кучу с 2 МБ до 20 ГБ по мере того, как приложению требуется больше памяти. Для этого нужно постепенно выделять физические страницы и вызывать ошибку страницы при первом обращении к каждой соответствующей виртуальной странице, что приводит к плохой задержке. С каждой секундой запросы на размещение объектов всё чаще удовлетворяются за счёт ранее выделенной памяти, освобождённой ZGC для повторного использования, а не за счёт вновь выделенной памяти, и время отклика P99 выше 20 мс встречается всё реже. К 40-й секунде работы всплески P99 становятся редкими, а примерно к 80-й секунде приложение прогревается.

Сценарий 2: JDK 25 с большой начальной кучей и её предварительным касанием

Чтобы ускорить прогрев, мы настраиваем JVM из JDK 25 с большой начальной кучей (20 ГБ) и обеспечиваем её предварительное касание:

$ java -XX:+UseZGC -Xms20G -Xmx20G -XX:+AlwaysPreTouch -jar petclinic.jar

Запуск хуже — 9,45 с вместо 5,86 с — из-за времени, необходимого на выделение физических страниц для большой начальной кучи и предварительное касание соответствующих виртуальных страниц. Зато прогрев заметно лучше благодаря обилию выделенных и затронутых физических страниц. Время отклика P99 ни разу не превышает 20 мс, так что после начального периода наращивания нагрузки длительностью 10 с приложение прогрето.

Сценарий 3: JDK NN

Описанные выше улучшения ZGC сочетают преимущество для запуска, которое даёт маленькая начальная куча, преимущество для прогрева, которое дают агрессивное расширение кучи и конкурентное предварительное касание, и общее преимущество больших страниц:

$ java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing -jar petclinic.jar

Запуск занимает 5,84 с, что практически совпадает со сценарием 1 и на 38 % меньше, чем в сценарии 2. Как и в сценарии 2, время отклика P99 ни разу не превышает 20 мс, так что после начального периода наращивания нагрузки длительностью 10 с приложение прогрето.

Сокращение времени прогрева с 80 с в сценарии 1 до 10 с в сценарии 3 при сохранении малого времени запуска можно объяснить сочетанием агрессивного расширения кучи, конкурентного предварительного касания и использования больших страниц. Это можно показать, включая и отключая эти возможности по отдельности:

  • Если повторить сценарий 3 с агрессивным расширением и большими страницами, но без конкурентного предварительного касания, прогрев займёт 34 с.

  • Если повторить сценарий 3 с агрессивным расширением и конкурентным предварительным касанием, но без больших страниц, прогрев займёт 18 с.

Все три возможности вместе сокращают время прогрева настолько, что он целиком укладывается в период наращивания нагрузки длительностью 10 с. Это существенно: когда 99-й процентиль времени отклика остаётся низким, у JVM больше ресурсов процессора для JIT-компиляции горячих путей приложения в машинный код.

Дальнейшая работа

  • Ключ к быстрому прогреву — сокращать использование кучи, тратя ресурсы процессора на конкурентную сборку мусора, и одновременно увеличивать ёмкость кучи, тратя ресурсы процессора на конкурентное выделение памяти. Мы изучим, как ещё больше ускорить прогрев за счёт более явного распределения ресурсов процессора между этими двумя видами работы.

  • Компиляция кода в режиме Ahead-of-Time (заблаговременная компиляция и подготовка, JEP 544) из проекта Leyden устранит накладные расходы на JIT-компиляцию приложения в машинный код во время прогрева. Это освободит больше ресурсов процессора для ZGC: для конкурентной сборки мусора, для конкурентного выделения памяти или для того и другого.

Альтернативы

Вместо того чтобы выделять под начальную кучу 2 МБ, ZGC мог бы выделять больший объём, что уменьшило бы необходимость в агрессивном расширении. Однако если пользователь ничего не указал о начальном размере кучи, разумно не предполагать, что она должна быть больше. Некоторые приложения создают совсем мало объектов, и большая начальная куча им не пригодится. Недостатки старта с начальной кучей, слишком маленькой для приложения, смягчаются выделением памяти для кучи и её предварительным касанием во время работы приложения, а также экспоненциальным расширением кучи.

Риски и допущения

  • Если начальный размер кучи по умолчанию будет равен 2 МБ, а не будет зависеть от объёма физической памяти, возникает риск: приложения могли быть настроены в расчёте на больший начальный размер кучи в своих средах развёртывания. Например, приложение, настроенное для работы на машине со 128 ГБ физической памяти, где в JDK 25 начальная куча по умолчанию составляет 2 ГБ, может прогреваться медленнее на JDK NN с начальной кучей 2 МБ. Более медленный прогрев может проявиться в виде затянувшегося периода после запуска, в течение которого время отклика P99 регулярно превышает приемлемый уровень. Мы исходим из того, что специалисты по развёртыванию знают о необходимости заново настраивать конфигурацию GC после значительных улучшений функциональности JVM.

  • Многие приложения запускаются скриптами, которые настраивают JVM с небольшой начальной кучей, намного меньше 2 ГБ. Такие приложения, скорее всего, будут прогреваться на JDK NN быстрее, чем на JDK 25. Мы исходим из того, что в экосистеме Java в целом предложенные здесь улучшения ZGC дадут чистый выигрыш как для запуска, так и для прогрева.