Skip to content
Go back

JVM指针压缩:32位地址访问32GB堆的魔法

JVM指针压缩:32位地址访问32GB堆的魔法

一句话结论(30s)

指针压缩的本质是”借用对象 8 字节对齐浪费的低 3 位”——编码时把 64 位地址右移 3 位存成 32 位、解码时左移 3 位恢复,因为低 3 位恒为 0 可被安全借出,所以 4 字节指针能寻址 2^32 × 8 = 32GB。关键设计是 Klass Pointer 从 8 字节压到 4 字节、对象头从 16 字节降到 12 字节,零基压缩时编码解码零开销。权衡在于堆一旦超过 32GB 压缩自动失效、引用退回 64 位,同样数据量内存要多占 30-40%。

核心原理(2min)

主流程:开启 UseCompressedOops(JDK 6u23+ 默认开启)后,HotSpot 把普通对象指针和 Klass Pointer 从 64 位压到 32 位,编码右移 3 位、解码左移 3 位,压缩值寻址能力 = 2^32 × 8 = 32GB。关键机制:对象 8 字节对齐保证地址低 3 位恒 0,堆基址对齐时用零基压缩(压缩值即堆内偏移),否则加偏移用非零基压缩;堆 >32GB 时压缩自动失效,静态字段与 JNI 全局引用因不在堆中而不压缩。

底层深入(5-10min)

如果从32位JDK升级到64位,你可能会惊讶地发现内存占用增加了将近50%。原因很简单:指针从32位变成了64位,每个引用多占了4字节。当堆中存着几百万个对象时,这多出来的开销积少成多。HotSpot的解决方案是指针压缩(Compressed OOPs,即Compressed Ordinary Object Pointers)

想一想:为什么 32 位升 64 位内存会涨近 50%?因为指针从 4 字节变 8 字节,堆里几百万个引用每个都多占 4 字节,积少成多;指针压缩就是想办法把引用再”压”回 4 字节。

64位对象头的布局

在64位HotSpot JVM中(未开启指针压缩时),一个Java对象的内存布局如下:

|  Mark Word (8字节)  |  Klass Pointer (8字节)  |  实例数据 ...  |  对齐填充 ...  |

Mark Word永远占8字节。Klass Pointer(指向类元数据的指针)在指针压缩关闭时也是8字节,所以对象头总共16字节。

开启指针压缩(-XX:+UseCompressedOops,JDK 6u23+默认开启)后:

|  Mark Word (8字节)  |  Klass Pointer (4字节)  |  实例数据 ...  |  对齐填充 ...  |

Klass Pointer压缩到了4字节,对象头变成12字节。顺便一提,-XX:+UseCompressedClassPointers控制的就是Klass Pointer的压缩,默认跟随CompressedOops。

压缩的底层原理:靠8字节对齐”借”出3位

核心问题是:4个字节只有32位,最大寻址空间是2^32 = 4GB。那怎么访问32GB堆呢?

答案是:对象在堆中是8字节对齐的

8字节对齐意味着每个对象的起始地址都是8的倍数——地址的二进制表示中,低3位永远是000(因为8 = 2^3):

地址示例(8字节对齐):
0x0000000012345600  →  二进制 ... 0000 0000  (低3位=000)
0x0000000012345608  →  二进制 ... 0000 1000  (低3位=000)
0x0000000012345610  →  二进制 ... 0001 0000  (低3位=000)

既然低3位永远是0,那就可以把它们”借”给编码用,解码时再补回来。

想一想:把低 3 位”借”出去,为什么不会有歧义?因为 8 字节对齐保证地址低 3 位恒为 0,存的时候右移 3 位丢掉的 0,在解码时左移 3 位补回,一进一出无损可逆——这就是”免费午餐”的数学依据。

编码(存储时):把64位地址右移3位,变成32位存储。

64位地址: 0x0000000012345608
右移3位:  0x0000000002468AC1  → 只取低32位存到4字节中

解码(使用时):把32位值左移3位,恢复64位地址。

32位压缩值: 0x02468AC1
左移3位:    0x0000000012345608  → 恢复了原始64位地址

右移3位等价于除以8,左移3位等价于乘以8。压缩后的32位指针,实际寻址能力 = 2^32 * 8 = 32GB。这就是32位压缩指针能访问32GB堆的数学原理。

文章里说的”右移 3 位 / 左移 3 位”,在 HotSpot 源码里就是 compressedOops.inline.hpp 的这对编码解码函数:

// 源码:src/hotspot/share/oops/compressedOops.inline.hpp
inline oop CompressedOops::decode_raw(narrowOop v) {
  return cast_to_oop((uintptr_t)base() + ((uintptr_t)v << shift()));
}

inline narrowOop CompressedOops::encode_not_null(oop v) {
  assert(!is_null(v), "oop value can never be zero");
  assert(is_object_aligned(v), "address not aligned: " PTR_FORMAT, p2i(v));
  assert(is_in(v), "address not in heap range: " PTR_FORMAT, p2i(v));
  uint64_t  pd = (uint64_t)(pointer_delta((void*)v, (void*)base(), 1));
  assert(OopEncodingHeapMax > pd, "change encoding max if new encoding");
  narrowOop result = narrow_oop_cast(pd >> shift());
  assert(decode_raw(result) == v, "reversibility");
  return result;
}

shift() 就是文章说的”借出的低 3 位”——开启压缩时它等于 LogMinObjAlignmentInBytes(即 3)。解码 decode_rawbase() + (v << 3),即”左移 3 位补回低 3 位,再加重定位基址”;编码 encode_not_null(v - base) >> 3,即”减去基址后右移 3 位”。倒数第二行 assert(decode_raw(result) == v, "reversibility") 直接在源码里断言了编码/解码可逆,而 narrow_oop_cast 强制截断为 uint32_t 并断言不溢出——这行就是 32GB 上限(2^32 × 8)的硬边界所在。

想一想:32GB 这个上限到底从哪来?2^32 个压缩值,每个对应 8 字节的对齐步长,乘起来就是 32GB;narrow_oop_cast 把结果强制截断成 uint32_t 并断言不溢出,正是这条硬边界在源码里的体现。

超过32GB堆会发生什么

当堆大于32GB时,压缩指针不够用了(2^32 * 8 = 32GB是极限)。此时:

这就是为什么很多生产环境的建议是:“要么堆小于32GB并享受指针压缩的红利,要么堆远大于32GB(如48GB)让那多出来的内存值得放弃压缩带来的节省。”

想一想:为什么 32-48GB 之间最”不划算”?因为一超过 32GB 指针压缩就失效、引用退回 64 位,同样数据量多占 30-40% 内存却换不来对等的收益;与其卡在这个”尴尬区间”,不如直接上 48GB+,让多出来的内存真正值回放弃压缩的代价。

压缩的不只是普通对象指针

HotSpot对三类指针做了压缩:

零基压缩 vs 非零基压缩

如果堆的基址刚好落在某个对齐边界上,可以实现”零基压缩”——压缩值就是堆内偏移,编码解码最快。

如果堆的基址不理想(比如堆起始地址是0x00000005A0000000,不是8字节的整数倍),JVM会使用非零基压缩(加一个偏移量),这会多一次加法运算,性能略有损失。

JVM通常优先使用零基压缩,这也是为什么通过-Xms设置堆从0地址开始分配时性能最好的原因之一。

“零基 vs 非零基”不是玄学,HotSpot 用四档枚举把编码模式定义得明明白白:

// 源码:src/hotspot/share/oops/compressedOops.hpp
  // Narrow Oop encoding mode:
  // 0 - Use 32-bits oops without encoding when
  //     NarrowOopHeapBaseMin + heap_size < 4Gb
  // 1 - Use zero based compressed oops with encoding when
  //     NarrowOopHeapBaseMin + heap_size < 32Gb
  // 2 - Use compressed oops with disjoint heap base if
  //     base is 32G-aligned and base > 0. This allows certain
  //     optimizations in encoding/decoding.
  // 3 - Use compressed oops with heap base + encoding.
  enum Mode {
    UnscaledNarrowOop  = 0,
    ZeroBasedNarrowOop = 1,
    DisjointBaseNarrowOop = 2,
    HeapBasedNarrowOop = 3
  };

这四档正好对应零基与非零基的权衡:堆小于 4GB 时用 UnscaledNarrowOop(连位移都不用,32 位值就是地址);能落到基址 0 时用 ZeroBasedNarrowOopbase()==nullptr,压缩值就是堆内偏移,编码解码只剩位移、零额外加法,这正是”零基压缩”性能最好的原因);堆基址不理想时退到 DisjointBaseNarrowOopHeapBasedNarrowOop,编码解码都要多一次与 base() 的加减——也就是文章里说的”多一次加法运算”。_shift 字段的注释也印证了这一点:宽指针或无缩放窄指针时为 0,压缩时才为 LogMinObjAlignmentInBytes

实战考量

  1. 堆4-32GB:享受指针压缩红利,内存高效,性能好。
  2. 堆>32GB:指针压缩失效,每个引用多占4字节。同样的数据量需要更多内存,GC频率升高。除非业务真的需要这么大堆,否则32GB是一个性价比很高的”甜蜜点”。
  3. 验证指针压缩是否生效java -XX:+PrintFlagsFinal -version | grep UseCompressedOops。或用jinfo -flag UseCompressedOops <PID>查看运行中的JVM。

指针压缩是一个巧妙的”免费午餐”——利用对象对齐”浪费”的低3位来扩展寻址空间,在不改对象布局的前提下用4字节指针访问32GB堆。这是HotSpot团队最优雅的工程优化之一。

章末提问

追问 1:4 字节指针最多寻址 4GB,凭什么能访问 32GB 堆?

回答思路:结论先行——靠”对象 8 字节对齐低 3 位恒 0”这一特性,编码右移 3 位、解码左移 3 位。因为 8 = 2^3,对齐后地址低 3 位永远是 0,可以安全”借”给编码用;压缩值的寻址能力 = 2^32 × 8 = 32GB。源码里 decode_rawbase() + (v << 3)encode_not_null(v - base) >> 3,并用 assert(decode_raw(result) == v) 直接断言可逆。

追问 2:堆超过 32GB 会发生什么?为什么很多团队坚持堆不超过 32GB?

回答思路:结论先行——UseCompressedOops 自动失效、引用退回 64 位,同样数据量内存多占 30-40%。因为 2^32 × 8 = 32GB 是压缩指针的硬上限,一旦超限所有对象指针和 Klass Pointer 都退回 8 字节;32-48GB 区间多占的内存不值得,所以要么卡在 32GB 内吃压缩红利,要么直接上 48GB+ 让多出的内存物有所值。

追问 3:零基压缩和非零基压缩差在哪?为什么零基更快?

回答思路:结论先行——零基压缩时压缩值就是堆内偏移,编码解码只剩位移;非零基要多一次与 base 的加减。因为堆基址落在对齐边界(尤其 0 地址)时 base()==nullptr(v - base) >> 3 退化成纯右移;这就是 HotSpot 用 UnscaledNarrowOop/ZeroBasedNarrowOop/DisjointBaseNarrowOop/HeapBasedNarrowOop 四档枚举区分编码模式、并优先零基的原因。


Share this post on:

Previous Post
JVM三色标记与漏标问题
Next Post
JVM对象创建与生命周期:从new指令到GC回收