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

JEP 516: Ahead-of-Time Object Caching with Any GC

Кэширование объектов в режиме Ahead-of-Time (заблаговременная компиляция и подготовка) с любым сборщиком мусора

ОтветственныйErik Österlund
ТипFeature
ОбластьJDK
СтатусClosed / Delivered
Выпуск26
Компонентhotspot / gc
Обсуждениеhotspot dash dev at openjdk dot org
ТрудоёмкостьM
ДлительностьM
РецензентыAlex Buckley, Ioi Lam, Stefan Karlsson, Vladimir Kozlov
ОдобренVladimir Kozlov
Создан2024/02/16 09:49
Обновлён2026/06/30 05:31
Задача8326035

Аннотация

Доработать AOT Cache (кэш, подготовленный заранее), с помощью которого HotSpot Java Virtual Machine сокращает время запуска и прогрева, так чтобы его можно было использовать с любым сборщиком мусора, в том числе с Z Garbage Collector (ZGC), который обеспечивает низкую задержку. Для этого сделать возможной последовательную загрузку кэшированных Java-объектов в память из нейтрального формата, не зависящего от сборщика мусора, вместо их прямого отображения в память в формате, специфичном для конкретного сборщика мусора.

Цели

  • Обеспечить беспроблемную работу всех сборщиков мусора с AOT-кэшем, появившимся в проекте Leyden.

  • Отделить AOT-кэш от деталей реализации и политик сборщиков мусора.

  • Сделать так, чтобы использование AOT-кэша не оказывало существенного влияния на время запуска по сравнению с предыдущими выпусками.

Мотивация

Большинство сборщиков мусора HotSpot JVM приостанавливают потоки приложения, чтобы освободить память. Из-за этого обработка некоторых запросов занимает у приложений значительно больше времени, чем обычно, и их хвостовая задержка (tail latency) растёт. Например, 99 % всех запросов могут обрабатываться за 10 мс, а 1 % запросов может занимать 100 мс и более. Хвостовую задержку, вызванную сборкой мусора, можно свести к минимуму с помощью Z Garbage Collector (ZGC). ZGC освобождает память конкурентно и никогда не приостанавливает потоки приложения больше чем на миллисекунду.

Однако сборка мусора — не единственная причина высокой хвостовой задержки.

Java-приложения часто масштабируют, запуская новые экземпляры JVM для обработки большего числа запросов, но запросы к новому экземпляру выполняются значительно дольше, чем запросы к уже прогретому экземпляру. Чтобы устранить этот источник хвостовой задержки, можно включить загрузку и компоновку классов в режиме Ahead-of-Time, появившуюся в JDK 24. Она ускоряет запуск приложения: классы приложения кэшируются в тренировочном прогоне и в рабочей среде становятся доступны сразу. Например, демонстрационное приложение Spring PetClinic в рабочей среде запускается на 41 % быстрее, потому что благодаря кэшу около 21 000 классов при запуске приложения оказываются уже загруженными и скомпонованными. Профилирование методов в режиме Ahead-of-Time, появившееся в JDK 25, а также готовящаяся компиляция кода в режиме Ahead-of-Time используют AOT-кэш, чтобы ещё больше увеличить этот выигрыш.

К сожалению, способ кэширования классов и других Java-объектов несовместим с ZGC. Из-за этого приходится выбирать, что терпеть: хвостовую задержку, вызванную сборкой мусора, или хвостовую задержку, вызванную запуском. Если вы используете ZGC, чтобы уменьшить первую, то не можете включить загрузку и компоновку классов в режиме Ahead-of-Time, чтобы уменьшить вторую, и наоборот.

Этого болезненного выбора можно было бы избежать, если бы AOT-кэши можно было использовать с любым сборщиком мусора HotSpot JVM, в том числе с ZGC.

Описание

AOT-кэш содержит, помимо прочего, представления Java-объектов Class для классов, которые были загружены и скомпонованы во время тренировочного прогона. В нём также содержатся Java-объекты, на которые ссылаются эти объекты Class, например строки и массивы байтов.

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

Мы предлагаем опционально кэшировать Java-объекты в нейтральном формате, не зависящем от сборщика мусора, который работает со всеми сборщиками мусора, независимо от того, какой сборщик используется при тренировке или в рабочей среде. Дополнительное преимущество: так JDK сможет включать базовый AOT-кэш, работающий во всех средах.

Препятствия для кэширования объектов независимо от сборщика мусора

Главная сложность кэширования объектов независимо от сборщика мусора — в том, как обрабатывать ссылки на объекты. С точки зрения Java-кода значение поля, которое хранит ссылку на объект, непрозрачно. Однако с точки зрения JVM у каждого сборщика мусора свои политики размещения объектов в памяти и представления ссылок одного объекта на другой:

  • Политики, зависящие от размера кучи (Serial, Parallel, G1) — для куч больше 32 ГБ ссылки на объекты представляются 64-битными адресами и хранятся непосредственно в ссылочных полях. Для куч меньше 32 ГБ ссылки на объекты хранятся в ссылочных полях как 32-битные значения, при необходимости со сжатием, поскольку адреса могут занимать до 35 бит. Существует три схемы сжатия, которые выбираются эвристически во время выполнения в зависимости от размера кучи и других факторов.

  • Политики, зависящие от размера объектов (G1, ZGC) — объекты размещаются в регионах кучи в соответствии с их размерами. В G1 старшие биты 64-битного адреса определяют регион, младшие биты кодируют смещение внутри региона, а объекты никогда не пересекают границы регионов. Объект, который G1 считает большим, получает собственный отдельный регион кучи, и у любой ссылки на такой объект все младшие биты должны быть нулевыми. ZGC же различает малые, средние и большие объекты и использует для каждого из них свой формат ссылок.

  • Метаданные — ZGC кодирует в ссылках на объекты биты метаданных. Эти биты используются для управления конкурентной сборкой мусора. Ни один другой сборщик мусора такой формат ссылок не поддерживает.

Из-за множества форматов ссылок сложно взять объекты, которыми управляет один сборщик мусора, закэшировать их и позже воссоздать для другого сборщика мусора.

Кэширование объектов сейчас

Представление Java-объекта в AOT-кэше повторяет его представление в памяти. Рассмотрим, например, объект String, у которого есть такие поля:

public class String {
    private final byte[] value;
    private final byte coder;
    private int hash;
    private boolean hashIsZero;
}

В кэшированной форме объекта String поле value содержит 64-битный адрес массива байтов в памяти:

header: ...  |  value: 0x4002045278  |  coder: ...  |  hash: ...  |  hashIsZero: ...

Адрес хранится в формате наименьшего общего знаменателя, допустимом для сборщиков Serial, Parallel и G1. Объекты сохраняются в AOT-кэшах так, чтобы ни один из них не пересекал границы регионов кучи, при этом используется заранее заданный размер региона. Благодаря этому в рабочей среде можно использовать G1, даже если тренировка выполнялась с Serial или Parallel.

Однако ZGC не использует 64-битные адреса в качестве ссылок на объекты и не поддерживает единый размер регионов. Поэтому ZGC нельзя использовать с AOT-кэшами.

Кэширование объектов независимо от сборщика мусора

Мы кэшируем Java-объекты независимо от сборщика мусора, сохраняя ссылки на объекты в формате, не зависящем от сборщика мусора, а именно в виде логических индексов. В объекте String, закэшированном в этом формате, поле value содержит логический индекс массива байтов:

header: ...  |  value: 5  |  coder: ...  |  hash: ...  |  hashIsZero: ...

Чтобы использовать объекты, закэшированные в этом формате, логические индексы нужно преобразовать обратно в адреса памяти. Поэтому JVM считывает объекты из кэша в память последовательно, то есть передаёт их потоком. При открытии кэша фоновый поток сразу начинает материализовывать объекты один за другим. Материализация объекта включает выделение памяти в куче, инициализацию полей объекта по данным из кэша и построение ссылок на другие материализованные объекты с помощью поиска во вспомогательной таблице. Когда приложение впервые использует класс, оно синхронизируется с фоновым потоком, чтобы гарантировать, что объект Class этого класса и все связанные с ним объекты материализованы. (Остальные данные в кэше по-прежнему отображаются в память, как и сейчас.)

Выбор между кэшированием объектов в формате, специфичном для сборщика мусора, и в формате, не зависящем от него

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

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

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

Наименее выгодная ситуация для потокового кэширования объектов в формате, не зависящем от сборщика мусора, — тёплый запуск в ограниченной среде, где нет свободного ядра процессора. JVM старается избежать такой ситуации в рабочей среде, применяя эвристику при создании AOT-кэша после тренировочного прогона:

  • Объекты кэшируются в потоковом формате, не зависящем от сборщика мусора, если при тренировке использовался ZGC, или использовалась опция -XX:-CompressedOops, или куча была больше 32 ГБ. Тренировка с ZGC, несжатыми указателями или большой кучей подразумевает, что тренировочная среда была большой и в ней было доступно больше одного ядра. Мы предполагаем, что рабочая среда так же не ограничена, а значит, потоковая передача будет наиболее эффективной.

  • Объекты кэшируются в отображаемом формате, специфичном для сборщика мусора, если при тренировке использовалась опция -XX:+UseCompressedOops. Эта опция указывает, что в тренировочной среде куча была меньше 32 ГБ и ZGC не использовался. Это подразумевает, что тренировочная среда была ограниченной системой без свободного ядра. Мы предполагаем, что рабочая среда аналогична, а значит, наиболее эффективным будет отображение.

Можно явно создать кэш, объекты в котором хранятся в потоковом формате, не зависящем от сборщика мусора, указав -XX:+AOTStreamableObjects, даже если вы также указываете -XX:+UseCompressedOops.

JDK включает два базовых AOT-кэша, один с кэшированными объектами в формате, не зависящем от сборщика мусора, и другой с кэшированными объектами в формате, специфичном для сборщика мусора, которые JVM использует, когда приложение не предоставляет свой кэш. Благодаря этому JVM может в зависимости от ситуации использовать потоковую передачу или отображение и добиваться наилучшей производительности запуска.

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

  • Для поддержки AOT-кэшей в ZGC не требуется решение, не зависящее от сборщика мусора. Вместо этого мы могли бы продолжить подход, специфичный для сборщика мусора, и создавать кэши для ZGC, содержащие ссылки на объекты в формате ZGC. Это оптимизировало бы производительность запуска. Однако решение, не зависящее от сборщика мусора, в котором объекты материализуются в фоне, не оказывает существенного влияния на производительность запуска, пока доступно дополнительное ядро, поэтому единственная ситуация, в которой кэш, специфичный для ZGC, работал бы лучше, — использование ZGC на одноядерной машине. Для ZGC с его высокой степенью конкурентности это необычная среда, и поэтому она не оправдывает создание кэшей, специфичных для ZGC. Мы предпочитаем опираться на принцип, согласно которому лучший способ уменьшить хвостовую задержку — системный подход, при котором проектирование отдельных компонентов согласовано; это и приводит нас к подходу, не зависящему от сборщика мусора.

  • Мы могли бы изменить ZGC так, чтобы он мог интерпретировать как собственные ссылки на объекты, так и ссылки на объекты в формате, ориентированном на G1, которые сейчас хранятся в AOT-кэшах. Сборщики Serial и Parallel были изменены именно так, но ZGC значительно сложнее. Такой подход фактически связал бы реализации всех сборщиков мусора друг с другом, что нежелательно. Подход, не зависящий от сборщика мусора, напротив, разделяет реализации: реализации сборщиков мусора могут развиваться, а вы можете выбирать из всего набора сборщиков мусора и при тренировке, и затем в рабочей среде. Кроме того, поскольку побитовое размещение кэшированных объектов в формате, не зависящем от сборщика мусора, не связано с размещением объектов в памяти кучи, мы рассчитываем, что сможем оптимизировать структуру кэша и уменьшить его статический объём, не оказывая существенного влияния на реализации сборщиков мусора.

Тестирование

Тестов кэширования объектов уже много. Мы адаптируем их для тестирования с ZGC и с новым потоковым подходом, не зависящим от сборщика мусора.