https://ost.51cto.com/posts/14747
大家好,我是bin,又到了每周我们见面的时刻了,我的公众号在1月10号那天发布了第一篇文章?《从内核角度看IO模型的演变》,在这篇文章中我们通过图解的方式以一个C10k的问题为主线,从内核角度详细阐述了5种IO模型的演变过程,以及两种IO线程模型的介绍,最后引出了Netty的网络IO线程模型。读者朋友们后台留言都觉得非常的硬核,在大家的支持下这篇文章的目前阅读量为2038,点赞量为80,在看为32。这对于刚刚诞生一个多月的小号来说,是一种莫大的鼓励。在这里bin再次感谢大家的认可,鼓励和支持~~
今天bin将再来为大家带来一篇硬核的技术文章,本文我们将从计算机组成原理的角度详细阐述对象在JVM内存中是如何布局的,以及什么是内存对齐,如果我们头比较铁,就是不进行内存对齐会造成什么样的后果,最后引出压缩指针的原理和应用。同时我们还介绍了在高并发场景下,False Sharing产生的原因以及带来的性能影响。
相信大家看完本文后,一定会收获很多,话不多说,下面我们正式开始本文的内容~~
本文概要.png
在我们的日常工作中,有时候我们为了防止线上应用发生OOM,所以我们需要在开发的过程中计算一些核心对象在内存中的占用大小,目的是为了更好的了解我们的应用程序内存占用的一个大概情况。
进而根据我们服务器的内存资源限制以及预估的对象创建数量级计算出应用程序占用内存的高低水位线,如果内存占用量超过高水位线,那么就有可能有发生OOM的风险。
我们可以在程序中根据估算出的高低水位线,做一些防止OOM的处理逻辑或者发出告警。
那么核心问题是如何计算一个Java对象在内存中的占用大小呢??
在为大家解答这个问题之前,笔者先来介绍下Java对象在内存中的布局,也就是本文的主题。
1. Java对象的内存布局
Java对象的内存布局.png
如图所示,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的需要填充字节。
2.在开启了压缩指针的64位JVM中,Java类中的第一个字段的OFFSET需要对齐至4N,在关闭压缩指针的情况下类中第一个字段的OFFSET需要对齐至8N。
3.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
上边的三条字段重排列规则非常非常重要,但是读起来比较绕脑,很抽象不容易理解,笔者把它们先列出来的目的是为了让大家先有一个朦朦胧胧的感性认识,下面笔者举一个具体的例子来为大家详细说明下,在阅读这个例子的过程中也方便大家深刻的理解这三条重要的字段重排列规则。
假设现在我们有这样一个类定义
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 开启压缩指针,关闭字段压缩
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字节。
2.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字节。
2.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字节。
2.4 -XX:-UseCompressedOops -XX +CompactFields 关闭压缩指针,开启字段压缩
在第三种情况的分析基础上,我们来看下第四种情况的字段排列情况:
image.png
由于在关闭指针压缩的情况下类型指针的大小变为了8个字节,所以导致Child对象中第一个字段Parent.l前边并没有空隙,刚好对齐8N,并不需要int型变量的插入。所以即使开启了字段压缩-XX +CompactFields,字段的总体排列顺序还是不变的。
默认情况下指针压缩-XX:+UseCompressedOops以及字段压缩-XX +CompactFields都是开启的