大家好,我是 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
,实例字段的重新分配策略遵循以下规定:
- 如果一个字段占用
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…
上边的三条字段重排列规定十分十分重要,然而读起来比拟绕脑,很形象不容易了解,笔者把它们先列进去的目标是为了让大家先有一个朦朦胧胧的感性认识,上面笔者举一个具体的例子来为大家具体阐明下,在浏览这个例子的过程中也不便大家粗浅的了解这三条重要的字段重排列规定。
假如当初咱们有这样一个类定义
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 标注在类上
@Contended
public 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 注解会采纳 两倍
的缓存行大小来填充呢?
其实这里的起因有两个:
- 首先第一个起因,咱们之前也曾经提到过了,目前大部分支流的 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 芯片的过程。
- 首先存储控制器将行地址
RAS = 2
通过地址引脚发送给DRAM 芯片
。 - DRAM 芯片依据
RAS = 2
将二维矩阵中的第二行的全部内容拷贝到外部行缓冲区
中。 - 接下来存储控制器会通过地址引脚发送
CAS = 2
到 DRAM 芯片中。 - 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
)。该读事务分为以下步骤进行:
- CPU 将内存地址 A 放到系统总线上。随后
IO bridge
将信号传递到存储总线上。 - 主存感触到存储总线上的
地址信号
并通过存储控制器将存储总线上的内存地址 A 读取进去。 - 存储控制器通过内存地址 A 定位到具体的存储器模块,从
DRAM 芯片
中取出内存地址 A 对应的数据 X
。 - 存储控制器将读取到的
数据 X
放到存储总线上,随后 IO bridge 将存储总线
上的数据信号转换为系统总线
上的数据信号,而后持续沿着系统总线传递。 - CPU 芯片感触到系统总线上的数据信号,将数据从系统总线上读取进去并拷贝到寄存器中。
以上就是 CPU 读取内存数据到寄存器中的残缺过程。
然而其中还波及到一个重要的过程,这里咱们还是须要摊开来介绍一下,那就是存储控制器如何通过 内存地址 A
从主存中读取出对应的 数据 X
的?
接下来咱们联合前边介绍的内存构造以及从 DRAM 芯片读取数据的过程,来总体介绍下如何从主存中读取数据。
5.3.3 如何依据内存地址从主存中读取数据
前边介绍到,当主存中的存储控制器感触到了存储总线上的 地址信号
时,会将内存地址从存储总线上读取进去。
随后会通过内存地址定位到具体的存储器模块。还记得内存构造中的存储器模块吗??
而每个存储器模块中蕴含了 8 个 DRAM 芯片,编号从0 - 7
。
存储控制器会将内存地址转换为 DRAM 芯片中 supercell 在二维矩阵中的坐标地址 (RAS
,CAS
)。并将这个坐标地址发送给对应的存储器模块。随后存储器模块会将RAS
和CAS
播送到存储器模块中的所有 DRAM 芯片
。顺次通过(RAS
,CAS
) 从 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
)。写事务步骤如下:
- CPU 将要写入的内存地址 A 放入系统总线上。
- 通过
IO bridge
的信号转换,将内存地址 A 传递到存储总线上。 - 存储控制器感触到存储总线上的地址信号,将内存地址 A 从存储总线上读取进去,并期待数据的达到。
- CPU 将寄存器中的数据拷贝到系统总线上,通过
IO bridge
的信号转换,将数据传递到存储总线上。 - 存储控制器感触到存储总线上的数据信号,将数据从存储总线上读取进去。
- 存储控制器通过内存地址 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 拜访内存对齐的地址时,比方 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. 字段重排列》这一大节介绍的三种字段对齐规定,是保障在字段内存对齐的根底上使得实例数据区占用内存尽可能的小。
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。
最初感激大家能看到这里,咱们下篇文章再见~~~