重磅硬核|一文聊透对象在JVM中的内存布局(二)
3. 对齐填充(Padding)
在前一小节关于实例数据区字段重排列的介绍中为了内存对齐而导致的字节填充不仅会出现在字段与字段之间,还会出现在对象与对象之间。
前边我们介绍了字段重排列需要遵循的三个重要规则,其中规则1,规则2定义了字段与字段之间的内存对齐规则。规则3定义的是对象字段之间的排列规则。
为了内存对齐的需要,对象头与字段之间,以及字段与字段之间需要填充一些不必要的字节。
比如前边提到的字段重排列的第一种情况-XX:+UseCompressedOops -XX -CompactFields。
image.png
而以上提到的四种情况都会在对象实例数据区的后边在填充4字节大小的空间,原因是除了需要满足字段与字段之间的内存对齐之外,还需要满足对象与对象之间的内存对齐。
Java 虚拟机堆中对象之间的内存地址需要对齐至8N(8的倍数),如果一个对象占用内存不到8N个字节,那么就必须在对象后填充一些不必要的字节对齐至8N个字节。
虚拟机中内存对齐的选项为-XX:ObjectAlignmentInBytes,默认为8。也就是说对象与对象之间的内存地址需要对齐至多少倍,是由这个JVM参数控制的。
我们还是以上边第一种情况为例说明:图中对象实际占用是44个字节,但是不是8的倍数,那么就需要再填充4个字节,内存对齐至48个字节。
以上这些为了内存对齐的目的而在字段与字段之间,对象与对象之间填充的不必要字节,我们就称之为对齐填充(Padding)。
4. 对齐填充的应用
在我们知道了对齐填充的概念之后,大家可能好奇了,为啥我们要进行对齐填充,是要解决什么问题吗?
那么就让我们带着这个问题,来接着听笔者往下聊~~
4.1 解决伪共享问题带来的对齐填充
除了以上介绍的两种对齐填充的场景(字段与字段之间,对象与对象之间),在JAVA中还有一种对齐填充的场景,那就是通过对齐填充的方式来解决False Sharing(伪共享)的问题。
在介绍False Sharing(伪共享)之前,笔者先来介绍下CPU读取内存中数据的方式。
4.1.1 CPU缓存
根据摩尔定律:芯片中的晶体管数量每隔18个月就会翻一番。导致CPU的性能和处理速度变得越来越快,而提升CPU的运行速度比提升内存的运行速度要容易和便宜的多,所以就导致了CPU与内存之间的速度差距越来越大。
为了弥补CPU与内存之间巨大的速度差异,提高CPU的处理效率和吞吐,于是人们引入了L1,L2,L3高速缓存集成到CPU中。当然还有L0也就是寄存器,寄存器离CPU最近,访问速度也最快,基本没有时延。
CPU缓存结构.png
一个CPU里面包含多个核心,我们在购买电脑的时候经常会看到这样的处理器配置,比如4核8线程。意思是这个CPU包含4个物理核心8个逻辑核心。4个物理核心表示在同一时间可以允许4个线程并行执行,8个逻辑核心表示处理器利用超线程的技术将一个物理核心模拟出了两个逻辑核心,一个物理核心在同一时间只会执行一个线程,而超线程芯片可以做到线程之间快速切换,当一个线程在访问内存的空隙,超线程芯片可以马上切换去执行另外一个线程。因为切换速度非常快,所以在效果上看到是8个线程在同时执行。
图中的CPU核心指的是物理核心。
从图中我们可以看到L1Cache是离CPU核心最近的高速缓存,紧接着就是L2Cache,L3Cache,内存。
离CPU核心越近的缓存访问速度也越快,造价也就越高,当然容量也就越小。
其中L1Cache和L2Cache是CPU物理核心私有的(注意:这里是物理核心不是逻辑核心)
而L3Cache是整个CPU所有物理核心共享的。
C
PU逻辑核心共享其所属物理核心的L1Cache和L2Cache
L1Cache
L1Cache离CPU是最近的,它的访问速度最快,容量也最小。
从图中我们看到L1Cache分为两个部分,分别是:Data Cache和Instruction Cache。它们一个是存储数据的,一个是存储代码指令的。
我们可以通过cd /sys/devices/system/cpu/来查看linux机器上的CPU信息。
image.png
在/sys/devices/system/cpu/目录里,我们可以看到CPU的核心数,当然这里指的是逻辑核心。
笔者机器上的处理器并没有使用超线程技术所以这里其实是4个物理核心。
下面我们进入其中一颗CPU核心(cpu0)中去看下L1Cache的情况:
CPU缓存的情况在/sys/devices/system/cpu/cpu0/cache目录下查看:
image.png
index0描述的是L1Cache中DataCache的情况:
image.png
• level:表示该cache信息属于哪一级,1表示L1Cache。
• type:表示属于L1Cache的DataCache。
• size:表示DataCache的大小为32K。
• shared_cpu_list:之前我们提到L1Cache和L2Cache是CPU物理核所私有的,而由物理核模拟出来的逻辑核是共享L1Cache和L2Cache的,/sys/devices/system/cpu/目录下描述的信息是逻辑核。shared_cpu_list描述的正是哪些逻辑核共享这个物理核。
index1描述的是L1Cache中Instruction Cache的情况:
image.png
我们看到L1Cache中的Instruction Cache大小也是32K。
L2Cache
L2Cache的信息存储在index2目录下:
image.png
L2Cache的大小为256K,比L1Cache要大些。
L3Cache
L3Cache的信息存储在index3目录下:
image.png
到这里我们可以看到L1Cache中的DataCache和InstructionCache大小一样都是32K而L2Cache的大小为256K,L3Cache的大小为6M。
当然这些数值在不同的CPU配置上会是不同的,但是总体上来说L1Cache的量级是几十KB,L2Cache的量级是几百KB,L3Cache的量级是几MB。
4.1.2 CPU缓存行
前边我们介绍了CPU的高速缓存结构,引入高速缓存的目的在于消除CPU与内存之间的速度差距,根据程序的局部性原理我们知道,CPU的高速缓存肯定是用来存放热点数据的。
程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则
不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。
那么在高速缓存中存取数据的基本单位又是什么呢??
事实上热点数据在CPU高速缓存中的存取并不是我们想象中的以单独的变量或者单独的指针为单位存取的。
CPU高速缓存中存取数据的基本单位叫做缓存行cache line。缓存行存取字节的大小为2的倍数,在不同的机器上,缓存行的大小范围在32字节到128字节之间。目前所有主流的处理器中缓存行的大小均为64字节(注意:这里的单位是字节)。
image.png
从图中我们可以看到L1Cache,L2Cache,L3Cache中缓存行的大小都是64字节。
这也就意味着每次CPU从内存中获取数据或者写入数据的大小为64个字节,即使你只读一个bit,CPU也会从内存中加载64字节数据进来。同样的道理,CPU从高速缓存中同步数据到内存也是按照64字节的单位来进行。
比如你访问一个long型数组,当CPU去加载数组中第一个元素时也会同时将后边的7个元素一起加载进缓存中。这样一来就加快了遍历数组的效率。
long类型在Java中占用8个字节,一个缓存行可以存放8个long型变量。
事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构,如果你的数据结构中的项在内存中不是彼此相邻的(比如:链表),这样就无法利用CPU缓存的优势。由于数据在内存中不是连续存放的,所以在这些数据结构中的每一个项都可能会出现缓存行未命中(程序局部性原理)的情况。
还记得我们在?《Reactor在Netty中的实现(创建篇)》中介绍Selector的创建时提到,Netty利用数组实现的自定义SelectedSelectionKeySet类型替换掉了JDK利用HashSet类型实现的sun.nio.ch.SelectorImpl#selectedKeys。目的就是利用CPU缓存的优势来提高IO活跃的SelectionKeys集合的遍历性能。