Привет, Хабр!
Каждый 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 |
Статус |
|---|---|---|
24 |
Experimental, под |
|
25 |
Product feature, opt-in через |
|
27 (targeted) |
Включено по умолчанию, откат — |
Прикладной 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, узлы коллекций, обёртки |
24 B |
4–8 B |
~17–33% |
Типовой Java-сервис |
40–48 B |
~4 B |
~8–11% |
Средне-крупные объекты |
80 B |
~4 B |
~5% |
Доминируют массивы / |
200+ B |
~4 B |
~2% и меньше |
Что это значит на конкретных размерах heap:
|
Микросервис (~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
Оценить экономию можно за четыре шага:
Из гистограммы взять топ-классы по числу инстансов.
Посчитать суммарное количество объектов в heap.
Умножить на 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 вместо findstr — grep 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)

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

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

vektory79
02.06.2026 16:01Это два бита, а не байта.
Ну и да, в статье всё несколько упрощено. Насколько помню - назначение этих битов может немного меняться, в зависимости от ситуации.
Chuvilev_MM
Интересный разбор. Тема с экономией кучи всегда актуальна, особенно когда проект начинает упираться в лимиты памяти. Сравнивать с .NET тут напрашивается само собой через структуры и Memory, но подход HotSpot с Compact Object Headers на уровне рантайма выглядит очень элегантно.
У меня вопрос, есть ли уже наработки или замеры по тому, как это в реальности сказывается на времени пауз GC при высокой плотности объектов? Сильно ли проседает или, наоборот, выигрывает производительность?
rurikovich Автор
Спасибо, да тема интересная.
По GC есть некоторые пробы, возможно как раз для одной из следующих статей материал будет. В теории должно выигрывать. Но да надо пробовать на реальном приложении и результаты могут удивить на некоторых профилях нагрузки.
Chuvilev_MM
Звучит как отличная тема для следующего поста, с удовольствием почитаю. Буду ждать тестов на реальных профилях нагрузки, это всегда самое интересное