跳到主要内容
约 7 分钟3 张卡

压缩指针与对象对齐

压缩指针与对象对齐共同影响 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 后,能容纳的业务对象数量未必同比例增加。类指针是否仍压缩,则需要另外确认。

比较两组配置时,应同时验证对象密度与运行成本:

  1. 记录压缩选项的实际值,以及对象头方案。
  2. 用 JOL 比较核心对象与数组的实际布局。
  3. 在相同负载下比较 live set、分配率、GC CPU 和吞吐。live set 指回收后仍存活的对象总量。
  4. 将 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 字段顺序与对象大小​

9.3 对齐粒度与性能​

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。计算对象大小时,再结合对象头布局和对齐粒度测量。

11. 参考资料​