2.4 synchronzied同步锁标记存储分析
如果synchronized同步锁想要实现多线程访问的互斥性,就必须保证多个线程竞争同一个资源,这个资源有点类似于生活中停车位上的红绿指示灯,绿灯表示车位闲置可以停车,红灯表示车位繁忙不能停车。在synchronized中,这个共享资源就是synchronized(lock)中的lock锁对象。
这就是对象锁和类锁能够影响锁的作用范围的原因,如果多个线程访问多个锁资源,就不存在竞争关系,也达不到互斥的效果,就像生活中两个停车位上的两个红绿指示灯,此时如果有两辆车停车,这两辆车之间就不会有竞争关系。
所以,从这个层面来看,要实现锁互斥要满足如下两个条件。
• 必须竞争同一个共享资源。
• 需要有一个标记来识别当前锁的状态是空闲还是繁忙。
第一个条件通过lock锁对象来实现即可,第二个条件需要有一个地方来存储抢占锁的标记,否则当其他线程来抢占资源时,不知道当前是应该正常执行还是应该排队,实际上,这个锁标记是存储在对象头中的,下面来简单分析一下对象头。
2.4.1 揭秘Mark Word的存储结构
一个Java对象被初始化之后会存储在堆内存中,那么这个对象在堆内存中存储了哪些信息呢?
Java对象存储结构可以分为三个部分:对象头、实例数据、对齐填充。当我们构建一个Object lock=new Object()对象实例时,这个lock实例最终的存储结构就对应如图2-6所示的模型。
图2-6 对象在内存中的布局模型
下面分别针对对象头、实例数据、对齐填充的作用和存储结构进行详细的说明。
2.4.1.1 对象头
Java中对象头由三个部分组成:Mark Word、Klass Pointer、Length。
Mark Word
Mark Word记录了与对象和锁相关的信息,当这个对象作为锁对象来实现synchronized的同步操作时,锁标记和相关信息都是存储在Mark Word中的,具体的相关存储结构如图2-7所示。
图2-7 32位系统中Mark Word的存储结构
在32位系统中,Mark Word的长度是4字节,在64位系统中,Mark Word的长度是8字节,如图2-8所示。
图2-8 64位系统中Mark Word的存储结构
不管在32位还是64位系统中,Mark Word中都会包含GC分代年龄、锁状态标记、hashCode、epoch等信息。从图中可以看到一个锁状态的字段,它包含五种状态分别是无锁、偏向锁、轻量级锁、重量级锁、GC标记。Mark Word使用2bit来存储这些锁状态,但是我们都知道2bit最多只能表达四种状态:01、00、10、11,那么第五种状态如何表达呢?Mark Word额外通过1bit来表达无锁和偏向锁,其中0表示无锁、1表示偏向锁。
关于不同锁的状态,笔者在后续的内容中会详细说明。
Klass Pointer
Klass Pointer表示指向类的指针,JVM通过这个指针来确定对象具体属于哪个类的实例。
它的存储长度根据JVM的位数来决定,在32位的虚拟机中占4字节,在64位的虚拟机中占8字节,但是在JDK 1.8中,由于默认开启了指针压缩,所以压缩后在64位系统中只占4字节。
Length
表示数组长度,只有构建对象数组时才会有数组长度属性。
2.4.1.2 实例数据
实例数据其实就是类中所有的成员变量,比如,一个对象中包含int、boolean、long等类型的成员变量,这些成员变量就存储在实例数据中。
实例数据占据的存储空间是由成员变量的类型决定的,比如boolean占1字节、int占4字节、long占8字节。如果成员变量是引用类型,那么它的数据大小与虚拟机位数和是否开启压缩指针有关系。
2.4.1.3 对齐填充
对齐填充本身没有任何含义,其目的是使得当前对象实例占用的存储空间是8字节的倍数,所以如果一个对象的字节大小不是8字节的整数倍,会使用对齐填充来达到这一目的。
为什么要通过增加存储空间来做填充呢?其实,这类的设计基本上都离不开空间换时间的理念。深层次的原因在于减少CPU访问内存的频率,从而达到性能提升的效果,对于这部分的分析,笔者会在第3章中详细说明。
2.4.2 图解分析对象的实际存储
为了让读者更好地理解对象在内存中的布局,我们使用下面这个程序来进行详细说明。
从上述代码中可以看到,在main()方法中定义了MarkWordExample对象实例,并且该对象包含两个成员变量:id和name。在main()方法运行之后,就会形成如图2-9所示的存储结构。
图2-9 对象在内存中的存储结构
2.4.3 通过ClassLayout查看对象内存布局
为了更加直观地看到一个对象的内存布局信息,OpenJDK官方提供了一个JOL(Java Object Layout)工具,使用步骤如下。
第一步,通过maven依赖引入JOL工具。
第二步,创建一个普通对象。
第三步,通过JOL工具打印对象的内存布局。
第四步,运行结果如下。
字段说明:
• OFFSET:偏移地址,单位为字节。
• SIZE:占用的内存大小,单位为字节。
• TYPE DESCRIPTION:类型描述,其中object header为对象头。
• VALUE:对应内存中当前存储的值。
上述内容的解读如下:
• TYPE DESCRIPTION字段对应的部分表示对象头(object header),一共占12字节,前面的8字节对应的是对象头中的Mark Word,最后4字节表示类型指针,它只占4字节是因为默认对指针进行了压缩。
• TYPE DESCRIPTION字段对应的(loss due to the next object alignment)描述部分,表示对齐填充,这里填充了4字节,从而保证最终的内存大小是8字节的整数倍。最终输出的Instance size: 16 bytes表示当前对象实例占16字节。
由于ClassLayoutExample只是一个空对象定义,因此在打印结果中只有对象头和对齐填充,没有实例数据部分。
2.4.3.1 关于压缩指针
在默认打印的对象内存布局信息中,Klass Pointer被压缩成4字节,如果我们不希望开启压缩指针功能,则可以增加一个JVM参数-XX:-UseCompressedOops。再次运行ClassLayoutExample,得到的结果如下。
从结果来看,Klass Pointer由4字节变成了8字节,而此时该对象的大小正好是16字节,是8字节的整数倍,因此不需要进行填充了。
2.4.3.2 详述对齐填充的作用
CPU在访问内存读取数据时,并不是按照逐个字节来访问的,而是以字长(Word Size)为单位来访问的。简单地说,字长是指CPU一次能够并行处理的二进制位数,字长总是8字节的整数倍。
比如在64位的操作系统中,CPU访问内存读取数据的单位就是8字节,在32位的操作系统中,CPU访问内存读取数据的单位是4字节,这样设计的目的是减少CPU访问内存的次数,提升CPU的使用率。
假设一个变量在内存中的存储跨越两个字长,形成如图2-10所示的结构,比如一个int类型的变量y占4字节,图2-8左边表示未对齐填充的内存布局,它会存在跨字长存储,右边表示对齐填充后的内存布局,不存在跨字长存储的情况。
如图2-11所示,在未对齐填充的内存布局中,CPU要读取变量y,由于跨越了两个字长,所以需要访问两次内存,第一次读取第一个字长获得最后三个有效字节,第二次读取第二个字长获得第二个字长的第一个有效字节,然后在寄存器中进行拼接。
图2-10 内存布局
图2-11 未对齐填充的数据读取方式
但是在对齐填充的内存布局中,CPU读取变量x或者y,都只需要一次内存访问,虽然做了无效填充,但是访问内存的次数减少了,这种方式的计算性能更高,因此本质上来说这就是一种空间换时间的设计方式。
2.4.4 Hotspot虚拟机中对象存储的源码
在Hotspot虚拟机中,我们在使用new来创建一个普通对象实例的时候,实际上在JVM层面会创建一个instanceOopDesc对象,而如果对象实例是数组类型,则会创建一个arrayOopDesc对象。instanceOopDesc对象的定义在Hotspot源码的instanceOop.hpp文件中,arrayOopDesc对象定义在Hotspot源码的arrayOop.hpp文件中。
当在Java中实例化一个对象时,在JVM中会创建一个instanceOopDesc对象,该对象定义在instanceOopDesc.hpp文件中,核心代码如下。
instanceOopDesc继承了oopDesc,oopDesc的定义在oop.hpp文件中,代码如下。
这种写法给出了C++中的继承关系,在普通实例对象中,oopDesc的定义包含两个成员,分别是_mark和_metadata,Hotspot虚拟机采用OOP-Klass模型来描述Java对象实例,OOP(Ordinary Object Point)指的是普通对象指针,Klass用来描述对象实例的具体类型。
• _mark表示对象标记,属于markOop类型,也就是前面提到的Mark Word,它记录了对象和锁有关的信息。
• _metadata表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址。
○ Klass表示普通指针,指向该对象的类元信息,也就是属于哪一个Class实例。
○ _compressed_klass表示压缩指针,默认开启了压缩指针,在开启压缩指针之后,存储中占用的字节数会被压缩。
接着我们重点关注markOop这个对象属性,markOop是一个markOopDesc类型的指针,它的定义在oopsHierarchy.hpp文件中。
在Hotspot中,markOopDesc这个类的定义在markOop.hpp文件中,代码如下:
实际上,在markOop.hpp文件的注释中,同样可以看到Mark Word在32位和64位虚拟机上的存储布局。
至此,我们从JVM的源码中完整地验证了与对象头相关的存储信息。