压缩指针与对象对齐
压缩指针与对象对齐共同影响 Java 对象的内存占用。本文以 Java 17、21 的 64 位 HotSpot 为范围,先说明引用编码与对齐,再分析集合和对象图的内存成本。单个对象多出几字节,放大到数千万个节点后,就会增加堆占用、缓存缺失和 GC 扫描工作。
1. 面试核心
先记住这几条,再读正文- 对象引用编码。 HotSpot 压缩对象指针用 32 位编码表示堆内引用,访问时结合堆基址与移位恢复地址,从而减少引用占用。卡 · 压缩对象指针
- 对齐与寻址。 对象大小按对齐粒度向上取整,默认的 8 字节对齐让 32 位编码理论上覆盖 32 GiB。
- 对象与类指针。 压缩对象指针表示堆内对象地址,压缩类指针表示类元数据地址,两者可以独立启用。卡 · 对象引用与类指针
- 浅大小与保留大小。 浅大小计算对象自身占用,保留大小还包含随该对象变为不可达而能一起释放的其他对象。卡 · 对象对齐
- 对象头基线。 Java 17、21 的 64 位 HotSpot 普通对象头在类指针压缩时占 12 字节。
2. 压缩对象指针
64 位地址需要 8 字节,引用很多的对象和数组会因此占用更多空间。压缩对象指针(Compressed Oops)用较窄的编码表示地址,以降低这部分成本。
开启 UseCompressedOops 时,堆内引用使用 4 字节;关闭后,普通对象引用使用 8 字节。需要访问对象时,JVM 再结合堆基址与移位恢复地址:
对象地址 = 堆基址 + (编码值 << 移位位数)
8 字节对齐意味着对象地址按 8 字节间隔排列,所以编码可以按 8 倍缩放,对应左移 3 位。这样,理论编码范围就是 2^32 × 8 = 32 GiB。
实际能否启用还受堆放置、保留空间和 JVM 实现策略影响。因此,32 GiB 是理解编码的理论范围,部署时仍要检查进程参数与启动日志。
查看 Java 17、21 的相关选项:
java -XX:+PrintFlagsFinal -version | rg 'UseCompressedOops|UseCompressedClassPointers|ObjectAlignmentInBytes'
这条命令显示当前 JVM 的选项值。验证部署配置时,需要带上实际使用的收集器和启动参数。ZGC 使用自己的引用编码,应按对应实现分析。
2.1 对象引用与类指针
评估对象大小时,要区分对象引用和类指针。对象引用指向堆中的另一个对象;类指针位于对象头中,指向描述所属类型的类元数据。
两者使用不同的编码范围和管理机制。因此,扩大堆导致压缩对象引用关闭后,类指针仍可保持压缩。Java 17、21 用 UseCompressedClassPointers 控制类指针压缩,引用数组的元素宽度则取决于对象引用方案。
3. 对象对齐
对象的实际占用还需要计入对齐产生的填充。默认 ObjectAlignmentInBytes=8 时,大小向 8 的倍数取整。因此,一个未经取整为 22 字节的布局,最终会占用 24 字节。
字段之间也会产生空洞。比如,下面的类同时包含不同宽度的字段:
final class Entry {
byte state;
long timestamp;
Object value;
}
HotSpot 可以重排字段,用较小字段填补内部空洞。因此,源码中的声明顺序无法直接给出最终偏移。继承层级、对象头方案和 JVM 布局算法都会影响结果,需要在目标 JVM 上测量。
增大对齐值可以扩大压缩引用的编码范围,但也会增加小对象的尾部填充。调整前应比较对象大小分布:小对象越多,多出的填充越容易抵消引用压缩的收益。
4. 浅大小与保留大小
评估一个对象的成本,要区分它自己占多大,以及它让多少其他对象继续存活。前者称为浅大小(shallow size),包括对象头、字段和填充。后者用**保留大小(retained size)**表示,包含它自身和随它变为不可达而能一起释放的对象。
比如,ArrayList 自身只保存数组引用等字段,浅大小很小,但内部数组和元素可以占用大量内存。若某个元素还被其他 GC Root 持有,它就不会随列表一起释放,也就不属于列表的保留集合。GC Root 是垃圾回收查找存活对象的起点。
JOL(Java Object Layout)用于测量对象布局,也可以统计可达对象图大小。可达图会计入共享对象,因此它与保留大小需要分开。堆转储中的 Dominator Tree(支配树)用于分析保留关系:到某个对象的所有根路径都经过同一对象时,后者支配前者。
5. 数组与集合节点的开销
估算数组或集合时,需要沿着引用继续计算内部结构。对象数组除了对象头和长度字段,还为每个元素保存引用;基本类型数组则直接保存值。
因此,同样数量的整数使用 int[] 和 Integer[],内存成本不同。后者既需要引用槽位,还需考虑装箱产生的 Integer 对象。若多个位置共享缓存中的同一个 Integer,该对象只计一次。
链表还为每个元素增加节点和链接引用。数组减少了这些节点,值或引用槽位连续排列也有利于 CPU 缓存复用,这称为访问局部性。选择时还要结合元素规模和操作模式,比较插入、删除、扩容以及元素引用是否需要保持稳定。
6. 字段类型与内存密度
内存优化应优先考虑数量多、保留时间长的记录。字段的值范围允许时,基本类型可以减少装箱对象和通过引用访问对象的开销:
record Metric(long timestamp, double value, int status) {}
这个 record 仍是普通对象,但字段直接保存基本类型的值,省去了包装对象。若每条记录都使用多个包装类型、字符串或 Map,就需要额外的对象和引用。
改用基本类型之前,要保留原有的值范围和可空语义。比如,包装类型的 null 表示“尚未采集”时,需要明确替代表示。先用堆直方图查看各类的实例数量和占用,再针对高占用、长期保留的结构评估收益。
7. 压缩指针失效后的容量变化
扩大堆导致对象引用从 4 字节变为 8 字节时,引用密集结构也会增大。因此,增加 -Xmx 后,能容纳的业务对象数量未必同比例增加。类指针是否仍压缩,则需要另外确认。
比较两组配置时,应同时验证对象密度与运行成本:
- 记录压缩选项的实际值,以及对象头方案。
- 用 JOL 比较核心对象与数组的实际布局。
- 在相同负载下比较 live set、分配率、GC CPU 和吞吐。live set 指回收后仍存活的对象总量。
- 将 RSS 与容器内存上限纳入容量评估。RSS 是进程实际驻留在物理内存中的页面总量。
这样才能判断新增堆空间带来了多少有效容量,以及 GC 和进程总内存付出了什么代价。
8. 对象头布局
计算浅大小时,要先确定对象头占用。Java 17、21 的 64 位 HotSpot 使用独立的标记字和类指针。标记字保存锁、哈希等状态,占 8 字节;压缩类指针占 4 字节,所以普通对象头共 12 字节。关闭类指针压缩后,对象头共 16 字节。
数组还需要 4 字节长度字段。最终对象大小需加上实例数据,再按对齐粒度取整。
9. 常见问题
9.1 Java 引用的字节数
在本文的 64 位 HotSpot 范围内,压缩的堆内对象引用占 4 字节,关闭压缩后占 8 字节。这个结论用于对象字段和对象数组中的引用槽位,实际状态应结合进程参数与 JOL 验证。
9.2 字段顺序与对象大小
仅调整源码顺序无法保证对象变小,因为 HotSpot 会按布局规则放置字段。修改后应重新测量实际偏移。若主要成本来自大量包装对象或集合节点,减少装箱和节点数量更值得优先验证。
9.3 对齐粒度与性能
增大对齐粒度可以扩大压缩编码范围,同时也增加尾部填充。因此,收益取决于对象大小分布和引用数量。保留默认值,再通过目标 JVM 上的布局测量和负载基准判断是否需要调整。
10 · 面试题卡片
10.1 压缩对象指针解决什么问题
出现公司:字节跳动
考察重点:解释压缩编码如何减少引用占用,以及对齐、堆放置和 JVM 配置怎样影响寻址范围。
相关内容:压缩对象指针和压缩指针失效后的容量变化。
参考回答
压缩对象指针减少对象字段和对象数组中引用占用的空间。
64 位 HotSpot 用 32 位编码表示对象地址,访问时再结合堆基址与移位恢复地址。这样也能让相同缓存空间容纳更多引用。
默认 8 字节对齐时,编码理论上覆盖 32 GiB,但实际能否启用还受堆放置与 JVM 配置影响。对象头中的类指针单独表示类元数据地址,压缩状态也需要分别检查。
10.2 怎样评估一个 Java 对象的真实内存成本
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分浅大小、可达图大小与保留大小,并解释实例数量如何放大单个对象的成本。
参考回答
评估对象成本,需要同时看对象自身的浅大小、它保留的对象图,以及同类实例的数量。
浅大小由对象头、字段和填充构成,集合还要考虑内部数组、节点和元素。
在目标 JVM 上用 JOL 测量布局,再用堆直方图确认数量,用 Dominator Tree 判断保留关系。共享对象即使能从当前对象到达,也未必由它保留,所以可达图大小需要单独解释。
优化优先针对数量大、保留时间长的结构,再通过 live set、分配率和 GC 结果验证收益。
10.3 扩大堆关闭压缩对象指针后,类指针也会变宽吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明对象引用和类指针指向什么,以及为何需要分别检查压缩状态。
相关内容:对象引用与类指针。
参考回答
关闭压缩对象指针后,类指针仍可以保持压缩。
对象引用受堆寻址范围约束,类指针则指向类元数据,两者使用不同的编码范围与管理机制。
因此,应分别检查 UseCompressedOops 和 UseCompressedClassPointers。计算对象大小时,再结合对象头布局和对齐粒度测量。