Java 紧凑对象头实战:对象头从 12 字节到 8 字节,堆里到底省了什么

2026-10-28 0 234

前段时间有个服务堆一直卡在 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 在对象布局上就有了更多腾挪空间,一些以前受限于头部开销的设计会变得可行。至于具体会演化成什么样,还得看后续版本,现在能做的就是把开关跑通、把数据量出来。

Java 紧凑对象头实战:对象头从 12 字节到 8 字节,堆里到底省了什么
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java 紧凑对象头实战:对象头从 12 字节到 8 字节,堆里到底省了什么 https://www.taomawang.com/server/java/2976.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务