大家好,我是bin,又到了每周咱们见面的时刻了,我的 “公众号:bin的技术小屋” 在1月10号那天公布了第一篇文章《从内核角度看IO模型的演变》,在这篇文章中咱们通过图解的形式以一个C10k的问题为主线,从内核角度具体论述了5种IO模型的演变过程,以及两种IO线程模型的介绍,最初引出了Netty的网络IO线程模型。读者敌人们后盾留言都感觉十分的硬核,在大家的反对下这篇文章的目前浏览量为2038,点赞量为80,在看为32。这对于刚刚诞生一个多月的小号来说,是一种莫大的激励。在这里bin再次感激大家的认可,激励和反对~~

明天bin将再来为大家带来一篇硬核的技术文章,本文咱们将从计算机组成原理的角度具体论述对象在JVM内存中是如何布局的,以及什么是内存对齐,如果咱们头比拟铁,就是不进行内存对齐会造成什么样的结果,最初引出压缩指针的原理和利用。同时咱们还介绍了在高并发场景下,False Sharing产生的起因以及带来的性能影响。

置信大家看完本文后,肯定会播种很多,话不多说,上面咱们正式开始本文的内容~~

在咱们的日常工作中,有时候咱们为了避免线上利用产生OOM,所以咱们须要在开发的过程中计算一些外围对象在内存中的占用大小,目标是为了更好的理解咱们的应用程序内存占用的一个大略状况。

进而依据咱们服务器的内存资源限度以及预估的对象创立数量级计算出应用程序占用内存的高下水位线,如果内存占用量超过高水位线,那么就有可能有产生OOM的危险。

咱们能够在程序中依据估算出的高下水位线,做一些避免OOM的解决逻辑或者收回告警。

那么外围问题是如何计算一个Java对象在内存中的占用大小呢??

在为大家解答这个问题之前,笔者先来介绍下Java对象在内存中的布局,也就是本文的主题。

1. Java对象的内存布局

如图所示,Java对象在JVM中是用instanceOopDesc 构造示意而Java对象在JVM堆中的内存布局能够分为三局部:

1.1 对象头(Header)

每个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

1.2 实例数据(Instance Data)

Java对象在内存中的实例数据区用来存储Java类中定义的实例字段,包含所有父类中的实例字段。也就是说,尽管子类无法访问父类的公有实例字段,或者子类的实例字段暗藏了父类的同名实例字段,然而子类的实例还是会为这些父类实例字段分配内存。

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对象中定义的这些实例字段在实例数据区是如何排列布局的:

2. 字段重排列

其实咱们在编写Java源代码文件的时候定义的那些实例字段的程序会被JVM重新分配排列,这样做的目标其实是为了内存对齐,那么什么是内存对齐,为什么要进行内存对齐,笔者会随着文章深刻的解读为大家逐层揭晓答案~~

本大节中,笔者先来为大家介绍一下JVM字段重排列的规定:

JVM重新分配字段的排列程序受-XX:FieldsAllocationStyle参数的影响,默认值为1,实例字段的重新分配策略遵循以下规定:

  1. 如果一个字段占用X个字节,那么这个字段的偏移量OFFSET须要对齐至NX
偏移量是指字段的内存地址与Java对象的起始内存地址之间的差值。比方long类型的字段,它内存占用8个字节,那么它的OFFSET应该是8的倍数8N。有余8N的须要填充字节。
  1. 在开启了压缩指针的64位JVM中,Java类中的第一个字段的OFFSET须要对齐至4N,在敞开压缩指针的状况下类中第一个字段的OFFSET须要对齐至8N。
  2. 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...

上边的三条字段重排列规定十分十分重要,然而读起来比拟绕脑,很形象不容易了解,笔者把它们先列进去的目标是为了让大家先有一个朦朦胧胧的感性认识,上面笔者举一个具体的例子来为大家具体阐明下,在浏览这个例子的过程中也不便大家粗浅的了解这三条重要的字段重排列规定。

假如当初咱们有这样一个类定义

public class Parent {    long l;    int i;}public class Child extends Parent {    long l;    int i;}
  • 依据下面介绍的规定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对象在实例数据区字段的排列程序分为四种状况,上面咱们联合前边提炼出的这三点规定来看下字段排列程序在这四种状况下的体现。

2.1 -XX:+UseCompressedOops -XX -CompactFields 开启压缩指针,敞开字段压缩

  • 偏移量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字节。

2.2 -XX:+UseCompressedOops -XX +CompactFields 开启压缩指针,开启字段压缩

  • 在第一种状况的剖析根底上,咱们开启了-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字节。

2.3 -XX:-UseCompressedOops -XX -CompactFields 敞开压缩指针,敞开字段压缩

首先在敞开压缩指针-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字节。

2.4 -XX:-UseCompressedOops -XX +CompactFields 敞开压缩指针,开启字段压缩

在第三种状况的剖析根底上,咱们来看下第四种状况的字段排列状况:

因为在敞开指针压缩的状况下类型指针的大小变为了8个字节,所以导致Child对象中第一个字段Parent.l前边并没有空隙,刚好对齐8N,并不需要int型变量的插入。所以即便开启了字段压缩-XX +CompactFields,字段的总体排列程序还是不变的。

默认状况下指针压缩-XX:+UseCompressedOops以及字段压缩-XX +CompactFields都是开启的

3. 对齐填充(Padding)

在前一大节对于实例数据区字段重排列的介绍中为了内存对齐而导致的字节填充不仅会呈现在字段与字段之间,还会呈现在对象与对象之间。

前边咱们介绍了字段重排列须要遵循的三个重要规定,其中规定1,规定2定义了字段与字段之间的内存对齐规定。 规定3定义的是对象字段之间的排列规定。

为了内存对齐的须要,对象头与字段之间,以及字段与字段之间须要填充一些不必要的字节。

比方前边提到的字段重排列的第一种状况-XX:+UseCompressedOops -XX -CompactFields

而以上提到的四种状况都会在对象实例数据区的后边在填充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外面蕴含多个外围,咱们在购买电脑的时候常常会看到这样的处理器配置,比方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。

public class FalseSharding {    volatile long a;    volatile long b;}

字段a,b之间逻辑上是独立的,它们之间一点关系也没有,别离用来存储不同的数据,数据之间也没有关联。

FalseSharding类中字段之间的内存布局如下:

FalseSharding类中的字段a,b在内存中是相邻存储,别离占用8个字节。

如果恰好字段a,b被CPU读进了同一个缓存行,而此时有两个线程,线程a用来批改字段a,同时线程b用来读取字段b。

在这种场景下,会对线程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各自独占一个缓存行了。

public class FalseSharding {    long p1,p2,p3,p4,p5,p6,p7;    volatile long a;    long p8,p9,p10,p11,p12,p13,p14;    volatile long b;    long p15,p16,p17,p18,p19,p20,p21;}

批改后的对象在内存中布局如下:

咱们看到为了解决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才会失效。
@Retention(RetentionPolicy.RUNTIME)@Target({ElementType.FIELD, ElementType.TYPE})public @interface Contended {    //contention group tag    String value() default "";}

@Contended注解能够标注在类上也能够标注在类中的字段上,被@Contended标注的对象会独占缓存行,不会和任何变量或者对象共享缓存行。

  • @Contended标注在类上示意该类对象中的实例数据整体须要独占缓存行。不能与其余实例数据共享缓存行。
  • @Contended标注在类中的字段上示意该字段须要独占缓存行。
  • 除此之外@Contended还提供了分组的概念,注解中的value属性示意contention group 。属于对立分组下的变量,它们在内存中是间断寄存的,能够容许共享缓存行。不同分组之间不容许共享缓存行。

上面咱们来别离看下@Contended注解在这三种应用场景下是怎么影响字段之间的排列的。

@Contended标注在类上
@Contendedpublic class FalseSharding {    volatile long a;    volatile long b;    volatile int c;    volatile int d;}

当@Contended标注在FalseSharding示例类上时,示意FalseSharding示例对象中的整个实例数据区须要独占缓存行,不能与其余对象或者变量共享缓存行。

这种状况下的内存布局:

如图中所示,FalseSharding示例类被标注了@Contended之后,JVM会在FalseSharding示例对象的实例数据区前后填充128个字节,保障实例数据区内的字段之间内存是间断的,并且保障整个实例数据区独占缓存行,不会与实例数据区之外的数据共享缓存行。

仔细的敌人可能曾经发现了问题,咱们之前不是提到缓存行的大小为64字节吗?为什么这里会填充128字节呢

而且之前介绍的手动填充也是填充的64字节,为什么@Contended注解会采纳两倍的缓存行大小来填充呢?

其实这里的起因有两个:

  1. 首先第一个起因,咱们之前也曾经提到过了,目前大部分支流的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-...

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标注在字段上
public class FalseSharding {    @Contended    volatile long a;    @Contended    volatile long b;    volatile int c;    volatile long d;}

这次咱们将 @Contended注解标注在了FalseSharding示例类中的字段a和字段b上,这样带来的成果是字段a和字段b各自独占缓存行。从内存布局上看,字段a和字段b前后别离被填充了128个字节,来确保字段a和字段b不与任何数据共享缓存行。

而没有被@Contended注解标注字段c和字段d则在内存中间断存储,能够共享缓存行。

@Contended分组
public class FalseSharding {    @Contended("group1")    volatile int a;    @Contended("group1")    volatile long b;    @Contended("group2")    volatile long  c;    @Contended("group2")    volatile long d;}

这次咱们将字段a与字段b放在同一content group下,字段c与字段d放在另一个content group下。

这样处在同一分组group1下的字段a与字段b在内存中是间断存储的,能够共享缓存行。

同理处在同一分组group2下的字段c与字段d在内存中也是间断存储的,也容许共享缓存行。

然而分组之间是不能共享缓存行的,所以在字段分组的前后各填充128字节,来保障分组之间的变量不能共享缓存行。

5. 内存对齐

通过以上内容咱们理解到Java对象中的实例数据区字段须要进行内存对齐而导致在JVM中会被重排列以及通过填充缓存行防止false sharding的目标所带来的字节对齐填充。

咱们也理解到内存对齐不仅产生在对象与对象之间,也产生在对象中的字段之间。

那么在本大节中笔者将为大家介绍什么是内存对齐,在本节的内容开始之前笔者先来抛出两个问题:

  • 为什么要进行内存对齐?如果就是头比拟铁,就是不内存对齐,会产生什么样的结果?
  • Java 虚拟机堆中对象的起始地址为什么须要对齐至 8的倍数?为什么不对齐至4的倍数或16的倍数或32的倍数呢?

带着这两个问题,上面咱们正式开始本节的内容~~~

5.1 内存构造

咱们平时所称的内存也叫随机拜访存储器(random-access memory)也叫RAM。而RAM分为两类:

  • 一类是动态RAM(SRAM),这类SRAM用于前边介绍的CPU高速缓存L1Cache,L2Cache,L3Cache。其特点是访问速度快,访问速度为1 - 30个时钟周期,然而容量小,造价高。
  • 另一类则是动静RAM(DRAM),这类DRAM用于咱们常说的主存上,其特点的是拜访速度慢(绝对高速缓存),访问速度为50 - 200个时钟周期,然而容量大,造价便宜些(绝对高速缓存)。

内存由一个一个的存储器模块(memory module)组成,它们插在主板的扩展槽上。常见的存储器模块通常以64位为单位(8个字节)传输数据到存储控制器上或者从存储控制器传出数据。

如图所示内存条上彩色的元器件就是存储器模块(memory module)。多个存储器模块连贯到存储控制器上,就聚合成了主存。

而前边介绍到的DRAM芯片就包装在存储器模块中,每个存储器模块中蕴含8个DRAM芯片,顺次编号为0 - 7

而每一个DRAM芯片的存储构造是一个二维矩阵,二维矩阵中存储的元素咱们称为超单元(supercell),每个supercell大小为一个字节(8 bit)。每个supercell都由一个坐标地址(i,j)。

i示意二维矩阵中的行地址,在计算机中行地址称为RAS(row access strobe,行拜访选通脉冲)。
j示意二维矩阵中的列地址,在计算机中列地址称为CAS(column access strobe,列拜访选通脉冲)。

下图中的supercell的RAS = 2,CAS = 2。

DRAM芯片中的信息通过引脚流入流出DRAM芯片。每个引脚携带1 bit的信号。

图中DRAM芯片蕴含了两个地址引脚(addr),因为咱们要通过RAS,CAS来定位要获取的supercell。还有8个数据引脚(data),因为DRAM芯片的IO单位为一个字节(8 bit),所以须要8个data引脚从DRAM芯片传入传出数据。

留神这里只是为了解释地址引脚和数据引脚的概念,理论硬件中的引脚数量是不肯定的。

5.2 DRAM芯片的拜访

咱们当初就以读取上图中坐标地址为(2,2)的supercell为例,来阐明拜访DRAM芯片的过程。

  1. 首先存储控制器将行地址RAS = 2通过地址引脚发送给DRAM芯片
  2. DRAM芯片依据RAS = 2将二维矩阵中的第二行的全部内容拷贝到外部行缓冲区中。
  3. 接下来存储控制器会通过地址引脚发送CAS = 2到DRAM芯片中。
  4. DRAM芯片从外部行缓冲区中依据CAS = 2拷贝出第二列的supercell并通过数据引脚发送给存储控制器。
DRAM芯片的IO单位为一个supercell,也就是一个字节(8 bit)。

5.3 CPU如何读写主存

前边咱们介绍了内存的物理构造,以及如何拜访内存中的DRAM芯片获取supercell中存储的数据(一个字节)。

本大节咱们来介绍下CPU是如何拜访内存的。

其中对于CPU芯片的内部结构咱们在介绍false sharding的时候曾经具体的介绍过了,这里咱们次要聚焦在CPU与内存之间的总线架构上。

5.3.1 总线结构

CPU与内存之间的数据交互是通过总线(bus)实现的,而数据在总线上的传送是通过一系列的步骤实现的,这些步骤称为总线事务(bus transaction)。

其中数据从内存传送到CPU称之为读事务(read transaction),数据从CPU传送到内存称之为写事务(write transaction)

总线上传输的信号包含:地址信号,数据信号,管制信号。其中管制总线上传输的管制信号能够同步事务,并可能标识出以后正在被执行的事务信息:

  • 以后这个事务是到内存的?还是到磁盘的?或者是到其余IO设施的?
  • 这个事务是读还是写?
  • 总线上传输的地址信号(内存地址),还是数据信号(数据)?。
还记得咱们前边讲到的MESI缓存一致性协定吗?当core0批改字段a的值时,其余CPU外围会在总线上嗅探字段a的内存地址,如果嗅探到总线上呈现字段a的内存地址,阐明有人在批改字段a,这样其余CPU外围就会生效本人缓存字段a所在的cache line

如上图所示,其中系统总线是连贯CPU与IO bridge的,存储总线是来连贯IO bridge和主存的。

IO bridge负责将系统总线上的电子信号转换成存储总线上的电子信号。IO bridge也会将系统总线和存储总线连贯到IO总线(磁盘等IO设施)上。这里咱们看到IO bridge其实起的作用就是转换不同总线上的电子信号。

5.3.2 CPU从内存读取数据过程

假如CPU当初要将内存地址为A的内容加载到寄存器中进行运算。

首先CPU芯片中的总线接口会在总线上发动读事务(read transaction)。 该读事务分为以下步骤进行:

  1. CPU将内存地址A放到系统总线上。随后IO bridge将信号传递到存储总线上。
  2. 主存感触到存储总线上的地址信号并通过存储控制器将存储总线上的内存地址A读取进去。
  3. 存储控制器通过内存地址A定位到具体的存储器模块,从DRAM芯片中取出内存地址A对应的数据X
  4. 存储控制器将读取到的数据X放到存储总线上,随后IO bridge将存储总线上的数据信号转换为系统总线上的数据信号,而后持续沿着系统总线传递。
  5. CPU芯片感触到系统总线上的数据信号,将数据从系统总线上读取进去并拷贝到寄存器中。

以上就是CPU读取内存数据到寄存器中的残缺过程。

然而其中还波及到一个重要的过程,这里咱们还是须要摊开来介绍一下,那就是存储控制器如何通过内存地址A从主存中读取出对应的数据X的?

接下来咱们联合前边介绍的内存构造以及从DRAM芯片读取数据的过程,来总体介绍下如何从主存中读取数据。

5.3.3 如何依据内存地址从主存中读取数据

前边介绍到,当主存中的存储控制器感触到了存储总线上的地址信号时,会将内存地址从存储总线上读取进去。

随后会通过内存地址定位到具体的存储器模块。还记得内存构造中的存储器模块吗??

而每个存储器模块中蕴含了8个DRAM芯片,编号从0 - 7

存储控制器会将内存地址转换为DRAM芯片中supercell在二维矩阵中的坐标地址(RASCAS)。并将这个坐标地址发送给对应的存储器模块。随后存储器模块会将RASCAS播送到存储器模块中的所有DRAM芯片。顺次通过(RASCAS)从DRAM0到DRAM7读取到相应的supercell。

咱们晓得一个supercell存储了8 bit数据,这里咱们从DRAM0到DRAM7
顺次读取到了8个supercell也就是8个字节,而后将这8个字节返回给存储控制器,由存储控制器将数据放到存储总线上。

CPU总是以word size为单位从内存中读取数据,在64位处理器中的word size为8个字节。64位的内存也只能每次吞吐8个字节。

CPU每次会向内存读写一个cache line大小的数据(64个字节),然而内存一次只能吞吐8个字节

所以在内存地址对应的存储器模块中,DRAM0芯片存储第一个低位字节(supercell),DRAM1芯片存储第二个字节,......顺次类推DRAM7芯片存储最初一个高位字节。

内存一次读取和写入的单位是8个字节。而且在程序员眼里间断的内存地址实际上在物理上是不间断的。因为这间断的8个字节其实是存储于不同的DRAM芯片上的。每个DRAM芯片存储一个字节(supercell)。

5.3.4 CPU向内存写入数据过程

咱们当初假如CPU要将寄存器中的数据X写到内存地址A中。同样的情理,CPU芯片中的总线接口会向总线发动写事务(write transaction)。写事务步骤如下:

  1. CPU将要写入的内存地址A放入系统总线上。
  2. 通过IO bridge的信号转换,将内存地址A传递到存储总线上。
  3. 存储控制器感触到存储总线上的地址信号,将内存地址A从存储总线上读取进去,并期待数据的达到。
  4. CPU将寄存器中的数据拷贝到系统总线上,通过IO bridge的信号转换,将数据传递到存储总线上。
  5. 存储控制器感触到存储总线上的数据信号,将数据从存储总线上读取进去。
  6. 存储控制器通过内存地址A定位到具体的存储器模块,最初将数据写入存储器模块中的8个DRAM芯片中。

6. 为什么要内存对齐

咱们在理解了内存构造以及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拜访内存对齐的地址时,比方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尽管额定的执行了一些位运算然而极大的进步了寻址空间,并且将对象援用占用内存大小升高了一半,节俭了大量空间。况且这些位运算对于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尽管能扩充寻址范畴,然而这同时也可能减少了对象之间的字节填充,导致压缩指针没有达到本来节俭空间的成果。

8. 数组对象的内存布局

前边大量的篇幅咱们都是在探讨Java一般对象在内存中的布局状况,最初这一大节咱们再来说下Java中的数组对象在内存中是如何布局的。

8.1 根本类型数组的内存布局

上图示意的是根本类型数组在内存中的布局,根本类型数组在JVM中用typeArrayOop构造体示意,根本类型数组类型元信息用TypeArrayKlass 构造体示意。

数组的内存布局大体上和一般对象的内存布局差不多,惟一不同的是在数组类型对象头中多出了4个字节用来示意数组长度的局部。

咱们还是别离以开启指针压缩和敞开指针压缩两种状况,通过上面的例子来进行阐明:

long[] longArrayLayout = new long[1];

开启指针压缩 -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

8.2 援用类型数组的内存布局

上图示意的是援用类型数组在内存中的布局,援用类型数组在JVM中用objArrayOop构造体示意,根本类型数组类型元信息用ObjArrayKlass 构造体示意。

同样在援用类型数组的对象头中也会有一个4字节大小用来示意数组长度的局部。

咱们还是别离以开启指针压缩和敞开指针压缩两种状况,通过上面的例子来进行阐明:

public class ReferenceArrayLayout {    char a;    int b;    short c;}ReferenceArrayLayout[] referenceArrayLayout = new ReferenceArrayLayout[1];

开启指针压缩 -XX:+UseCompressedOops

援用数组类型内存布局与根底数组类型内存布局最大的不同在于它们的实例数据区。因为开启了压缩指针,所以对象援用占用内存大小为4个字节,而咱们示例中援用数组只蕴含一个援用元素,所以这里实例数据区中只有4个字节。雷同的到情理,如果示例中的援用数组蕴含的元素变为两个援用元素,那么实例数据区就会变为8个字节,以此类推......。

最初因为Java对象须要内存对齐至8的倍数,所以在该援用数组的实例数据区后填充了4个字节。

敞开指针压缩 -XX:-UseCompressedOops

当敞开压缩指针时,对象援用占用内存大小变为了8个字节,所以援用数组类型的实例数据区占用了8个字节。

依据字段重排列规定2,在援用数组类型对象头与实例数据区两头须要填充4个字节以保障内存对齐的目标。


总结

本文笔者具体介绍了Java一般对象以及数组类型对象的内存布局,以及相干对象占用内存大小的计算方法。

以及在对象内存布局中的实例数据区字段重排列的三个重要规定。以及后边由字节的对齐填充引出来的false sharding问题,还有Java8为了解决false sharding而引入的@Contented注解的原理及应用形式。

为了讲清楚内存对齐的底层原理,笔者还花了大量的篇幅解说了内存的物理构造以及CPU读写内存的残缺过程。

最初又由内存对齐引出了压缩指针的工作原理。由此咱们晓得进行内存对齐的四个起因:

  • CPU拜访性能:当CPU拜访内存对齐的地址时,能够通过一个read transaction读取一个字长(word size)大小的数据进去。否则就须要两个read transaction。
  • 原子性: CPU能够原子地操作一个对齐的word size memory。
  • 尽可能利用CPU缓存:内存对齐能够使对象或者字段尽可能的被调配到一个缓存行中,防止跨缓存行存储,导致CPU执行效率减半。
  • 晋升压缩指针的内存寻址空间: 对象与对象之间的内存对齐,能够使咱们在64位零碎中利用32位对象援用将内存寻址空间晋升至32G。既升高了对象援用的内存占用,又晋升了内存寻址空间。

在本文中咱们顺带还介绍了和内存布局相干的几个JVM参数:-XX:+UseCompressedOops, -XX +CompactFields ,-XX:-RestrictContended ,-XX:ContendedPaddingWidth, -XX:ObjectAlignmentInBytes。

最初感激大家能看到这里,咱们下篇文章再见~~~