Привет, Хабр!

Каждый Java-объект в HotSpot начинается со служебного заголовка: метаданные о типе, состояние блокировок, GC-возраст, identity hash, forwarding-указатель при копировании. На 64-битной JVM с включёнными compressed class pointers (типичная конфигурация) этот заголовок занимает 12 байт: 8 байт mark word + 4 байта klass word. Без compressed class pointers — все 16.

12 байт за служебную информацию — много, особенно для приложений, где в куче живут миллионы мелких объектов: DTO, узлы HashMap и LinkedList, обёртки Integer/Long, ORM-сущности. У такого объекта заголовок может занимать больше места, чем полезные поля. После выравнивания тела объекта по 8 байтам ситуация ещё хуже: появляется padding, который тоже считается в heap, но никакой пользы не несёт.


1. Что такое Compact Object Headers

Compact Object Headers — это альтернативная раскладка заголовка в HotSpot, которая упаковывает всё в одно 64-битное слово (8 байт) вместо двух раздельных полей. Указатель на класс сжимается с 32 до 22 бит и встраивается в верхние биты mark word; остальные биты (hash, GC age, lock state, бит self-forwarded, резерв под Project Valhalla) сохраняют свою роль.

Чтение типа объекта при таком layout — это сдвиг и маска по mark word, по производительности сопоставимо с обычным compressed oop.


2. Заголовок объекта: зачем он вообще

Любой обычный Java-объект в куче HotSpot начинается не с полей вашего класса, а с служебного заголовка. JVM должна быстро понять:

  • к какому типу относится объект — для instanceof, виртуальных вызовов, GC;

  • как обрабатывать блокировки на мониторе объекта;

  • что делать при копировании в другую область heap (forwarding);

  • где хранить identity hash code, если его уже запросили.

Прикладной код этого не видит. Зато видит SRE, когда сервис с миллионами мелких DTO съедает heap быстрее, чем ожидали по размеру «полезных» полей.

Типичный случай: объект с одним int или пустой ArrayList после выравнивания занимает больше места в заголовке и padding, чем в собственных данных. Compact headers сокращают накладные расходы именно на таких объектах.


3. Классическая раскладка на 64-bit

При включённых compressed class pointers (типичный случай на 64-bit) заголовок — два поля:

┌──────────────────┬──────────────────┐
│   mark word      │   klass word     │
│   64 bit (8 B)   │   32 bit (4 B)   │
└──────────────────┴──────────────────┘
        Итого: 12 байт (+ выравнивание тела объекта)

Mark word — всё про конкретный экземпляр: lock state, GC age, hash, forwarding при evacuate.

Klass word — сжатый указатель на метаданные класса в metaspace (для reflection, проверок типа, layout полей).

Если compressed class pointers выключены, klass pointer раздувается до 64 бит, заголовок может быть 16 байт. Такой режим в JDK 25 уже deprecated, с compact headers он несовместим.


4. Компактный заголовок и Project Lilliput

Project Lilliput в OpenJDK как раз про уменьшение overhead объектов. Compact headers — первый крупный кусок, дошедший до product и до default.

Идея: не хранить klass отдельно, а встроить сжатый class pointer в единственное 64-битное слово вместе с остальными битами mark word.

Раскладка 64 бит compact header (упрощённо)
┌───────────────────────────────────────────────────────────────┐
│ klass:22 │ hash:31 │ valhalla:4 │ age:4 │ self-fwd:1 │ lock:2 │
└───────────────────────────────────────────────────────────────┘
  • klass:22 — class pointer сжали с 32 до 22 бит (до порядка 4M классов).

  • hash:31 — identity hash, как в классической схеме.

  • valhalla:4 — зарезервировано под Project Valhalla.

  • age:4, lock:2 — GC и мониторы.

  • self-fwd:1 — тег для GC, когда при копировании нельзя затереть klass в заголовке.

Чтение типа объекта при таком layout: загрузить mark word, сдвигом и маской достать 22-битное поле klass, декодировать в полный указатель по формуле Klass* = klass_base + (narrow_klass << klass_shift) — та же арифметика, что у compressed oops, только база и shift свои. По стоимости это всего одна дополнительная инструкция сдвига поверх чтения mark word, которое JIT и так выполняет почти при каждом обращении к объекту (instanceof, виртуальный вызов, GC barrier). В часто исполняемом коде накладные расходы относительно классической раскладки пренебрежимо малы и в бенчмарках обычно не проявляются.

Итог для разработчика: 8 байт заголовка вместо 12. На миллиардах объектов это минус гигабайты committed memory и меньше cache miss при обходе heap.

Помимо памяти возможен выигрыш и по CPU: меньший heap означает более редкие GC-циклы и более плотное размещение объектов в кэше процессора. В открытых отчётах по нагрузкам вроде SPECjbb экономия CPU оценивается в единицы процентов. На профилях с небольшим числом крупных объектов эффект может быть нулевым.


5. Что делает JEP 534

Формулировка из JEP 534:

Make compact object headers the default object header layout in the HotSpot JVM.

То есть JEP 534 не меняет раскладку заголовка и не вводит новых битов — он переключает значение по умолчанию. Сама же оптимизация прошла три стадии (эта схема стандартна для изменений, затрагивающих каждый new в HotSpot):

JEP

JDK

Статус

450

24

Experimental, под -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders

519

25

Product feature, opt-in через -XX:+UseCompactObjectHeaders, выключено по умолчанию

534

27 (targeted)

Включено по умолчанию, откат — -XX:-UseCompactObjectHeaders. Старый layout планируют объявить deprecated в будущих релизах

Прикладной Java-код менять не нужно — изменения касаются только runtime. Флаг -XX:+UseCompactObjectHeaders после включения default не обязателен. Тем не менее, пока в инфраструктуре одновременно работают JDK 25/26 и JDK 27, имеет смысл явно фиксировать ожидаемый layout в конфигурации запуска (Helm chart, Dockerfile, runbook). Это снимает неоднозначность: при чтении конфига сразу видно, какой режим заголовков используется на конкретном стенде, и не приходится сверяться с версией JDK.

В JEP также сказано, что перед включением default в основной ветке OpenJDK будут проведены correctness и performance testing. Это означает, что изменение требует серьёзной проверки и со стороны разработчиков приложений: меняется базовое предположение о структуре объекта, на которое опираются JIT-компилятор, GC, JFR, JVM TI, нативные агенты и инструменты профилирования. Проверять поведение на своих нагрузках имеет смысл заранее, не дожидаясь обновления на JDK 27.


6. Цифры: когда 4 байта на объект — это много

Простая оценка на типовой сценарий.

Допустим, сервис держит в heap 200 млн живых объектов среднего «мелкого» класса. Экономия 4 байта только на заголовке:

200_000_000 × 4 B ≈ 800 MB

Плюс меньше давление на GC, плотнее упаковка в young generation, иногда меньше расширений heap под лимит контейнера.

На allocation-heavy сервисах (кэши из короткоживущих объектов, стримы, JSON в DTO, ORM-сущности) открытые замеры показывают 10–22% меньше heap. На сервисе, где доминируют byte[], примитивные массивы и мало «объектной обвязки», эффект будет скромным.

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


7. Сколько именно отъедает у разных профилей heap

Базовая экономия — 4 байта на объект. Но из-за выравнивания тела объекта по 8 байтам иногда выигрывается 8 байт: объект, занимавший 24 B (12 header + 8 данных + 4 padding), ужимается до 16 B.

Поэтому реальная экономия в процентах сильно зависит от среднего размера живого объекта. Прикидка для разных профилей:

Профиль heap

Средний размер объекта

Экономия на штуке

% от heap

Очень мелкие объекты (DTO, POJO, узлы коллекций, обёртки Integer/Long)

24 B

4–8 B

~17–33%

Типовой Java-сервис

40–48 B

~4 B

~8–11%

Средне-крупные объекты

80 B

~4 B

~5%

Доминируют массивы / byte[] / String payload

200+ B

~4 B

~2% и меньше

Что это значит на конкретных размерах heap:

-Xmx

Микросервис (~10%)

Allocation-heavy (~20%)

Очень мелкие объекты (~30%)

2 GB

~200 MB

~400 MB

~600 MB

8 GB

~800 MB

~1.6 GB

~2.4 GB

30 GB

~3 GB

~6 GB

~9 GB

Для большинства корпоративных бэкендов реалистично ожидать 5–15% экономии. 20%+ — на сервисах, где живёт много мелких объектов (кэши с короткоживущими entity, потоковая обработка, ORM с большими графами). На сервисах, где heap забит крупными byte[] (изображения, протобуф, off-heap буферы внутри heap), эффект почти не виден.

И ещё одно следствие, про которое часто забывают: на heap, висящем впритык к 32 GB, экономия может позволить остаться под границей compressed oops. Это сам по себе отдельный выигрыш — без 64-битных ссылок heap занимает на 10–20% меньше.


8. Функциональные языки на JVM

Отдельно стоит выделить функциональные JVM-языки и иммутабельные коллекции — для них профиль heap почти всегда попадает в верхнюю строку таблицы из раздела 7. Persistent data structures работают через structural sharing: каждая «модификация» создаёт новые мелкие узлы и переиспользует старые, поэтому в куче живут миллионы небольших объектов с большим отношением header/payload.

  • Scala. Cons-ячейка :: (поля head + tail): сейчас 12 байт header + 8 байт ссылок = 20, после выравнивания 24 байта. С compact: 8 + 8 = 16 байт, экономия 33% на каждой ячейке. Аналогично — Tuple2, многие case class с 1–2 ссылочными полями, узлы HashMap/HashSet на HAMT.

  • Clojure. Целиком построен на persistent collections: PersistentVector, PersistentHashMap, PersistentList, MapEntry, Keyword, Symbol. Узлы маленькие, их очень много — один из самых выгодных профилей для JEP 534.

  • Kotlin. Сам по себе не persistent, но data class с 1–3 полями, Pair, Triple, sealed-иерархии в стиле ADT выигрывают по той же арифметике. С kotlinx.collections.immutable — эффект уровня Scala/Clojure.

  • Vavr и иммутабельные коллекции из Eclipse Collections / Guava — тот же выигрыш на узлах деревьев и связных списков.

Дополнительный бонус — на копирующих сборщиках (G1, ZGC, Shenandoah): меньше живых байт значит меньше работы при evacuation и, как следствие, более короткие GC-паузы при том же throughput.


9. Как померить именно у себя

Не гадать, а снять снапшот:

# гистограмма классов и количества объектов
jcmd <pid> GC.class_histogram | head -50

# полный дамп heap для анализа в MAT / VisualVM
jcmd <pid> GC.heap_dump /tmp/heap.hprof

Оценить экономию можно за четыре шага:

  1. Из гистограммы взять топ-классы по числу инстансов.

  2. Посчитать суммарное количество объектов в heap.

  3. Умножить на 4 байта — это экономия только на заголовках.

  4. Разделить на текущий committed heap — получится нижняя граница экономии в процентах. Реальная цифра будет чуть выше за счёт выравнивания.

Параллельно — A/B на staging: один pod с -XX:+UseCompactObjectHeaders, второй без, под одинаковой нагрузкой сравниваете committed heap, RSS (Resident Set Size — объём физической памяти, реально занятой процессом; именно его видит OOM-killer и kubectl top pod), GC pause, throughput.


10. Флаги JVM: сейчас и после JDK 27

JDK 24 (experimental):

java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders -jar app.jar

JDK 25–26 (product, opt-in):

java -XX:+UseCompactObjectHeaders -jar app.jar

JDK 27+ (план JEP 534):

# compact headers без флагов
java -jar app.jar

# явный откат на legacy layout
java -XX:-UseCompactObjectHeaders -jar app.jar

Проверить, что реально включено, можно так:

java -XX:+PrintFlagsFinal -version 2>&1 | findstr UseCompactObjectHeaders

На Linux/macOS вместо findstrgrep UseCompactObjectHeaders.

Если в JAVA_TOOL_OPTIONS или Helm chart когда-то прописали -XX:-UseCompactObjectHeaders «на всякий случай», после перехода на JDK 27 вы сами останетесь на старом layout, хотя мир уйдёт на новый. Такие сюрпризы я уже ловил с другими default-флагами GC.


Вывод

JEP 534 — финальный шаг внедрения compact object headers: с JDK 27 HotSpot по умолчанию кладёт в кучу 8-байтный заголовок вместо 12-байтного классического на типичной 64-bit конфигурации.

Для большинства корпоративных бэкендов это даёт 5–15% экономии heap. На allocation-heavy нагрузках с массой короткоживущих объектов открытые бенчмарки показывают 10–22%. На профилях, где доминируют очень мелкие объекты (DTO, узлы коллекций, обёртки примитивов), экономия может достигать ~30% — одновременно сжимается и сам заголовок, и часть объектов теряет padding из-за выравнивания. На сервисах, где heap забит большими byte[] или массивами, эффект минимальный — единицы процентов.

Для прикладного Java это редкий подарок: код не трогаем, флаги после миграции можно убрать, память и GC часто выигрывают на «объектных» сервисах. Цена — внимательная проверка агентов, JVM-опций в base image и профиля heap.

Уже на JDK 25 имеет смысл прогнать -XX:+UseCompactObjectHeaders на staging и замерить RSS до того, как default приедет сам в JDK 27.


Если Вам понравилась статья, то буду рад видеть в моем канале!


Успешных Вам Релизов!

Комментарии (7)


  1. Chuvilev_MM
    02.06.2026 16:01

    Интересный разбор. Тема с экономией кучи всегда актуальна, особенно когда проект начинает упираться в лимиты памяти. Сравнивать с .NET тут напрашивается само собой через структуры и Memory, но подход HotSpot с Compact Object Headers на уровне рантайма выглядит очень элегантно.

    У меня вопрос, есть ли уже наработки или замеры по тому, как это в реальности сказывается на времени пауз GC при высокой плотности объектов? Сильно ли проседает или, наоборот, выигрывает производительность?


    1. rurikovich Автор
      02.06.2026 16:01

      Спасибо, да тема интересная.

      По GC есть некоторые пробы, возможно как раз для одной из следующих статей материал будет. В теории должно выигрывать. Но да надо пробовать на реальном приложении и результаты могут удивить на некоторых профилях нагрузки.


      1. Chuvilev_MM
        02.06.2026 16:01

        Звучит как отличная тема для следующего поста, с удовольствием почитаю. Буду ждать тестов на реальных профилях нагрузки, это всегда самое интересное


  1. yursdan
    02.06.2026 16:01

    Хороший обзор и полезное напоминание про вопросы экономии хипа в новых JVM, спасибо.

    Интересно, в дополнение к расходу памяти для обычных/сжатых заголовков, кто-то уже делал с помощью JMH сравнение производительности чтения/записи в каких ни будь типовых структурах вроде HashMap/TreeSet/CHM/CSLM на относительно больших количествах - десятках/сотнях миллионов объектов? Очень любопытно, как сжатые заголовки влияют на производительность.


  1. tsypanov
    02.06.2026 16:01

    lock:2

    Правильно ли я понимаю, что в заголовке каждого объекта два байта выделяются под возможное использование в качестве монитора, даже если объект таковым никогда не станет?


    1. vektory79
      02.06.2026 16:01

      Это два бита, а не байта.

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


  1. vektory79
    02.06.2026 16:01

    Маленькое дополнение: коли уж сняли хип дамп, то посчитать выигрыш от компактных заголовков можно прям точно, а не оценочно. Через JOL