前言
在深入解析之前,会解释为什么 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工具打印的对象布局信息。
可以看到图中
类型指针内存地址 就是一个内存地址,它的地址是: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 这两行就是 state 和 stateName 两个变量的实例数据,占用 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 所以可以在拿到地址后舍弃后三位,读取的时候加上后三位。
为了验证一下,我准备了三个对象,分别是 SimpleObjectA、SimpleObjectB、SimpleObjectC,然后,每个对象都只有一个 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 内存的扩展。