很多Java应用出现内存压力后,团队首先想到的通常是排查内存泄漏、调整-Xmx、优化缓存,或者更换垃圾收集器。
但有一种内存开销既不是业务字段,也不是内存泄漏,却存在于几乎每一个Java对象中:
对象头。
一个对象只保存了几个简单字段,最终占用的内存却可能是字段本身的两倍甚至更多。单个对象多出几字节似乎无关紧要,可当堆中存在数千万个DTO、集合节点、缓存条目、字符串、代理对象和ORM实体时,这些固定开销就会被不断放大。
截至2026年9月2日,JDK 26仍是最新正式版本,JDK 25是当前长期支持版本;JDK 27计划于2026年9月完成最终发布,并已纳入JEP 534,将Compact Object Headers,也就是紧凑对象头,设为HotSpot JVM的默认对象布局。JDK 25和JDK 26已经可以使用这项能力,只是需要手动开启。(Oracle)
这项变化不要求开发者修改Java代码,也不会改变类的字段和业务逻辑,却可能同时影响:
- Java堆的存活数据量;
- 单位堆空间能够容纳的对象数量;
- Young GC和Old GC的触发频率;
- GC扫描、复制和整理对象的成本;
- CPU缓存命中率;
- 容器部署密度。
它看起来只是“对象头少了4字节”,实际上改变的是整个Java堆的空间组织方式。
一、一个Java对象到底由什么组成
从HotSpot JVM的角度看,一个普通Java对象通常可以拆成三部分:
对象大小 = 对象头 + 实例字段 + 对齐填充
对象头保存JVM管理对象所需要的元数据,实例字段保存业务数据,对齐填充则用于满足对象地址和对象大小的对齐要求。
在常见的64位HotSpot JVM中,启用压缩类指针后,传统普通对象头通常为12字节;未使用压缩类指针时可能达到16字节。Compact Object Headers会将普通对象头压缩到8字节。数组还需要额外保存一个4字节的长度字段。(Oracle Docs)
graph LR
subgraph OLD[传统对象布局]
A[Mark Word 8字节] --> B[Class Word 4字节]
B --> C[实例字段]
C --> D[对齐填充]
end
subgraph NEW[紧凑对象布局]
E[合并对象头 8字节] --> F[实例字段]
F --> G[对齐填充]
end
1. Mark Word
Mark Word属于对象自身的运行时状态。
它需要承载与对象相关的多种信息,例如:
- 对象的身份哈希值;
- GC年龄;
- 锁状态;
- 与对象同步相关的标记;
- 垃圾收集器使用的部分状态信息。
这些信息不一定会同时占据固定区域。HotSpot会根据对象当前状态,以不同方式解释Mark Word中的位。
2. Class Word
JVM仅仅拿到一段对象地址还不够,它必须知道这个对象属于哪个类。
Class Word保存指向类元数据的引用。JVM执行虚方法分派、字段访问、类型检查和垃圾回收时,都需要通过对象找到对应的类信息。
在启用压缩类指针的传统布局中,Class Word通常占4字节。
3. 数组长度
普通对象的字段布局可以从类元数据中获知,但数组实例的长度各不相同,因此每个数组对象还需要额外保存自身长度。
这也是为什么数组对象即使没有任何元素,仍然会占据一部分堆空间。
二、Compact Object Headers到底压缩了什么
紧凑对象头并不是直接删除Class Word。
如果完全删除类信息,JVM就无法仅根据对象地址判断对象类型。Compact Object Headers采用的方式,是重新设计对象头编码,将经过压缩的类信息与原来的Mark Word合并到一个64位结构中。
结果是:
| 对象布局 | 普通对象头 | 数组基础头部 |
|---|---|---|
| 传统压缩布局 | 12字节 | 16字节 |
| 紧凑对象头 | 8字节 | 12字节 |
| 理论差值 | 4字节 | 4字节 |
这里的“理论差值”非常重要。
它只表示对象头本身减少了4字节,不代表每一个对象的最终实例大小都一定减少4字节。对象最终仍然需要按照特定边界对齐,因此单个对象可能减少0字节,也可能直接减少8字节。
Compact Object Headers最早以JEP 450的形式在JDK 24中作为实验功能提供;到了JDK 25,JEP 519将其升级为正式产品功能,但仍默认关闭;JDK 27中的JEP 534则进一步将其设为默认布局。(Oracle)
| JDK版本 | 功能状态 | 默认状态 | 使用方式 |
|---|---|---|---|
| JDK 24 | 实验功能 | 关闭 | 需要解锁实验参数后开启 |
| JDK 25 | 产品功能 | 关闭 | 显式开启 |
| JDK 26 | 产品功能 | 关闭 | 显式开启 |
| JDK 27 | 默认对象布局 | 开启 | 必要时显式关闭 |
JDK 24的启动方式如下:
java \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCompactObjectHeaders \
-jar app.jar
JDK 25和JDK 26只需要:
java \
-XX:+UseCompactObjectHeaders \
-jar app.jar
JDK 27默认启用,出现兼容性或性能问题时可以回退:
java \
-XX:-UseCompactObjectHeaders \
-jar app.jar
三、少4字节,为什么可能最终少8字节
HotSpot通常要求对象大小按照8字节边界对齐。
假设某个对象计算后的实际大小是20字节,那么JVM通常会为它分配24字节;如果计算结果是25字节,则可能需要向上补齐到32字节。
因此,对象头从12字节缩小到8字节后,会出现三种情况:
- 对象头减少4字节,但填充增加4字节,最终大小不变;
- 对象头减少4字节,同时跨过一个对齐边界,最终减少8字节;
- 在不同字段布局、对象对齐参数下出现其他结果。
下面是在64位HotSpot、压缩对象引用、8字节对象对齐这一常见环境中的示意结果:
| 对象结构 | 传统布局 | 紧凑布局 | 最终节省 |
|---|---|---|---|
| 空对象 | 16字节 | 8字节 | 8字节 |
只有一个int字段 |
16字节 | 16字节 | 0字节 |
两个int字段 |
24字节 | 16字节 | 8字节 |
一个long和两个int |
32字节 | 24字节 | 8字节 |
| 大型字节数组 | 主要由数组内容决定 | 主要由数组内容决定 | 比例较低 |
这也解释了Oracle文档为什么使用“平均每个对象减少4字节”来描述这项功能,而不是承诺所有对象严格减少4字节。(Oracle Docs)
可以先用一个简单公式估算理论收益:
理论对象头节省量 ≈ 存活对象数量 × 4字节
假设一个Java服务在稳定状态下存在5000万个存活对象,那么仅按照平均4字节估算,对象头就可能减少约200,000,000字节,也就是约190.7MiB。
实际结果还会受到对象对齐、字段布局、数组比例、压缩对象引用、垃圾收集器和JDK构建版本影响,所以这个公式只能用于判断“是否值得测试”,不能直接作为生产容量规划结果。
四、对象变小后,影响的不只是堆占用
1. 同样的堆可以容纳更多对象
假设服务的-Xmx保持不变,对象平均尺寸下降后,相同堆容量可以容纳更多存活对象。
这意味着业务流量上涨时,应用可能更晚触及老年代压力,也可能降低因为存活集过大而频繁执行并发标记或Full GC的概率。
需要注意,这并不代表应该在开启参数后立即缩小-Xmx。正确顺序应该是先保持堆配置不变,观察存活集和GC变化,再评估是否调整堆容量。
2. GC需要处理的数据可能减少
垃圾收集器执行标记、扫描、复制和整理时,处理的核心对象仍然是堆中的存活对象。
当相同业务状态对应的对象占用更少空间时:
- GC需要扫描的堆页可能减少;
- Young GC复制的存活数据可能减少;
- 对象晋升需要的空间可能减少;
- G1 Region中的对象密度可能提高;
- 相同老年代容量能够保留更多业务对象。
这并不意味着GC时间一定按照内存节省比例同步下降,因为GC还受到引用图复杂度、卡表、写屏障、并发线程、对象年龄和分配速率影响。
3. CPU缓存局部性可能改善
对象越小,同一个CPU缓存行中能够容纳的对象或字段通常越多。
在需要遍历大量节点、实体、消息或集合元素的场景中,更高的对象密度可能减少缓存未命中和内存访问次数。
Oracle的GC调优文档也将更小的存活数据集、更高的部署密度以及更好的数据局部性列为紧凑对象头的主要收益。(Oracle Docs)
4. 官方数据不能直接等同于业务结果
JEP 519给出的代表性测试结果中,SPECjbb2015工作负载出现过约22%的堆空间下降和约8%的CPU时间下降。这里的数字证明这项优化具备生产价值,但不能理解为所有Java服务都能固定节省22%内存、提升8%性能。(OpenJDK)
一个对象数量很多的订单系统,和一个主要使用大型byte[]、直接内存或本地库的系统,收益可能完全不同。
Compact Object Headers更像一个值得进行A/B测试的JVM能力,而不是可以直接写入容量预算的固定折扣。
五、使用JOL观察真实对象布局
仅凭字段类型手工计算对象大小很容易出错,因为HotSpot可能重新排列字段,还要考虑继承关系、压缩指针和对象对齐。
OpenJDK提供了JOL,也就是Java Object Layout。它可以借助Unsafe、JVMTI和Serviceability Agent分析真实对象布局、对象图和内存占用。(GitHub)
在Maven项目中加入依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
Maven Central当前提供的jol-core版本为0.17。(Maven Central)
创建测试代码:
package com.example;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
public class ObjectHeaderLab {
private static final int COUNT = 1_000_000;
/**
* 防止测试对象在输出前被回收。
*/
private static volatile Object keepAlive;
static final class OrderLine {
private final long orderId;
private final int skuId;
private final int quantity;
OrderLine(long orderId, int skuId, int quantity) {
this.orderId = orderId;
this.skuId = skuId;
this.quantity = quantity;
}
}
public static void main(String[] args) {
OrderLine[] lines = new OrderLine[COUNT];
for (int i = 0; i < COUNT; i++) {
lines[i] = new OrderLine(i, i % 100_000, i % 10 + 1);
}
keepAlive = lines;
System.out.println("单个对象布局:");
System.out.println(
ClassLayout.parseInstance(lines[0]).toPrintable()
);
System.out.println("完整对象图占用:");
System.out.println(
GraphLayout.parseInstance((Object) lines).toFootprint()
);
}
}
先编译并复制依赖:
mvn -q package dependency:copy-dependencies
在JDK 25或JDK 26中,先运行传统布局:
java \
-Xms512m \
-Xmx512m \
-cp "target/classes:target/dependency/*" \
com.example.ObjectHeaderLab
再开启紧凑对象头:
java \
-Xms512m \
-Xmx512m \
-XX:+UseCompactObjectHeaders \
-cp "target/classes:target/dependency/*" \
com.example.ObjectHeaderLab
Windows环境需要将类路径中的冒号改为分号。
在常见配置下,测试结果可能接近:
| 测试项目 | 传统布局 | 紧凑布局 |
|---|---|---|
单个OrderLine |
32字节 | 24字节 |
| 100万个对象 | 约32MB | 约24MB |
| 对象数组加全部元素 | 约36MB | 约28MB |
| 整体节省比例 | — | 约22% |
这里并不是对象头直接减少了8字节,而是对象头减少4字节后,整个OrderLine从32字节对齐区间进入了24字节区间。
如果将类改成只有一个int字段,可能会发现对象头虽然缩小了,最终对象仍然是16字节。这正是对象对齐带来的差异。
六、不要只在微型示例中测试
JOL适合解释对象布局,却不能替代真实业务压测。
一个Spring Boot应用中可能同时存在:
- 业务DTO和数据库实体;
- HashMap节点;
- JSON解析产生的中间对象;
- 线程池任务;
- 动态代理;
- 字节码增强对象;
- 日志事件;
- 缓存条目;
- 网络缓冲区包装对象;
- 框架内部元数据。
不同对象类型的数量和生命周期差异很大。要判断紧凑对象头是否真正有价值,必须对完整应用进行对照测试。
第一步:确认当前JVM是否支持
可以查看参数是否存在以及默认值:
java \
-XX:+PrintFlagsFinal \
-version 2>&1 | grep UseCompactObjectHeaders
运行中的进程可以使用:
jcmd <pid> VM.flags
在JDK 25和JDK 26中,未手动开启时通常会看到该参数为关闭状态。
第二步:建立传统布局基线
基线环境应保持以下条件一致:
| 条件 | 要求 |
|---|---|
| JDK发行版 | 完全一致 |
| JDK补丁版本 | 完全一致 |
| GC类型 | 完全一致 |
-Xms和-Xmx |
完全一致 |
| 容器内存限制 | 完全一致 |
| CPU配额 | 完全一致 |
| 流量模型 | 尽量一致 |
| 数据规模 | 尽量一致 |
| 预热时间 | 完全一致 |
不能用一台2核机器上的传统布局,与另一台4核机器上的紧凑布局直接比较。
第三步:同时观察堆、GC、CPU和延迟
建议至少记录以下指标:
| 指标 | 观察目的 |
|---|---|
| Full GC后的存活堆 | 判断相同业务状态需要多少真实堆空间 |
| 对象数量 | 判断收益是否来自对象密集型负载 |
| 对象分配速率 | 判断是否减少分配压力 |
| Young GC次数 | 判断新生代回收频率是否变化 |
| Old GC或并发周期 | 判断老年代压力是否下降 |
| GC总耗时 | 判断回收成本是否改善 |
| P95和P99延迟 | 防止平均值掩盖尾延迟回退 |
| CPU使用率 | 判断更高缓存密度是否转化为收益 |
| 进程RSS | 判断操作系统看到的物理内存是否下降 |
| 容器Working Set | 判断实际部署密度是否能够提高 |
可以开启GC与安全点日志:
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags
也可以使用JFR采集一段完整负载:
jcmd <pid> JFR.start \
name=compact-header-test \
settings=profile \
duration=10m \
filename=compact-header-test.jfr
第四步:灰度而不是全量切换
graph LR
A[固定环境建立基线] --> B[少量实例开启参数]
B --> C[比较堆占用与对象数量]
C --> D[比较GC CPU和延迟]
D --> E{指标是否稳定}
E -->|是| F[逐步扩大灰度范围]
E -->|否| G[关闭参数并分析差异]
F --> H[重新评估堆与容器配置]
比较稳妥的上线顺序是:
先保持原有-Xmx和容器限制不变,只增加-XX:+UseCompactObjectHeaders;确认功能、延迟、CPU和GC没有回退后,再逐步调整堆大小或容器内存。
这样可以把“对象布局变化”和“容量配置变化”拆成两个独立变量。
如果同时开启紧凑对象头、缩小-Xmx、调整GC参数并升级框架,一旦出现延迟波动,就很难判断真正原因。
七、哪些Java应用收益更明显
1. 海量小对象系统
这是最典型的受益场景。
例如:
- 大量订单明细和商品对象;
- 规则引擎中的事实对象;
- 搜索和推荐系统中的特征对象;
- 图结构中的节点和边;
- 内存索引中的条目;
- ORM加载的大批实体;
- 消息消费过程中产生的DTO;
- 大量短生命周期的请求上下文。
对象越小,对象头占整个对象的比例通常越高。
一个只有8字节业务字段的对象,如果传统布局最终占24字节,那么对象头和对齐空间可能比业务字段本身还大。
2. JSON与序列化密集型服务
JSON解析经常产生大量字符串、数组、数字包装类、中间节点和业务DTO。
即使这些对象生命周期不长,它们仍然会消耗TLAB和Eden空间,并参与Young GC。对象变小后,同一个TLAB和Eden区能够容纳更多实例,可能降低内存补充和回收频率。
3. 本地缓存和集合密集型服务
一个缓存条目不只有Key和Value。
以基于哈希表的缓存为例,堆中还可能存在:
- Map节点;
- 链表或树节点;
- 过期时间对象;
- 统计对象;
- 包装器;
- 异步刷新任务;
- 引用队列节点。
紧凑对象头会同时作用于这些普通Java对象。
4. 高密度容器部署
假设单个实例只节省100MB,单看一个服务并不明显。
但如果同一节点部署20个实例,就可能对应约2GB的堆存活数据差异。是否能够真正减少节点内存,还取决于堆提交策略、-Xms、AlwaysPreTouch、本地内存和容器限制。
5. 收益可能较低的场景
以下场景不一定获得明显收益:
| 场景 | 原因 |
|---|---|
主要保存大型byte[] |
数组内容远大于对象头 |
| 大型数值数组计算 | 主要空间来自原始数据 |
| 大量使用直接内存 | 堆外主体不受对象头影响 |
| 主要压力来自Metaspace | 紧凑对象头优化的是Java堆 |
| 主要压力来自线程栈 | 线程栈不属于普通堆对象 |
| 对象数量少但单个对象很大 | 固定头部占比很低 |
| JNI或本地库占用高 | 本地分配不会因为对象头缩小 |
例如Netty的直接缓冲区仍然会有一个Java包装对象,因此包装对象本身可能缩小,但真正占用大量空间的本地缓冲区不会因此缩小。
八、为什么堆变小了,RSS却可能没有下降
这是生产测试中最容易产生误判的地方。
假设应用设置:
-Xms4g -Xmx4g
如果JVM启动时已经提交了接近4GB堆空间,那么Compact Object Headers降低的主要是堆中的实际存活数据量,而不是堆容量上限。
如果还启用了:
-XX:+AlwaysPreTouch
JVM会在启动阶段主动触碰堆页,操作系统可能较早为这些页面建立物理映射。此时即使存活对象从2GB下降到1.7GB,进程RSS也不一定立即同步下降。
因此需要区分三个概念:
| 概念 | 含义 |
|---|---|
| Heap Max | JVM允许使用的最大堆 |
| Heap Committed | JVM已经向操作系统提交的堆 |
| Heap Used或Live Set | 当前实际使用或GC后仍然存活的数据 |
Compact Object Headers首先影响的是第三项。
只有在验证存活集确实下降后,进一步调整-Xms、-Xmx、容器限制或GC提交策略,才可能把堆内收益转化为节点级内存收益。
九、它和Compressed Oops有什么区别
Compact Object Headers经常被误认为Compressed Oops的另一个名称,但两者解决的问题不同。
| 技术 | 主要压缩对象 | 影响位置 |
|---|---|---|
| Compressed Oops | Java对象之间的引用 | 实例字段和对象数组元素 |
| Compressed Class Pointers | 指向类元数据的指针 | 传统Class Word |
| Compact Object Headers | 整体对象头布局 | 每个普通Java对象的起始位置 |
Compressed Oops会尽可能使用32位偏移表示64位进程中的对象引用,从而减少引用字段和对象数组的空间。Oracle文档说明,压缩对象引用通常在适用堆范围内默认启用,并可能提高内存效率和性能。(Oracle Docs)
Compact Object Headers则进一步处理对象自身携带的固定元数据。
两者可以叠加产生效果:
- 引用字段更小;
- 对象头更小;
- 对象数组元素更小;
- 相同堆页中可以容纳更多对象。
这也是为什么不能只根据Java字段声明推算对象大小,必须结合实际JVM参数检查。
十、上线前需要关注的风险边界
1. 并非每个对象都会变小
由于对象对齐,有些对象最终大小不会变化。
如果应用主要由恰好落在相同对齐区间的对象组成,整体收益可能低于平均值。
2. 不会修复内存泄漏
Compact Object Headers只能让泄漏对象占用得少一些,不能让失去业务价值但仍被引用的对象自动释放。
如果缓存没有上限、监听器没有注销、ThreadLocal没有清理,内存仍然会持续增长。
3. 极端类加载场景需要验证
Oracle的JDK 26文档指出,启用Compact Object Headers后,单个JVM能够加载的不同类数量存在约400万个的上限。普通业务服务通常不会主动接近这一数量,但大量动态生成类、代理类、脚本类或租户隔离类加载器的平台,应将类数量纳入测试。(Oracle Docs)
可以通过类加载指标、JFR以及下面的命令观察:
jcmd <pid> VM.classloader_stats
4. 低层工具需要重新验证
正常Java业务代码不应该依赖对象头的具体位布局。
但部分性能工具、诊断代理、Unsafe代码、JVMTI扩展或自研JVM探针,可能对对象头偏移和对象布局存在隐含假设。
JDK升级测试不能只验证接口功能,还需要验证:
- APM探针;
- 热修复工具;
- 字节码增强框架;
- 性能分析器;
- 堆分析工具;
- 自研Java Agent;
- CDS或AOT相关构建流程。
5. CPU收益不是强制结果
对象更紧凑通常有利于缓存局部性,但新的对象头解码方式本身也存在实现成本。
某些工作负载可能明显改善,某些几乎没有变化,极少数边缘场景也可能出现回退。因此JDK 27仍然保留了关闭参数,允许应用恢复传统布局。
十一、一个更可靠的决策标准
不要问:
“Compact Object Headers能不能提升20%性能?”
应该问:
“我的应用中有多少存活对象,这些对象分别多大,对象头占整个存活集的比例是多少?”
可以按照下面的顺序判断:
| 问题 | 判断意义 |
|---|---|
| 堆中是否存在数百万到数千万个对象 | 判断固定头部是否值得优化 |
| 小对象是否占据主要存活集 | 判断对象头占比是否足够高 |
| GC是否受存活数据量影响 | 判断更小对象是否能降低回收压力 |
| 主要内存是否位于Java堆 | 排除直接内存、栈和Metaspace问题 |
| 是否能够进行同版本A/B测试 | 排除JDK升级带来的其他变量 |
| 是否具备快速关闭参数的能力 | 确保生产可回滚 |
| 节省的堆能否转化为更小容器 | 判断是否具有实际成本收益 |
对于对象密集型服务,JDK 25和JDK 26已经值得进行灰度验证。
对于准备升级JDK 27的团队,这项变化更需要被写入升级清单。因为它不再是“要不要主动开启一个优化参数”,而是“默认对象布局已经发生变化”。
十二、结语
Compact Object Headers最有价值的地方,不是提供了一条新的Java API,而是在不修改业务代码的情况下,降低每个对象必须承担的固定成本。
过去,一个普通对象通常先携带12字节对象头,再保存真正的业务字段;在JDK 27的默认布局下,对象头将被压缩到8字节。
单个对象减少的空间很小,但Java系统真正擅长的事情,恰恰是创建大量对象。
当数千万个对象同时缩小时,收益会沿着整个运行时链路向上传递:
对象更小,相同堆中能够保存更多数据;存活集更小,GC需要处理的数据可能减少;对象排列更紧密,CPU缓存局部性可能改善;单实例内存降低后,容器和服务器的部署密度才有机会进一步提高。
但它不是免费的性能承诺,也不是内存泄漏修复器。
正确的落地方式不是看到“最多节省22%”就立即缩小生产堆,而是保持环境不变,使用JOL理解对象布局,使用GC日志和JFR观察真实负载,通过灰度实例完成对照测试,最后再决定是否调整堆与容器配置。
Compact Object Headers真正改变的,不只是4字节。
它让JVM开始重新审视一个延续多年的问题:
一个Java对象为了被JVM管理,到底应该支付多少固定的内存成本。

评论区 0