前段时间有个服务堆一直卡在 70% 上下,GC 日志看着也不算离谱,就是老年代慢慢往上爬。拿堆转储下来一看,占大头的不是哪个大集合,而是几百万个几乎一样的小对象——订单状态快照、事件包装、缓存条目。每个对象真正有意义的字段就几字节,对象头反而占了一半。
这种情况正好是紧凑对象头(Compact Object Headers)想解决的问题。它把每个对象固定开销那 12 个字节压到 8 个字节。听着不多,但对象基数一上来,账就不一样了。
我拿这个服务的一个典型对象做了组对照实验,结果和预想的不太一样,值得写下来。
对象头里到底装了些什么
普通对象的布局是这样的:对象头 + 实例字段 + 对齐填充。对象头本身又分两块,mark word 和 klass pointer。mark word 存哈希码、锁状态、GC 分代年龄;klass pointer 指向这个对象的类元数据。
在 64 位 JVM 上,mark word 一直是 8 字节。klass pointer 默认开启压缩指针(-XX:+UseCompressedClassPointers),占 4 字节,所以对象头是 12 字节。如果关掉压缩指针,klass 变回 8 字节,头就成 16 字节了。数组对象还要多 4 字节存长度,这个是单算的。
紧凑对象头的思路是把 mark word 和 klass pointer 挤进同一个 8 字节里。类指针只留必要的高位,其余信息压缩存放,需要时再解码。开与不开,对象头分别是 12 字节和 8 字节。
光看这个对比,很容易得出“每个对象省 4 字节”的结论。问题就在这里:真正落到堆里的节省,不是 4 字节。
先把开关打开,并确认它真的生效了
启动参数加一个 -XX:+UseCompactObjectHeaders。不同 JDK 版本对这个参数的门槛不一样,有的是实验性参数需要先 -XX:+UnlockExperimentalVMOptions,命令直接报错就加上;不报错说明当前版本已经是产品级参数。
加完参数一定要回头验证,别默认它已经生效了,尤其生产环境的启动脚本往往被好几层包裹,参数很容易加在一个不生效的位置:
java -XX:+UseCompactObjectHeaders -XX:+PrintFlagsFinal -version
| grep -i compactobjectheaders
输出里看到 bool UseCompactObjectHeaders = true 才算数。如果显示 false,多半是参数被后面的启动脚本覆盖了,或者 JDK 版本压根不认识这个参数(这种情况一般会直接报 unrecognized VM option,反而好排查)。
写个程序,把省下的字节量出来
与其拿着对象布局规则纸上推演,不如直接跑一遍。下面这个程序对三种典型形态的对象各分配 500 万个,用堆占用的差值除以对象数,得出单对象平均大小。
import java.util.ArrayList;
import java.util.List;
public class HeaderCost {
static final class Point {
int x;
int y;
}
static final class Order {
long id;
int amount;
String sku;
}
static final class Tag {
boolean flagged;
}
public static void main(String[] args) {
measure("Point", () -> new Point());
measure("Order", () -> new Order());
measure("Tag", () -> new Tag());
}
interface Maker {
Object make();
}
static void measure(String name, Maker maker) {
int n = 5_000_000;
Object[] keep = new Object[n];
// 先把数组本身的开销算进基线,避免污染测量结果
long base = used();
for (int i = 0; i < n; i++) {
keep[i] = maker.make();
}
long after = used();
double per = (after - base) * 1.0 / n;
System.out.printf("%-6s 每个对象约 %5.2f 字节%n", name, per);
keep = null;
}
static long used() {
Runtime rt = Runtime.getRuntime();
for (int i = 0; i < 4; i++) {
System.gc();
}
return rt.totalMemory() - rt.freeMemory();
}
}
几点说明。Object[] 在测基线之前就分配好了,所以要减的那部分只有对象本身。每轮结束把 keep 置空,下一轮从干净的堆开始。System.gc() 连调几次是因为一次 Full GC 未必能把所有软引用、finalizer 队列清干净,多来几次读数更稳。
这个测法谈不上精确,数字会有零点几字节的抖动,但用来判断“省没省、省了多少”完全够。真要精确到字节,用 JOL 的 GraphLayout.parseInstance(obj).totalSize() 更靠谱,只是需要引一个外部依赖。
实测结果:省下的字节去哪了
关闭紧凑对象头、开启压缩指针的情况下,我的输出大概是这样的:
Point 每个对象约 24.00 字节
Order 每个对象约 32.00 字节
Tag 每个对象约 16.00 字节
加上 -XX:+UseCompactObjectHeaders 之后:
Point 每个对象约 16.00 字节
Order 每个对象约 24.00 字节
Tag 每个对象约 16.00 字节
看出来了吗。Point 省了 8 字节,Order 省了 8 字节,Tag 一字节都没省。
原因是对齐规则。JVM 要求对象总大小是 8 的倍数,不足的补填充字节。对象头从 12 变成 8,确实少了 4 字节,但这 4 字节会连同后面的数据一起去凑 8 的倍数,最后省下来的是 8、0 还是 4,完全取决于你的字段布局刚好卡在哪。
拿 Point 来说。它有两个 int,一共 8 字节字段。原来 12 + 8 = 20,不对齐,补到 24。现在 8 + 8 = 16,正好是 8 的倍数,一个填充字节都不用补,直接从 24 掉到 16。
Order 那个更典型。它有一个 long,而 long 需要 8 字节对齐。原来的头是 12 字节,long 从偏移 12 开始放不下,只能先跳到偏移 16,中间白白空出 4 个字节,加上 int 和引用,最后凑到 32。头变成 8 字节之后,long 从偏移 8 开始,严丝合缝,整个对象落到 24。
Tag 就没什么可说的。一个 boolean,原来 12 + 1 = 13,补到 16;现在 8 + 1 = 9,还是补到 16。省下的 4 字节全被填充吃掉了。
所以那句“每个对象省 4 字节”不能直接拿来估算收益,一个对象实际省多少,得看它补齐对齐之后落在哪一档。粗略经验是:省 0、4、8 字节都可能,字段越少、越接近对齐边界的对象越容易吃到便宜。
什么情况下这个开关值得开
判断标准其实很简单:堆里小对象的数量够不够多。
像前面那个服务,几百万个对象、每个平均省 6 到 8 字节,堆占用能降一两个百分点,听着不起眼,但在堆接近上限、GC 频率敏感的场景里,这点余量可能就是每周少一次 Full GC 和每周多一次的区别。而且对象变小之后,一次 Young GC 能容纳的对象更多,存活对象搬运的拷贝量也跟着下降。
反过来,如果堆里主要是大数组、大缓冲、长字符串,对象数量本来就不多,头从 12 变 8 对总占用的影响微乎其微,没必要为了这个开关去折腾发布流程。
还有一种收益容易被忽略:拥有大量引用字段的对象图。类指针压缩到对象头之后,对指针密度的改善在某些访问模式上有额外的好处,特别是遍历大量分散小对象的场景。不过这块提升不好量化,还是以自己压测数据为准。
开之前必须先排查的几处
直接操作对象内存的代码。 老项目里如果有用 Unsafe 算字段偏移、读 mark word、或者靠 objectFieldOffset 做手写序列化的地方,对象头布局一变,这些代码全部失效,而且往往不是启动报错,是跑到某个边界条件上才崩。上线前全局搜一遍 Unsafe、sun.misc、jdk.internal.misc。
JNI 和原生库。 C 代码里直接摸 oop 结构、按固定偏移读 klass 指针的,同样会出问题。这类问题排查成本很高,因为栈里看到的是一段 native 地址。
序列化框架的自定义实现。 主流的序列化库早就适配了,但自己写的、或者很久没升级的老版本,可能会按对象头大小推算内存布局,从而算错偏移。跑一遍完整的回归用例比较稳妥。
堆快照分析工具和 profiler。 老版本的 MAT、JProfiler、async-profiler 可能不认识新的对象头格式,打开转储文件时字段显示错乱,甚至直接解析失败。要么升级工具,要么至少保证分析时用的工具版本一致,不然前后两组数据没法对比。
锁和哈希码相关的极端场景。 对象头是 mark word 的家,布局变了之后,高度竞争下的锁膨胀路径和哈希码存储策略都有变化。正常业务代码感知不到,但如果服务里有大量基于对象监视器的细粒度锁,压测时多盯一眼锁竞争指标。
另外提一句,不同 JDK 版本对这个特性的支持程度不一样,容器镜像、测试环境、生产环境的 JDK 版本一定要对齐。否则本地测出堆占用降了 2%,生产上看到的是另一回事,会浪费很多时间互相怀疑。
上线时的推进顺序
我的建议是分四步走,别一次全量。
第一步,挑一个流量稳定、堆压力明显、对象数量多的服务作为试点。先不动代码,只加启动参数。
第二步,用同一份堆转储对比前后。注意要保证两次采样点接近——同样的时间窗、同样的请求量、同样刚做完一次 Full GC。不然老年代的自然增长会把收益抹平,看起来像是没效果。
第三步,压测看延迟和吞吐。堆占用降了但吞吐掉了的情况虽然少见,但确实存在,尤其是那些极度依赖对象内存布局的访问模式。数字要以压测为准,理论上的收益不能拿来交差。
第四步,灰度发布,观察 GC 日志里的 GC 次数、单次停顿、堆占用曲线这几项,稳定后再扩量。
结尾补一句
紧凑对象头是个挺实在的优化,但它属于那种“收益取决于数据形状”的改动。别人嘴里省 20% 的场景,换到你的对象模型上可能只有 2%。所以别拿别人的数字做决策,花二十分钟把上面那个测量程序改改,把你的业务对象放进去跑一遍,比看十篇分析都管用。
顺带说一句,这个特性真正有意思的地方在于后面。对象头一旦压缩,JVM 在对象布局上就有了更多腾挪空间,一些以前受限于头部开销的设计会变得可行。至于具体会演化成什么样,还得看后续版本,现在能做的就是把开关跑通、把数据量出来。

