🤖
AI审核中

每个Java对象都在交“内存税”:JDK27如何用Compact Object Headers压缩整个堆

Java 24分钟 109浏览 0评论

很多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字节后,会出现三种情况:

  1. 对象头减少4字节,但填充增加4字节,最终大小不变;
  2. 对象头减少4字节,同时跨过一个对齐边界,最终减少8字节;
  3. 在不同字段布局、对象对齐参数下出现其他结果。

下面是在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的堆存活数据差异。是否能够真正减少节点内存,还取决于堆提交策略、-XmsAlwaysPreTouch、本地内存和容器限制。

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 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论