概叙
今天来讲些抽象的东西 -- 对象头,因为我在学习的过程中发现很多地方都关联到了对象头的知识点,例如JDK中的 synchronized锁优化 和 JVM 中对象年龄升级等等。
对象内存构成#
Java 中通过 new 关键字创建一个类的实例对象,对象存于内存的堆中并给其分配一个内存地址,那么是否想过如下这些问题:
- 这个实例对象是以怎样的形态存在内存中的?
- 一个Object对象在内存中占用多大?
- 对象中的属性是如何在内存中分配的?
在 JVM 中,Java对象保存在堆中时,由以下三部分组成:
- 对象头(object header):包括了关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息。Java对象和vm内部对象都有一个共同的对象头格式。
- 实例数据(Instance Data):主要是存放类的数据信息,父类的信息,对象字段属性信息。
- 对齐填充(Padding):为了字节对齐,填充的数据,不是必须的。
1.对象头#
我们可以在Hotspot官方文档中找到它的描述(下图)。从中可以发现,它是Java对象和虚拟机内部对象都有的共同格式,由两个字(计算机术语)组成。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
每个Java对象都包含一个对象头,对象头中包含了两类信息:
MarkWord
:在JVM中用markOopDesc
结构表示用于存储对象自身运行时的数据。比如:hashcode,GC分代年龄,锁状态标志,线程持有的锁,偏向线程Id,偏向时间戳等。在32位操作系统和64位操作系统中MarkWord
分别占用4B和8B大小的内存。类型指针
:JVM中的类型指针封装在klassOopDesc
结构中,类型指针指向了InstanceKclass对象
,Java类在JVM中是用InstanceKclass对象封装的,里边包含了Java类的元信息,比如:继承结构,方法,静态变量,构造函数等。- 在不开启指针压缩的情况下(-XX:-UseCompressedOops)。在32位操作系统和64位操作系统中类型指针分别占用4B和8B大小的内存。
- 在开启指针压缩的情况下(-XX:+UseCompressedOops)。在32位操作系统和64位操作系统中类型指针分别占用4B和4B大小的内存。
- 如果Java对象是一个数组类型的话,那么在数组对象的对象头中还会包含一个4B大小的用于记录数组长度的属性。
由于在对象头中用于记录数组长度大小的属性只占4B的内存,所以Java数组可以申请的最大长度为: 2^32
。
它里面提到了对象头由两个字组成,这两个字是什么呢?我们还是在上面的那个Hotspot官方文档中往上看,可以发现还有另外两个名词的定义解释,分别是 mark word 和 klass pointer。
从中可以发现对象头中那两个字:第一个字就是 mark word,第二个就是 klass pointer。
1.1 Mark Word#
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。我们打开openjdk的源码包,对应路径/openjdk/hotspot/src/share/vm/oops
,Mark Word对应到C++的代码markOop.hpp
,可以从注释中看到它们的组成,本文所有代码是基于Jdk1.8。
Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的
在64位JVM中是这么存的
虽然它们在不同位数的JVM中长度不一样,但是基本组成内容是一致的。
- 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
- biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
- 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是
-XX:MaxTenuringThreshold
选项最大值为15的原因。 - 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
- 偏向锁的线程ID(JavaThread):持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
- epoch:偏向时间戳;偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
- ptr_to_lock_record:指向栈中锁记录的指针。轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
- ptr_to_heavyweight_monitor:指向管程Monitor的指针。重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
1.2 Klass Pointer##
即类型指针,是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops
开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
- 每个Class的属性指针(即静态变量)
- 每个对象的属性指针(即对象变量)
- 普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。手动设置jvm启动参数为:-XX:+UseCompressedOops
1.3 哪些信息会被压缩?
- 对象的全局静态变量(即类属性)
- 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
- 对象的引/用类型:64位平台下,引|用类型本身大小为8字节,压缩后为4字节
- 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
1.4 字段重排列
其实我们在编写Java源代码文件的时候定义的那些实例字段的顺序会被JVM重新分配排列,这样做的目的其实是为了内存对齐,那么什么是内存对齐,为什么要进行内存对齐,笔者会随着文章深入的解读为大家逐层揭晓答案~~
本小节中,笔者先来为大家介绍一下JVM字段重排列的规则:
JVM重新分配字段的排列顺序受-XX:FieldsAllocationStyle
参数的影响,默认值为1
,实例字段的重新分配策略遵循以下规则:
- 如果一个字段占用
X
个字节,那么这个字段的偏移量OFFSET
需要对齐至NX
偏移量是指字段的内存地址与Java对象的起始内存地址之间的差值。比如long类型的字段,它内存占用8个字节,那么它的OFFSET应该是8的倍数8N。不足8N的需要填充字节。
- 在开启了压缩指针的64位JVM中,Java类中的第一个字段的OFFSET需要对齐至4N,在关闭压缩指针的情况下类中第一个字段的OFFSET需要对齐至8N。
- JVM默认分配字段的顺序为:long / double,int / float,short / char,byte / boolean,oops(Ordianry Object Point 引用类型指针),并且父类中定义的实例变量会出现在子类实例变量之前。当设置JVM参数
-XX +CompactFields
时(默认),占用内存小于long / double 的字段会允许被插入到对象中第一个 long / double字段之前的间隙中,以避免不必要的内存填充。
CompactFields选项参数在JDK14中以被标记为过期了,并在将来的版本中很可能被删除。详细细节可查看issue: https://bugs.openjdk.java.net/browse/JDK-8228750
上边的三条字段重排列规则非常非常重要,但是读起来比较绕脑,很抽象不容易理解,笔者把它们先列出来的目的是为了让大家先有一个朦朦胧胧的感性认识,下面笔者举一个具体的例子来为大家详细说明下,在阅读这个例子的过程中也方便大家深刻的理解这三条重要的字段重排列规则。
假设现在我们有这样一个类定义
- 根据上面介绍的
规则3
我们知道父类中的变量是出现在子类变量之前的,并且字段分配顺序应该是long型字段l,应该在int型字段i之前。
如果JVM开启了-XX +CompactFields
时,int型字段是可以插入对象中的第一个long型字段(也就是Parent.l字段)之前的空隙中的。如果JVM设置了-XX -CompactFields
则int型字段的这种插入行为是不被允许的。
- 根据
规则1
我们知道long型字段在实例数据区的OFFSET需要对齐至8N,而int型字段的OFFSET需要对齐至4N。 - 根据
规则2
我们知道如果开启压缩指针-XX:+UseCompressedOops
,Child对象的第一个字段的OFFSET需要对齐至4N,关闭压缩指针时-XX:-UseCompressedOops
,Child对象的第一个字段的OFFSET需要对齐至8N。
由于JVM参数UseCompressedOops
和CompactFields
的存在,导致Child对象在实例数据区字段的排列顺序分为四种情况,下面我们结合前边提炼出的这三点规则来看下字段排列顺序在这四种情况下的表现。
1.4.1 -XX:+UseCompressedOops -XX -CompactFields 开启压缩指针,关闭字段压缩
image.png
- 偏移量OFFSET = 8的位置存放的是类型指针,由于开启了压缩指针所以占用4个字节。对象头总共占用12个字节:MarkWord(8字节) + 类型指针(4字节)。
- 根据
规则3:
父类Parent中的字段是要出现在子类Child的字段之前的并且long型字段在int型字段之前。 根据规则2:
在开启压缩指针的情况下,Child对象中的第一个字段需要对齐至4N。这里Parent.l字段的OFFSET可以是12也可以是16。根据规则1:
long型字段在实例数据区的OFFSET需要对齐至8N,所以这里Parent.l字段的OFFSET只能是16,因此OFFSET = 12的位置就需要被填充。Child.l字段只能在OFFSET = 32处存储,不能够使用OFFSET = 28位置,因为28的位置不是8的倍数无法对齐8N,因此OFFSET = 28的位置被填充了4个字节。
规则1也规定了int型字段的OFFSET需要对齐至4N,所以Parent.i与Child.i分别存储以OFFSET = 24和OFFSET = 40的位置。
因为JVM中的内存对齐除了存在于字段与字段之间还存在于对象与对象之间,Java对象之间的内存地址需要对齐至8N。
所以Child对象的末尾处被填充了4个字节,对象大小由开始的44字节被填充到48字节。
1.4.2 -XX:+UseCompressedOops -XX +CompactFields 开启压缩指针,开启字段压缩
image.png
- 在第一种情况的分析基础上,我们开启了
-XX +CompactFields
压缩字段,所以导致int型的Parent.i字段可以插入到OFFSET = 12的位置处,以避免不必要的字节填充。 - 根据
规则2:
Child对象的第一个字段需要对齐至4N,这里我们看到int型
的Parent.i字段是符合这个规则的。 - 根据
规则1:
Child对象的所有long型字段都对齐至8N,所有的int型字段都对齐至4N。
最终得到Child对象大小为36字节,由于Java对象与对象之间的内存地址需要对齐至8N,所以最后Child对象的末尾又被填充了4个字节最终变为40字节。
这里我们可以看到在开启字段压缩 -XX +CompactFields
的情况下,Child对象的大小由48字节变成了40字节。
1.4.3 -XX:-UseCompressedOops -XX -CompactFields 关闭压缩指针,关闭字段压缩
image.png
首先在关闭压缩指针-UseCompressedOops
的情况下,对象头中的类型指针占用字节变成了8字节。导致对象头的大小在这种情况下变为了16字节。
- 根据
规则1:
long型的变量OFFSET需要对齐至8N。根据规则2:
在关闭压缩指针的情况下,Child对象的第一个字段Parent.l需要对齐至8N。所以这里的Parent.l字段的OFFSET = 16。 - 由于long型的变量OFFSET需要对齐至8N,所以Child.l字段的OFFSET 需要是32,因此OFFSET = 28的位置被填充了4个字节。
这样计算出来的Child对象大小为44字节,但是考虑到Java对象与对象的内存地址需要对齐至8N,于是又在对象末尾处填充了4个字节,最终Child对象的内存占用为48字节。
1.4.4 -XX:-UseCompressedOops -XX +CompactFields 关闭压缩指针,开启字段压缩
在第三种情况的分析基础上,我们来看下第四种情况的字段排列情况:
image.png
由于在关闭指针压缩的情况下类型指针的大小变为了8个字节,所以导致Child对象中第一个字段Parent.l前边并没有空隙,刚好对齐8N,并不需要int型变量的插入。所以即使开启了字段压缩-XX +CompactFields
,字段的总体排列顺序还是不变的。
默认情况下指针压缩-XX:+UseCompressedOops
以及字段压缩-XX +CompactFields
都是开启的
2.实例数据#
Java对象在内存中的实例数据区用来存储Java类中定义的实例字段,包括所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存。
如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如boolean类型占1个字节,int类型占4个字节等等;
Java对象中的字段类型分为两大类:
- 基础类型:Java类中实例字段定义的基础类型在实例数据区的内存占用如下:
- long | double占用8个字节。
- int | float占用4个字节。
- short | char占用2个字节。
- byte | boolean占用1个字节。
- 引用类型:Java类中实例字段的引用类型在实例数据区内存占用分为两种情况:
- 不开启指针压缩(-XX:-UseCompressedOops):在32位操作系统中引用类型的内存占用为4个字节。在64位操作系统中引用类型的内存占用为8个字节。
- 开启指针压缩(-XX:+UseCompressedOops):在64为操作系统下,引用类型内存占用则变为为4个字节,32位操作系统中引用类型的内存占用继续为4个字节。
为什么32位操作系统的引用类型占4个字节,而64位操作系统引用类型占8字节?
在Java中,引用类型所保存的是被引用对象的内存地址。在32位操作系统中内存地址是由32个bit表示,因此需要4个字节来记录内存地址,能够记录的虚拟地址空间是2^32大小,也就是只能够表示4G大小的内存。
而在64位操作系统中内存地址是由64个bit表示,因此需要8个字节来记录内存地址,但在 64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 2^48大小,能够表示256T大小的内存,其中低 128T 的空间划分为用户空间,高 128T 划分为内核空间,可以说是非常大了。
在我们从整体上介绍完Java对象在JVM中的内存布局之后,下面我们来看下Java对象中定义的这些实例字段在实例数据区是如何排列布局的:
3.对齐填充数据#
对象可以有对齐数据也可以没有。默认情况下,Java虚拟机堆中对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N个字节则需要对其填充,以此来补齐对象头和实例数据占用内存之后剩余的空间大小。如果对象头和实例数据已经占满了JVM所分配的内存空间,那么就不用再进行对齐填充了。
所有的对象分配的字节总SIZE需要是8的倍数,如果前面的对象头和实例数据占用的总SIZE不满足要求,则通过对齐数据来填满。
为什么要对齐数据?
字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址。
至此,我们已经了解了对象在堆内存中的整体结构布局,如下图所示
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里面包含多个核心,我们在购买电脑的时候经常会看到这样的处理器配置,比如4核8线程
。意思是这个CPU包含4个物理核心8个逻辑核心。4个物理核心表示在同一时间可以允许4个线程并行执行,8个逻辑核心表示处理器利用超线程的技术
将一个物理核心模拟出了两个逻辑核心,一个物理核心在同一时间只会执行一个线程,而超线程芯片
可以做到线程之间快速切换,当一个线程在访问内存的空隙,超线程芯片可以马上切换去执行另外一个线程。因为切换速度非常快,所以在效果上看到是8个线程在同时执行。
图中的CPU核心指的是物理核心。
从图中我们可以看到L1Cache是离CPU核心最近的高速缓存,紧接着就是L2Cache,L3Cache,内存。
离CPU核心越近的缓存访问速度也越快,造价也就越高,当然容量也就越小。
其中L1Cache和L2Cache是CPU物理核心私有的(注意:这里是物理核心不是逻辑核心)
而L3Cache是整个CPU所有物理核心共享的。
CPU逻辑核心共享其所属物理核心的L1Cache和L2Cache
L1Cache
L1Cache离CPU是最近的,它的访问速度最快,容量也最小。
从图中我们看到L1Cache分为两个部分,分别是:Data Cache和Instruction Cache。它们一个是存储数据的,一个是存储代码指令的。
我们可以通过cd /sys/devices/system/cpu/
来查看linux机器上的CPU信息。
在/sys/devices/system/cpu/
目录里,我们可以看到CPU的核心数,当然这里指的是逻辑核心。
笔者机器上的处理器并没有使用超线程技术所以这里其实是4个物理核心。
下面我们进入其中一颗CPU核心(cpu0)中去看下L1Cache的情况:
CPU缓存的情况在/sys/devices/system/cpu/cpu0/cache
目录下查看:
index0
描述的是L1Cache中DataCache的情况:
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的情况:
我们看到L1Cache中的Instruction Cache大小也是32K。
L2Cache
L2Cache的信息存储在index2
目录下:
L2Cache的大小为256K,比L1Cache要大些。
L3Cache
L3Cache的信息存储在index3
目录下:
到这里我们可以看到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字节
(注意:这里的单位是字节)。
从图中我们可以看到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集合的遍历性能。
4.2 False Sharing(伪共享)
我们先来看一个这样的例子,笔者定义了一个示例类FalseSharding,类中有两个long型的volatile字段a,b。
字段a,b之间逻辑上是独立的,它们之间一点关系也没有,分别用来存储不同的数据,数据之间也没有关联。
FalseSharding类中字段之间的内存布局如下:
FalseSharding类中的字段a,b在内存中是相邻存储,分别占用8个字节。
如果恰好字段a,b被CPU读进了同一个缓存行,而此时有两个线程,线程a用来修改字段a,同时线程b用来读取字段b。
falsesharding1.png
在这种场景下,会对线程b的读取操作造成什么影响呢?
我们知道声明了volatile关键字
的变量可以在多线程处理环境下,确保内存的可见性。计算机硬件层会保证对被volatile关键字修饰的共享变量进行写操作后的内存可见性,而这种内存可见性是由Lock前缀指令
以及缓存一致性协议(MESI控制协议)
共同保证的。
- Lock前缀指令可以使修改线程所在的处理器中的相应缓存行数据被修改后立马刷新回内存中,并同时
锁定
所有处理器核心中缓存了该修改变量的缓存行,防止多个处理器核心并发修改同一缓存行。 - 缓存一致性协议主要是用来维护多个处理器核心之间的CPU缓存一致性以及与内存数据的一致性。每个处理器会在总线上嗅探其他处理器准备写入的内存地址,如果这个内存地址在自己的处理器中被缓存的话,就会将自己处理器中对应的缓存行置为
无效
,下次需要读取的该缓存行中的数据的时候,就需要访问内存获取。
基于以上volatile关键字原则,我们首先来看第一种影响:
- 当线程a在处理器core0中对字段a进行修改时,
Lock前缀指令
会将所有处理器中缓存了字段a的对应缓存行进行锁定
,这样就会导致线程b在处理器core1中无法读取和修改自己缓存行的字段b。 - 处理器core0将修改后的字段a所在的缓存行刷新回内存中。
从图中我们可以看到此时字段a的值在处理器core0的缓存行中以及在内存中已经发生变化了。但是处理器core1中字段a的值还没有变化,并且core1中字段a所在的缓存行处于锁定状态
,无法读取也无法写入字段b。
从上述过程中我们可以看出即使字段a,b之间逻辑上是独立的,它们之间一点关系也没有,但是线程a对字段a的修改,导致了线程b无法读取字段b。
第二种影响:
当处理器core0将字段a所在的缓存行刷新回内存的时候,处理器core1会在总线上嗅探到字段a的内存地址正在被其他处理器修改,所以将自己的缓存行置为失效
。当线程b在处理器core1中读取字段b的值时,发现缓存行已被置为失效
,core1需要重新从内存中读取字段b的值即使字段b没有发生任何变化。
从以上两种影响我们看到字段a与字段b实际上并不存在共享,它们之间也没有相互关联关系,理论上线程a对字段a的任何操作,都不应该影响线程b对字段b的读取或者写入。
但事实上线程a对字段a的修改导致了字段b在core1中的缓存行被锁定(Lock前缀指令),进而使得线程b无法读取字段b。
线程a所在处理器core0将字段a所在缓存行同步刷新回内存后,导致字段b在core1中的缓存行被置为失效
(缓存一致性协议),进而导致线程b需要重新回到内存读取字段b的值无法利用CPU缓存的优势。
由于字段a和字段b在同一个缓存行中,导致了字段a和字段b事实上的共享(原本是不应该被共享的)。这种现象就叫做False Sharing(伪共享)
。
在高并发的场景下,这种伪共享的问题,会对程序性能造成非常大的影响。
如果线程a对字段a进行修改,与此同时线程b对字段b也进行修改,这种情况对性能的影响更大,因为这会导致core0和core1中相应的缓存行相互失效。
4.3 False Sharing的解决方案
既然导致False Sharing出现的原因是字段a和字段b在同一个缓存行导致的,那么我们就要想办法让字段a和字段b不在一个缓存行中。
那么我们怎么做才能够使得字段a和字段b一定不会被分配到同一个缓存行中呢?
这时候,本小节的主题字节填充就派上用场了~~
在Java8之前我们通常会在字段a和字段b前后分别填充7个long型变量(缓存行大小64字节),目的是让字段a和字段b各自独占一个缓存行避免False Sharing
。
比如我们将一开始的实例代码修改成这个这样子,就可以保证字段a和字段b各自独占一个缓存行了。
修改后的对象在内存中布局如下:
我们看到为了解决False Sharing问题,我们将原本占用32字节的FalseSharding示例对象硬生生的填充到了200字节。这对内存的消耗是非常可观的。通常为了极致的性能,我们会在一些高并发框架或者JDK的源码中看到False Sharing的解决场景。因为在高并发场景中,任何微小的性能损失比如False Sharing,都会被无限放大。
但解决False Sharing的同时又会带来巨大的内存消耗,所以即使在高并发框架比如disrupter或者JDK中也只是针对那些在多线程场景下被频繁写入的共享变量。
4.3.1 @Contended注解
在Java8中引入了一个新注解@Contended
,用于解决False Sharing的问题,同时这个注解也会影响到Java对象中的字段排列。
在上一小节的内容介绍中,我们通过手段填充字段的方式解决了False Sharing的问题,但是这里也有一个问题,因为我们在手动填充字段的时候还需要考虑CPU缓存行的大小,因为虽然现在所有主流的处理器缓存行大小均为64字节,但是也还是有处理器的缓存行大小为32字节,有的甚至是128字节。我们需要考虑很多硬件的限制因素。
Java8中通过引入@Contended注解帮我们解决了这个问题,我们不在需要去手动填充字段了。下面我们就来看下@Contended注解是如何帮助我们来解决这个问题的~~
上小节介绍的手动填充字节是在共享变量前后填充64字节大小的空间,这样只能确保程序在缓存行大小为32字节或者64字节的CPU下独占缓存行。但是如果CPU的缓存行大小为128字节,这样依然存在False Sharing的问题。
引入@Contended注解可以使我们忽略底层硬件设备的差异性,做到Java语言的初衷:平台无关性。
@Contended注解默认只是在JDK内部起作用,如果我们的程序代码中需要使用到@Contended注解,那么需要开启JVM参数 -XX:-RestrictContended
才会生效。
@Contended注解可以标注在类上也可以标注在类中的字段上,被@Contended标注的对象会独占缓存行,不会和任何变量或者对象共享缓存行。
- @Contended标注在类上表示该类对象中的
实例数据整体
需要独占缓存行。不能与其他实例数据共享缓存行。 - @Contended标注在类中的字段上表示该字段需要独占缓存行。
- 除此之外@Contended还提供了分组的概念,注解中的value属性表示
contention group
。属于统一分组下的变量,它们在内存中是连续存放的,可以允许共享缓存行。不同分组之间不允许共享缓存行。
下面我们来分别看下@Contended注解在这三种使用场景下是怎样影响字段之间的排列的。
@Contended标注在类上
当@Contended标注在FalseSharding示例类上时,表示FalseSharding示例对象中的整个实例数据区
需要独占缓存行,不能与其他对象或者变量共享缓存行。
这种情况下的内存布局:
如图中所示,FalseSharding示例类被标注了@Contended之后,JVM会在FalseSharding示例对象的实例数据区前后填充128个字节
,保证实例数据区内的字段之间内存是连续的,并且保证整个实例数据区独占缓存行,不会与实例数据区之外的数据共享缓存行。
细心的朋友可能已经发现了问题,我们之前不是提到缓存行的大小为64字节吗?为什么这里会填充128字节呢?
而且之前介绍的手动填充也是填充的64字节
,为什么@Contended注解会采用两倍
的缓存行大小来填充呢?
其实这里的原因有两个:
- 首先第一个原因,我们之前也已经提到过了,目前大部分主流的CPU缓存行是64字节,但是也有部分CPU缓存行是32字节或者128字节,如果只填充64字节的话,在缓存行大小为32字节和64字节的CPU中是可以做到独占缓存行从而避免FalseSharding的,但在缓存行大小为
128字节
的CPU中还是会出现FalseSharding问题,这里Java采用了悲观的一种做法,默认都是填充128字节
,虽然对于大部分情况下比较浪费,但是屏蔽了底层硬件的差异。
不过@Contended注解填充字节的大小我们可以通过JVM参数-XX:ContendedPaddingWidth
指定,有效值范围0 - 8192
,默认为128
。
- 第二个原因其实是最为核心的一个原因,主要是为了防止CPU Adjacent Sector Prefetch(CPU相邻扇区预取)特性所带来的FalseSharding问题。
CPU Adjacent Sector Prefetch: https://www.techarp.com/bios-guide/cpu-adjacent-sector-prefetch/
CPU Adjacent Sector Prefetch是Intel处理器特有的BIOS功能特性,默认是enabled。主要作用就是利用程序局部性原理
,当CPU从内存中请求数据,并读取当前请求数据所在缓存行时,会进一步预取
与当前缓存行相邻的下一个缓存行,这样当我们的程序在顺序处理数据时,会提高CPU处理效率。这一点也体现了程序局部性原理中的空间局部性特征。
当CPU Adjacent Sector Prefetch特性被disabled禁用时,CPU就只会获取当前请求数据所在的缓存行,不会预取下一个缓存行。
所以在当CPU Adjacent Sector Prefetch
启用(enabled)的时候,CPU其实同时处理的是两个缓存行,在这种情况下,就需要填充两倍缓存行大小(128字节)来避免CPU Adjacent Sector Prefetch所带来的的FalseSharding问题。
@Contended标注在字段上
这次我们将 @Contended注解标注在了FalseSharding示例类中的字段a和字段b上,这样带来的效果是字段a和字段b各自独占缓存行。从内存布局上看,字段a和字段b前后分别被填充了128个字节,来确保字段a和字段b不与任何数据共享缓存行。
而没有被@Contended注解标注字段c和字段d则在内存中连续存储,可以共享缓存行。
@Contended分组
这次我们将字段a与字段b放在同一content group下,字段c与字段d放在另一个content group下。
这样处在同一分组group1
下的字段a与字段b在内存中是连续存储的,可以共享缓存行。
同理处在同一分组group2下的字段c与字段d在内存中也是连续存储的,也允许共享缓存行。
但是分组之间是不能共享缓存行的,所以在字段分组的前后各填充128字节
,来保证分组之间的变量不能共享缓存行。
5. 为什么要内存对齐
我们在了解了内存结构以及CPU读写内存的过程之后,现在我们回过头来讨论下本小节开头的问题:为什么要内存对齐?
下面笔者从三个方面来介绍下要进行内存对齐的原因:
速度
CPU读取数据的单位是根据word size
来的,在64位处理器中word size = 8字节
,所以CPU向内存读写数据的单位为8字节
。
在64位内存中,内存IO单位为8个字节
,我们前边也提到内存结构中的存储器模块通常以64位为单位(8个字节)传输数据到存储控制器上或者从存储控制器传出数据。因为每次内存IO读取数据都是从数据所在具体的存储器模块中包含的这8个DRAM芯片中以相同的(RAM
,CAS
)依次读取一个字节,然后在存储控制器中聚合成8个字节
返回给CPU。
由于存储器模块中这种由8个DRAM芯片组成的物理存储结构的限制,内存读取数据只能是按照地址顺序8个字节的依次读取----8个字节8个字节地来读取数据。
- 假设我们现在读取
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访问内存对齐的地址时,比如0x0000
和0x0008
这两个起始地址都是对齐至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. 字段重排列》这一小节介绍的三种字段对齐规则,是保证在字段内存对齐的基础上使得实例数据区占用内存尽可能的小。
6. 数组对象的内存布局
前边大量的篇幅我们都是在讨论Java普通对象在内存中的布局情况,最后这一小节我们再来说下Java中的数组对象在内存中是如何布局的。
6.1 基本类型数组的内存布局
上图表示的是基本类型数组在内存中的布局,基本类型数组在JVM中用typeArrayOop
结构体表示,基本类型数组类型元信息用TypeArrayKlass
结构体表示。
数组的内存布局大体上和普通对象的内存布局差不多,唯一不同的是在数组类型对象头中多出了4个字节
用来表示数组长度的部分。
我们还是分别以开启指针压缩和关闭指针压缩两种情况,通过下面的例子来进行说明:
开启指针压缩 -XX:+UseCompressedOops
我们看到红框部分即为数组类型对象头中多出来一个4字节
大小用来表示数组长度的部分。
因为我们示例中的long型数组只有一个元素,所以实例数据区的大小只有8字节。如果我们示例中的long型数组变为两个元素,那么实例数据区的大小就会变为16字节,以此类推................。
关闭指针压缩 -XX:-UseCompressedOops
当关闭了指针压缩时,对象头中的MarkWord还是占用8个字节,但是类型指针从4个字节变为了8个字节。数组长度属性还是不变保持4个字节。
这里我们发现是实例数据区与对象头之间发生了对齐填充。大家还记得这是为什么吗??
我们前边在字段重排列小节介绍了三种字段排列规则在这里继续适用:
规则1
:如果一个字段占用X
个字节,那么这个字段的偏移量OFFSET需要对齐至NX
。规则2
:在开启了压缩指针的64位JVM中,Java类中的第一个字段的OFFSET需要对齐至4N
,在关闭压缩指针的情况下类中第一个字段的OFFSET需要对齐至8N
。
这里基本数组类型的实例数据区中是long型,在关闭指针压缩的情况下,根据规则1和规则2需要对齐至8的倍数,所以要在其与对象头之间填充4个字节,达到内存对齐的目的,起始地址变为24
。
6.2 引用类型数组的内存布局
引用类型数组的内存布局
上图表示的是引用类型数组在内存中的布局,引用类型数组在JVM中用objArrayOop
结构体表示,基本类型数组类型元信息用ObjArrayKlass
结构体表示。
同样在引用类型数组的对象头中也会有一个4字节
大小用来表示数组长度的部分。
我们还是分别以开启指针压缩和关闭指针压缩两种情况,通过下面的例子来进行说明:
开启指针压缩 -XX:+UseCompressedOops
引用数组类型内存布局与基础数组类型内存布局最大的不同在于它们的实例数据区。由于开启了压缩指针,所以对象引用占用内存大小为4个字节
,而我们示例中引用数组只包含一个引用元素,所以这里实例数据区中只有4个字节。相同的到道理,如果示例中的引用数组包含的元素变为两个引用元素,那么实例数据区就会变为8个字节,以此类推......。
最后由于Java对象需要内存对齐至8的倍数
,所以在该引用数组的实例数据区后填充了4个字节。
关闭指针压缩 -XX:-UseCompressedOops
当关闭压缩指针时,对象引用占用内存大小变为了8个字节
,所以引用数组类型的实例数据区占用了8个字节。
根据字段重排列规则2,在引用数组类型对象头与实例数据区中间需要填充4个字节
以保证内存对齐的目的。
Talk is cheap, show me code#
概念的东西是抽象的,你说它是这样组成的,就真的是吗?学习是需要持怀疑的态度的,任何理论和概念只有自己证实和实践之后才能接受它。还好 openjdk 给我们提供了一个工具包,可以用来获取对象的信息和虚拟机的信息,我们只需引入 jol-core 依赖,如下
<dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.8</version>
</dependency>
jol-core 常用的三个方法
ClassLayout.parseInstance(object).toPrintable()
:查看对象内部信息.GraphLayout.parseInstance(object).toPrintable()
:查看对象外部信息,包括引用的对象.GraphLayout.parseInstance(object).totalSize()
:查看对象总大小.
普通对象#
为了简单化,我们不用复杂的对象,自己创建一个类 D,先看无属性字段的时候
public class D {
}
通过 jol-core 的 api,我们将对象的内部信息打印出来
public static void main(String[] args) {D d = new D();System.out.println(ClassLayout.parseInstance(d).toPrintable());
}
最后的打印结果为
可以看到有 OFFSET、SIZE、TYPE DESCRIPTION、VALUE 这几个名词头,它们的含义分别是
- OFFSET:偏移地址,单位字节;
- SIZE:占用的内存大小,单位为字节;
- TYPE DESCRIPTION:类型描述,其中object header为对象头;
- VALUE:对应内存中当前存储的值,二进制32位;
可以看到,d对象实例共占据16byte,对象头(object header)占据12byte(96bit),其中 mark word占8byte(64bit),klass pointe 占4byte,另外剩余4byte是填充对齐的。
这里由于默认开启了指针压缩 ,所以对象头占了12byte,具体的指针压缩的概念这里就不再阐述了,感兴趣的读者可以自己查阅下官方文档。jdk8版本是默认开启指针压缩的,可以通过配置vm参数开启关闭指针压缩,-XX:-UseCompressedOops
。
如果关闭指针压缩重新打印对象的内存布局,可以发现总SIZE变大了,从下图中可以看到,对象头所占用的内存大小变为16byte(128bit),其中 mark word占8byte,klass pointe 占8byte,无对齐填充。
开启指针压缩可以减少对象的内存使用。从两次打印的D对象布局信息来看,关闭指针压缩时,对象头的SIZE增加了4byte,这里由于D对象是无属性的,读者可以试试增加几个属性字段来看下,这样会明显的发现SIZE增长。因此开启指针压缩,理论上来讲,大约能节省百分之五十的内存。jdk8及以后版本已经默认开启指针压缩,无需配置。
数组对象#
上面使用的是普通对象,我们来看下数组对象的内存布局,比较下有什么异同
public static void main(String[] args) {int[] a = {1};System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
打印的内存布局信息,如下
可以看到这时总SIZE为共24byte,对象头占16byte,其中Mark Work占8byte,Klass Point 占4byte,array length 占4byte,因为里面只有一个int 类型的1,所以数组对象的实例数据占据4byte,剩余对齐填充占据4byte。
结尾#
经过以上的内容我们了解了对象在内存中的布局,了解对象的内存布局和对象头的概念,特别是对象头的Mark Word的内容,在我们后续分析 synchronize 锁优化 和 JVM 垃圾回收年龄代的时候会有很大作用。
JVM中大家是否还记得对象在Suvivor中每熬过一次MinorGC,年龄就增加1,当它的年龄增加到一定程度后就会被晋升到老年代中,这个次数默认是15岁,有想过为什么是15吗?在Mark Word中可以发现标记对象分代年龄的分配的空间是4bit,而4bit能表示的最大数就是2^4-1 = 15。