深入理解 JVM 指针压缩

2,339 阅读14分钟

前言

在深入解析之前,会解释为什么 JVM 在 32 位操作系统上最多只能利用 4G 内存,以及 Java 对象内存布局。
指针压缩其实是 jvm 在 64 位操作系统上的的一种性能优化,了解这些基础知识有助于指针压缩的理解。
接下来开始进入主题。

为什么 JVM 在 32 位操作系统上最多只能利用 4G 内存?

先说下关于内存的小知识:
1、bit(位) 是内存的最小存储单位,1bit 可以表示为 0 或者 1。
2、byte(比特) 是内存基本计量单位,1byte = 8bit,1byte 表示范围 00000000 ~ 11111111,对应十进制是 255。
3、32 位操作系统内存地址大小是 4byte,内存寻址范围是 00000000 00000000 00000000 00000000 ~ 111111111 11111111 111111111
4、内存地址通常用十六进制表示,十六进制表示寻址范围是 0x00000000 ~ 0xffffffff

有了上面这些信息,为什么 32 位操作系统最多只能利用 4g 内存就是个算数问题了。 内存寻址范围是 32 个 0 和 1 的排列组合,也就是 232,总内存地址数就是 4294967296。
内存的基本计量单位是 1byte,内存大小的计算公式:内存地址个数 * 内存基本计量单位,也就是 232 * 1byte = 4294967296byte = 4g。

所以,32 位内存寻址决定了在 32 位操作系统最多只能用 4g 内存。

接下来看下 jvm 中内存地址是啥样的,下图是使用org.openjdk.jol工具打印的对象布局信息。 WechatIMG90.png 可以看到图中 类型指针内存地址 就是一个内存地址,它的地址是:20 0c 06 00(二进制形式展示为:00100000 00001100 00000110 00000000)。

指针压缩之缘起 - Java 对象内存布局(Java Object Layout)

网上和书中有非常多关于对象内存结构的理论知识,我就不在文章中墨迹了,直接上代码。

我的环境信息是这样的:
maxOS Monterey 12.4 (64 位操作系统)
java version "1.8.0_291"

以下的实践代码会使用org.openjdk.jol工具包。
Maven 依赖如下:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.13</version>
</dependency>

1、实例对象的内存布局
首先,准备一个 SimpleObject 类。

class SimpleObject {
  int state;
  String stateName;
}

使用以下语句打印 SimpleObject 实例对象的内存布局:

SimpleObject so = new SimpleObject();
so.state = 1;
so.stateName = "normal";
System.out.println(ClassLayout.parseInstance(so).toPrintable());

执行完之后打印出以下的信息:

SimpleObject object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           f0 97 07 00 (11110000 10010111 00000111 00000000) (497648)
     12     4                int SimpleObject.state                        1
     16     4   java.lang.String SimpleObject.stateName                    (object)
     20     4                    (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

SimpleObject 实例内存布局解读:
OFFSET 0~4 前两行是实例对象对象头,占用 8 bytes,主要存储对象的 hashCode、锁和分带年龄啥的(这个跟指针压缩关系不大,就不深入解读了。感兴趣的可以看 Markword 源码)。
OFFSET 8 这一行是实例对象的类型指针地址,占用 4bytes,这个实例的类型指针指向的就是 SimpleObject Class 的内存地址。
OFFSET 12~16 这两行就是 statestateName 两个变量的实例数据,占用 8 bytes。state 的值是 1,stateName 指向一个 String 对象内存地址。
OFFSET 20 这 4bytes 对齐填充是为了保证内存地址始终保值在指定的内存边界上,Hotspot 默认对齐至 8bytes 边界上,这个值也可以通过 -XX:ObjectAlignmentInBytes 对齐参数进行配置(关于数据结构对齐,大家可以看下这个:数据结构对齐)。

2、class 的内存布局
还是基于 SimpleObject 类。执行下面的语句:

System.out.println(ClassLayout.parseClass(SimpleObject.class).toPrintable());

执行完,将会得到以下结果:

SimpleObject object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0    12                    (object header)                           N/A
     12     4                int SimpleObject.state                        N/A
     16     4   java.lang.String SimpleObject.stateName                    N/A
     20     4                    (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

从结果上来看,class 和实例内存布局差不多,主要有两点差异,对象头由原来的三部分合并成了一个,所有 OFFSET 对应的 VALUE 都是 N/A
对象头合并成了一个,是因为对象头包含两个部分:Markword 和类型指针,因为 Markword 和类型指针都没有值,所以展示的时候合并成了一个。

所有 OFFSET 的 VALUE 都是 N/A,这是因为 class 是用来定义描述类信息的,不存储实例数据,可以把 class 想象成一个模板,实例对象是根据这个模板生成的。

再来看下 SimpleObject class 都定义了什么:
OFFSET 0,占用 12 bytes,这一行表示的是对象头,对象头又包括 Markword 和 类型指针。
OFFSET 12~16,占用 8 字节,表示这个类有两个字段,分别是 state 和 stateName,每个字段又分别占用 4bytes。
OFFSET 20,占用 4bytes,对齐填充。

所有根据 SimpleObject class 创建的实例对象都会有这三部分,class 对应的对象实例总内存大小都是 24bytes

3、数组对象的内存布局
数据打印出来的内存布局会比对象多一个数组长度信息,仅此而已,大家可以自己去打印出来看看。

4、再看下对齐填充
对齐填充不总是出现在对象的最后,也可能出现在字段后,我们把 int 类型 state 改成 byte 类型,下面是调整完之后的类:

class SimpleObject {
    byte state;
    String stateName;
}

接下来打印 class 内存布局:

System.out.println(ClassLayout.parseClass(SimpleObject.class).toPrintable());

打印得到的信息:

SimpleObject object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0    12                    (object header)                           N/A
     12     1               byte SimpleObject.state                        N/A
     13     3                    (alignment/padding gap)                  
     16     4   java.lang.String SimpleObject.stateName                    N/A
     20     4                    (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

可以看到两个对齐,第一个是 state 字段后,另一个在对象的最后,在对象后面的对齐填充之前已经介绍过了,这里着重说一下字段对齐。
字段对齐是当一个类包含多个字段,并且当前字段不是最后一个字段,同时当前字段内存地址的结束位置不是 4bytes 边界时,JVM 会对当前字段进行对齐填充。(关闭指针压缩同时在 64bits 操作系统时,字段会对齐到 8bytes 边界上)

总结:
1、Java 对象内存布局分为大概分为三个部分:对象头、实例数据、对齐填充。

对象头:又分为两个部分,分别是 Markword 和 类型指针。
对齐填充:字段对齐 和 对象对齐。

字段对齐:字段对齐出现在字段后,在开启指针压缩时以 4bytes 对齐,关闭指针压缩时以 8 bytes 对齐。
对象对齐:对象对齐出现在内存的最后一块,将内存对齐到 8bytes 边界上。

3、对象头中的类型指针和对齐填充很重要,是实现指针压缩的关键。
4、建议大家看 数据结构对齐

指针压缩

官方链接(openjdk 和 oracle 内容都差不多,原文内容就不摘抄了,大家自己看吧):
openjdk:wiki.openjdk.org/display/Hot…
oracle:docs.oracle.com/javase/7/do…

我们重点看下官方提到的为什么需要指针压缩和指针压缩实现原理。

为什么需要指针压缩?
第一点: 所有应用在 64 位操作系统上运行要比 32 位操作系统运行内存大 1.5 倍,也就是原来需要 2g 内存运行的程序,现在需要 3g。
第二点: 内存的获取越来越容易,不能在突破 4g 内存限制之后无限扩大内存,因为 CPU 的吞吐量有限,垃圾回收的压力会增加。
以上就是关于为什么需要指针压缩的观点。

指针压缩实现原理:
采用 32 位操作系统的指针管理方式,让对象内存地址对齐到 8bytes 边界上。在操作内存前后增加编码和解码逻辑,实现 32bits 寻址范围地址支持 32g 内存的扩展。

先来看下 64bits 操作系统上开启和关闭指针压缩的差异,指针压缩到底做了什么。

指针压缩的两个参数配置:

 -XX:+UseCompressedClassPointers # 普通对象指针压缩,实例对象地址压缩
 -XX:+UseCompressedOops # 类型指针压缩,对象头中指向类型执行的地址

tips:这两个参数必须同开启或同时关闭,否则 jvm 关闭指针压缩。

开启指针压缩的 SimpleObject 实例对象内存布局

SimpleObject object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           f0 97 07 00 (11110000 10010111 00000111 00000000) (497648)
     12     4                int SimpleObject.state                        1
     16     4   java.lang.String SimpleObject.stateName                    (object)
     20     4                    (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

关闭指针压缩的 SimpleObject 实例对象内存布局

SimpleObject object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           e8 93 31 0f (11101000 10010011 00110001 00001111) (254907368)
     12     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
     16     4                int SimpleObject.state                        1
     17     4                    (alignment/padding gap)                  
     24     8   java.lang.String SimpleObject.stateName                    null
Instance size: 32 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

开启和关闭指针压缩的实例对象内存布局差异对比:

对象头差异: 关闭指针压缩后,内存比原来增加了 4bytes,增加的这部分主要是类型指针内存地址变大的,从原来的 4bytes 变成了 8bytes。
字段差异: 关闭指针压缩后,state 后增加了字段对齐填充,对齐填充大小是 4bytes。stateName 由原来的 4 bytes 增加到了 8bytes,这是因为 String 是一个实例对象,关闭指针压缩后,它的地址增加了 4 bytes。
对齐填充: 开启指针压缩前,因为对象大小不满足 8bytes 倍数要求,所以有 4bytes 对齐填充。关闭指针压缩后,内存对齐到了 8bytes 边界上,所有就不需要填充了。

结论SimpleObject 实例对象在开启指针压缩后节省了 8bytes。压缩的是对象头中的类型指针、引用类型变量内存地址,这个结论跟官方文档中描述的一样(其实,除了这两个点外,还有数组元素引用地址也会压缩,因为我们的例子中没有数组,所以没有展示)。这应该就是官方提到的 64 位操作系统运行内存会比 32 位大一倍的原因吧。

上面看完了指针压缩开启和关闭的差异,再来看下指针压缩的实现原理。

关于指针压缩,官方提到,在 64bits 操作系统上实现 32bits 操作系统一样的指针管理方式,内存可以实现从 4g 到 32g 的提升。

这是咋实现的?其实官方已经把实现原理说的很明白了,指针压缩有两个过程,分别是编码和解码。编码时候将内存地址右移三位,解码时将内存地址左移三位。
解码是在操作内存前,把原来地址左移三位,得到真实的内存地址。编码是指获得到内存地址后,右移三位,得到压缩后的内存地址。

至于为什么要对内存地址进行左右位移操作,是因为内存进行 8bytes 边界对齐后,内存地址的后三位都是 0,根据这个规则,就可以在存储的时候舍弃后面的三位,读取的时候再加上这三位。

下面是 8 和它的倍数对应的二进制关系:

  8 =    1000
 16 =   10000
 24 =   11000
 32 =  100000
 40 =  101000
 48 =  110000
 56 =  111000
 64 = 1000000
 72 = 1001000

可以看到,在二进制下,8 和它的倍数的后三位都是 0。因为三位都是 0 所以可以在拿到地址后舍弃后三位,读取的时候加上后三位。

为了验证一下,我准备了三个对象,分别是 SimpleObjectASimpleObjectBSimpleObjectC,然后,每个对象都只有一个 state 字段。

class SimpleObjectA {
  int state;
}

class SimpleObjectB {
  int state;
}

class SimpleObjectC {
  int state;
}

然后,创建三个实例对象并打印内存布局:

SimpleObjectA a = new SimpleObjectA();
SimpleObjectB b = new SimpleObjectB();
SimpleObjectC c = new SimpleObjectC();
System.out.println(ClassLayout.parseInstance(a).toPrintable());
System.out.println(ClassLayout.parseInstance(b).toPrintable());
System.out.println(ClassLayout.parseInstance(c).toPrintable());

完整的内存布局我就不完全复制出来了,就只看类型指针部分内存地址:
a 变量指向的 class 地址:20 0a 06 00 (00100000 00001010 00000110 00000000),对应十进制:537527808
b 变量指向的 class 地址:18 0c 06 00 (00011000 00001100 00000110 00000000),对应十进制:403441152
c 变量指向的 class 地址:28 10 06 00 (00101000 00010000 00000110 00000000),对应十进制:672138752

可以看到,在 64 位操作系统上,开启指针压缩,得到的内存地址都是 8 的倍数,且没有后面的三个 0。

到这里,原理就研究的差不多了,还有最后一个问:指针压缩是怎么通过 32bits 内存寻址范围现了支持 32g 内存的,为什么在 32bits 操作系统时不可以呢?

因为 32 位操作系统的内存地址是 32 位,地址右移三位后就变成 35 位了,35 位超过了 32 位操作系统的最大寻址范围,所以在 32bits 操作系统时不能支持 32g。

至于为什么 32 位内存地址为什么可以支持 32g 内存,是因为 2^35 = 32g。

总结

指针压缩是一种性能优化,主要为了解决同样的应用在 64 位操作系统运行内存被扩大 1.5 倍,以及突破 4g 内存后,显著增加的内存给 CPU 和 垃圾回来带来的压力。

指针压缩的实现原理是:在 64 位操作系统上,保持 32 位操作系统的数据结构大小和指针大小。让指针对齐到 8bytes 边界上,在读写内存时候对内存地址进行编码和解码,实现 32 位内存寻址支持 32g 内存的扩展。