重磅硬核 | 一文聊透对象在JVM中的内存布局(五)

r660926
发布于 2022-7-28 17:53
浏览
0收藏

6. 为什么要内存对齐


我们在了解了内存结构以及CPU读写内存的过程之后,现在我们回过头来讨论下本小节开头的问题:为什么要内存对齐?

 

下面笔者从三个方面来介绍下要进行内存对齐的原因:

 

速度


CPU读取数据的单位是根据word size来的,在64位处理器中word size = 8字节,所以CPU向内存读写数据的单位为8字节

 

在64位内存中,内存IO单位为8个字节,我们前边也提到内存结构中的存储器模块通常以64位为单位(8个字节)传输数据到存储控制器上或者从存储控制器传出数据。因为每次内存IO读取数据都是从数据所在具体的存储器模块中包含的这8个DRAM芯片中以相同的(RAM,CAS)依次读取一个字节,然后在存储控制器中聚合成8个字节返回给CPU。

重磅硬核 | 一文聊透对象在JVM中的内存布局(五)-鸿蒙开发者社区

 读取存储器模块数据.png


由于存储器模块中这种由8个DRAM芯片组成的物理存储结构的限制,内存读取数据只能是按照地址顺序8个字节的依次读取----8个字节8个字节地来读取数据。重磅硬核 | 一文聊透对象在JVM中的内存布局(五)-鸿蒙开发者社区

内存IO单位.png


 • 假设我们现在读取0x0000 - 0x0007这段连续内存地址上的8个字节。由于内存读取是按照8个字节为单位依次顺序读取的,而我们要读取的这段内存地址的起始地址是0(8的倍数),所以0x0000 - 0x0007中每个地址的坐标都是相同的(RAS,CAS)。所以他可以在8个DRAM芯片中通过相同的(RAS,CAS)一次性读取出来。

 

 • 如果我们现在读取0x0008 - 0x0015这段连续内存上的8个字节也是一样的,因为内存段起始地址为8(8的倍数),所以这段内存上的每个内存地址在DREAM芯片中的坐标地址(RAS,CAS)也是相同的,我们也可以一次性读取出来。


注意:0x0000 - 0x0007内存段中的坐标地址(RAS,CAS)与0x0008 - 0x0015内存段中的坐标地址(RAS,CAS)是不相同的。


 • 但如果我们现在读取0x0007 - 0x0014这段连续内存上的8个字节情况就不一样了,由于起始地址0x0007在DRAM芯片中的(RAS,CAS)与后边地址0x0008 - 0x0014的(RAS,CAS)不相同,所以CPU只能先从0x0000 - 0x0007读取8个字节出来先放入结果寄存器中并左移7个字节(目的是只获取0x0007),然后CPU在从0x0008 - 0x0015读取8个字节出来放入临时寄存器中并右移1个字节(目的是获取0x0008 - 0x0014)最后与结果寄存器或运算。最终得到0x0007 - 0x0014地址段上的8个字节。

 

从以上分析过程来看,当CPU访问内存对齐的地址时,比如0x00000x0008这两个起始地址都是对齐至8的倍数。CPU可以通过一次read transaction读取出来。

 

但是当CPU访问内存没有对齐的地址时,比如0x0007这个起始地址就没有对齐至8的倍数。CPU就需要两次read transaction才能将数据读取出来。

 

还记得笔者在小节开头提出的问题吗  ?"Java 虚拟机堆中对象的起始地址为什么需要对齐至 8的倍数?为什么不对齐至4的倍数或16的倍数或32的倍数呢?" 现在你能回答了吗???

 

原子性


CPU可以原子地操作一个对齐的word size memory。64位处理器中word size = 8字节

 

尽量分配在一个缓存行中


前边在介绍false sharding的时候我们提到目前主流处理器中的cache line大小为64字节,堆中对象的起始地址通过内存对齐至8的倍数,可以让对象尽可能的分配到一个缓存行中。一个内存起始地址未对齐的对象可能会跨缓存行存储,这样会导致CPU的执行效率慢2倍

 

其中对象中字段内存对齐的其中一个重要原因也是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。

 

另外在《2. 字段重排列》这一小节介绍的三种字段对齐规则,是保证在字段内存对齐的基础上使得实例数据区占用内存尽可能的小。

 

7. 压缩指针


在介绍完关于内存对齐的相关内容之后,我们来介绍下前边经常提到的压缩指针。可以通过JVM参数XX:+UseCompressedOops开启,当然默认是开启的。

 

在本小节内容开启之前,我们先来讨论一个问题,那就是为什么要使用压缩指针??

 

假设我们现在正在准备将32位系统切换到64位系统,起初我们可能会期望系统性能会立马得到提升,但现实情况可能并不是这样的。

 

在JVM中导致性能下降的最主要原因就是64位系统中的对象引用。在前边我们也提到过,64位系统中对象的引用以及类型指针占用64 bit也就是8个字节。

 

这就导致了在64位系统中的对象引用占用的内存空间是32位系统中的两倍大小,因此间接的导致了在64位系统中更多的内存消耗以及更频繁的GC发生,GC占用的CPU时间越多,那么我们的应用程序占用CPU的时间就越少。

 

另外一个就是对象的引用变大了,那么CPU可缓存的对象相对就少了,增加了对内存的访问。综合以上几点从而导致了系统性能的下降。

 

从另一方面来说,在64位系统中内存的寻址空间为2^48 = 256T,在现实情况中我们真的需要这么大的寻址空间吗??好像也没必要吧~~

 

于是我们就有了新的想法:那么我们是否应该切换回32位系统呢?

 

如果我们切换回32位系统,我们怎么解决在32位系统中拥有超过4G的内存寻址空间呢?因为现在4G的内存大小对于现在的应用来说明显是不够的。

 

我想以上的这些问题,也是当初JVM的开发者需要面对和解决的,当然他们也交出了非常完美的答卷,那就是使用压缩指针可以在64位系统中利用32位的对象引用获得超过4G的内存寻址空间。

 

7.1 压缩指针是如何做到的呢?


还记得之前我们在介绍对齐填充和内存对齐小节中提到的,在Java虚拟机堆中对象的起始地址必须对齐至8的倍数吗?

 

由于堆中对象的起始地址均是对齐至8的倍数,所以对象引用在开启压缩指针情况下的32位二进制的后三位始终是0(因为它们始终可以被8整除)。

 

既然JVM已经知道了这些对象的内存地址后三位始终是0,那么这些无意义的0就没必要在堆中继续存储。相反,我们可以利用存储0的这3位bit存储一些有意义的信息,这样我们就多出3位bit的寻址空间。

 

这样在存储的时候,JVM还是按照32位来存储,只不过后三位原本用来存储0的bit现在被我们用来存放有意义的地址空间信息。

 

当寻址的时候,JVM将这32位的对象引用左移3位(后三位补0)。这就导致了在开启压缩指针的情况下,我们原本32位的内存寻址空间一下变成了35位。可寻址的内存空间变为2^32 * 2^3 = 32G。

重磅硬核 | 一文聊透对象在JVM中的内存布局(五)-鸿蒙开发者社区

 压缩指针.png


这样一来,JVM虽然额外的执行了一些位运算但是极大的提高了寻址空间,并且将对象引用占用内存大小降低了一半,节省了大量空间。况且这些位运算对于CPU来说是非常容易且轻量的操作

 

通过压缩指针的原理我挖掘到了内存对齐的另一个重要原因就是通过内存对齐至8的倍数,我们可以在64位系统中使用压缩指针通过32位的对象引用将寻址空间提升至32G.

 

从Java7开始,当maximum heap size小于32G的时候,压缩指针是默认开启的。但是当maximum heap size大于32G的时候,压缩指针就会关闭。

 

那么我们如何在压缩指针开启的情况下进一步扩大寻址空间呢???

 

7.2 如何进一步扩大寻址空间


前边提到我们在Java虚拟机堆中对象起始地址均需要对其至8的倍数,不过这个数值我们可以通过JVM参数-XX:ObjectAlignmentInBytes 来改变(默认值为8)。当然这个数值的必须是2的次幂,数值范围需要在8 - 256之间

 

正是因为对象地址对齐至8的倍数,才会多出3位bit让我们存储额外的地址信息,进而将4G的寻址空间提升至32G。

 

同样的道理,如果我们将ObjectAlignmentInBytes的数值设置为16呢?

 

对象地址均对齐至16的倍数,那么就会多出4位bit让我们存储额外的地址信息。寻址空间变为2^32 * 2^4 = 64G。

 

通过以上规律,我们就能知道,在64位系统中开启压缩指针的情况,寻址范围的计算公式:4G * ObjectAlignmentInBytes = 寻址范围

 

但是笔者并不建议大家贸然这样做,因为增大了ObjectAlignmentInBytes虽然能扩大寻址范围,但是这同时也可能增加了对象之间的字节填充,导致压缩指针没有达到原本节省空间的效果。

标签
已于2022-7-28 17:53:57修改
收藏
回复
举报
回复
    相关推荐