一个Java对象,依据Hotspot的实现来讲,分为三块区域:对象头,实例数据,对齐填充块,如下图
首先来认识下对象头
对象头由两部分组成:一部分是Markword,另一部分是类型指针;
Markword在32位操作系统中占用4字节,在64位操作系统中占用8字节。
下图是Markword在32位系统中的存储示例:
从图中可见,在32位地址的Markword中,前25位是对象的HashCode,后4位是对象的分代年龄,后2位是锁标志位,最后一位固定为0
HashCode类似于对象的ID,通过Hash算法生成,常用equals()比较对象是否相等;
分代年龄是指该对象经历了多少次垃圾回收,默认情况下,一个对象在新生代中经历15次垃圾回收(分代年龄>15),仍然存活的话,便会进入老年代;
锁标志位是JVM用来识别该对象是否被上锁,以及锁的级别(JVM根据锁膨胀过程会有偏向锁,轻量级锁和重量级锁三个等级);
考虑JVM的空间效率,Markword的结构是动态的,会根据对象的不同状态而变化(被锁定,被垃圾回收等)
类型指针(Class Pointer)记录的是该对象类型在MetaSpace的地址引用
比如new JavaObject()
这个对象,类型指针记录的就是JavaObject.class
的地址引用
类型指针占用的内存大小分两种情况,当开启对象压缩时占用4字节(JVM默认开启),关闭时占用8字节
关闭压缩指针参数:-XX:-UseCompressedOops
这一块就是实例数据的大小,举个例子来说明,我们定义一个叫JavaObject
的对象如下
public class JavaObject {
private int i;
private long l;
private Object obj;
private List list;
}
这个对象中定义了两个基本类型,两个引用类型,我们知道int类型占4字节,long类型8字节,引用类型默认是占4字节,关闭指针压缩后占8字节。
所以JavaObject
对象的实例数据部分在开启指针压缩的内存占用是20字节
这个部分存在的目的是为了保持对象的大小与8字节的倍数对齐
假如一个对象占用12字节,12不是8的倍数,则需要填充4字节,16刚好是8的倍数,那么这块区域就会用0进行填充;
如果对象大小刚好等于8的倍数,如16,32等,则该区域大小为0。
下图中每一行代表一个对象,形象说明了Padding的作用
上图中为了展示Padding的填充效果,把对象换行了,实际上对象在内存中的存储是连续的,也就是内存地址是连续的,如下图所示
这里可能会有疑问,为什么要进行8字节内存对齐?
原因之一,在默认情况下,JVM堆中的对象默认要对齐8字节倍数,可以通过参数-XX:ObjectAlignmentInBytes
修改
还有一个原因,是由于CPU进行内存访问时,一次寻址的指针大小是8字节,正好也是L1缓存行的大小;如果不进行内存对齐,则可能出现跨缓存行的情况,这叫做缓存行污染
,如图所示:
之所以叫做“污染”,是由于当obj1对象的字段被修改后,那么CPU在访问obj2对象时,必须将其重新加载到缓存行,因此影响了程序执行效率。
Maven依赖
<dependency>
<groupId>org.openjdk.jolgroupId>
<artifactId>jol-coreartifactId>
<version>{version}version>
dependency>
这里依然使用上面的JavaObject
对象作为示例
public class JavaObject {
private int i;
private long l;
private Object obj;
private List list;
}
使用ClassLayout打印对象
ClassLayout classLayout = ClassLayout.parseInstance(new JavaObject());
System.out.println(classLayout.toPrintable());
可以在控制台中看到,当开启压缩指针(默认)时的内存布局如下
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 08 cb 16 00 (00001000 11001011 00010110 00000000) (1493768)
12 4 int JavaObject.i 0
16 8 long JavaObject.l 0
24 4 java.lang.Object JavaObject.obj (object)
28 4 java.util.List JavaObject.list (object)
Instance size: 32 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
OFFSET是偏移量,SIZE是占用的字节数,TYPE是类型描述,VALUE是这个类型具体的值(详见下图)。
可以看出,Instance size: 32 bytes
即表示该对象占用32字节,其中对象头(object header)占用12字节,实例数据部分占用20字节,由于32为8的倍数,所以没有对齐填充(Padding)部分。
当关闭压缩指针(-XX:-UseCompressedOops)时,对象内存布局如下
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) d8 c6 c0 33 (11011000 11000110 11000000 00110011) (868271832)
12 4 (object header) 02 00 00 00 (00000010 00000000 00000000 00000000) (2)
16 8 long JavaObject.l 0
24 4 int JavaObject.i 0
28 4 (alignment/padding gap)
32 8 java.lang.Object JavaObject.obj (object)
40 8 java.util.List JavaObject.list (object)
Instance size: 48 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
Space losses
记录了对齐所用的字节数
通过对比可以发现,关闭压缩指针后,在OFFSET=28的位置,有4字节的padding gap
,之所以padding会出现在中间部分,是因为接下来的字段类型都是引用类型,而OFFSET(0~28)的位置占用28字节,所以这里需要补4字节。
JVM会优先将内存大小相同的字段排列在一起,所以即使将对象的字段按照如下排列,JVM依然会将基本类型与引用类型分开排列,这叫做字段重排列
private int i;
private Object obj = new Object();
private long l;
private List list = new ArrayList();
为了演示Padding补齐在对象尾部的例子,需要将JavaObject
作如下调整,只保留一个int字段
public class JavaObject {
private int i;
}
再次打印信息如下
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 06 8e 12 (00101000 00000110 10001110 00010010) (311297576)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 4 int JavaObject.i 0
20 4 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
loss due to the next object alignment
直译过来:“由于下一个对象的基准线而丢失”,其实就是为了内存对齐。
首先需要明确的是,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型的数组。
什么是压缩指针呢?
在64位操作系统中,对象头中的类型指针占用64位(8字节),开启压缩指针后占用32位(4字节),压缩指针的目的即节省内存空间。
我们把内存空间抽象成下面这幅图,每一个带有数字的格子用来指代一块实际内存,那么下图抽象表示了8块连续等长的内存地址
在关闭压缩指针时,指针记录的地址就是实际的内存地址;
而开启压缩指针时,指针记录的是内存地址上的那个对象编号;
如此以来,4字节(32位)的引用,能够表示的最大数为 2 32 2^{32} 232,即最多能表示 2 32 2^{32} 232个对象;
但是又该如何得到对象的实际地址呢?
这里就需要基于对象内存对齐了,由于对象是内存对齐的,那么对象占用的内存就可以通过一个偏移量算出,比如上图中,要计算对象三的内存地址,已知前面有两个对象,每个对象占用2个格子,即对象三的地址就是2 * 2 + 1 = 5
当然,不是所有的对象都占用2个格子,实际运用中偏移量的计算并非这么简单。
上面,我们已经知道了指针压缩的过程,可能会发现一个新问题
2 32 2^{32} 232所能表示的最大内存只有4GB,而4GB空间在现在看来是非常小了,于是我们需要扩容
如何扩容呢?
我们可以把指针左移3位, 2 32 < < 3 = 2 35 2^{32}<<3=2^{35} 232<<3=235, 2 35 2^{35} 235能表示的最大内存就是32GB了
这也是为什么,当内存大于32GB时,开启指针压缩的参数会失效
扩容过后,在计算对象内存地址时,需要先将引用左移3位,然后加上偏移量,就能得到实际内存地址了。
至此,本文介绍了Java对象在内存中的存储结构,了解了一个对象是由对象头、实例数据、对齐填充三部分组成;虚拟机会按照字段占用的空间大小,对字段进行重排列;
接着,介绍了为了节省内存空间,虚拟机可以对指针进行压缩,了解了指针压缩的原理,以及压缩后的内存空间问题如何解决。
另外,若文中有不当或者有误之出,欢迎及时指正!