Привет, Хабр!
Каждый 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.
Если Вам понравилась статья, то буду рад видеть в моем канале!
Успешных Вам Релизов!
