![Java并发编程深度解析与实战](https://wfqqreader-1252317822.image.myqcloud.com/cover/971/43737971/b_43737971.jpg)
2.4 synchronzied同步锁标记存储分析
如果synchronized同步锁想要实现多线程访问的互斥性,就必须保证多个线程竞争同一个资源,这个资源有点类似于生活中停车位上的红绿指示灯,绿灯表示车位闲置可以停车,红灯表示车位繁忙不能停车。在synchronized中,这个共享资源就是synchronized(lock)中的lock锁对象。
这就是对象锁和类锁能够影响锁的作用范围的原因,如果多个线程访问多个锁资源,就不存在竞争关系,也达不到互斥的效果,就像生活中两个停车位上的两个红绿指示灯,此时如果有两辆车停车,这两辆车之间就不会有竞争关系。
所以,从这个层面来看,要实现锁互斥要满足如下两个条件。
• 必须竞争同一个共享资源。
• 需要有一个标记来识别当前锁的状态是空闲还是繁忙。
第一个条件通过lock锁对象来实现即可,第二个条件需要有一个地方来存储抢占锁的标记,否则当其他线程来抢占资源时,不知道当前是应该正常执行还是应该排队,实际上,这个锁标记是存储在对象头中的,下面来简单分析一下对象头。
2.4.1 揭秘Mark Word的存储结构
一个Java对象被初始化之后会存储在堆内存中,那么这个对象在堆内存中存储了哪些信息呢?
Java对象存储结构可以分为三个部分:对象头、实例数据、对齐填充。当我们构建一个Object lock=new Object()对象实例时,这个lock实例最终的存储结构就对应如图2-6所示的模型。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-068-01.jpg?sign=1739190581-0FhUbkr3AKpxXKivKloIFENUzxdiNYWR-0-7c6d6ed8a9e8f8b3a543948f8dd3902c)
图2-6 对象在内存中的布局模型
下面分别针对对象头、实例数据、对齐填充的作用和存储结构进行详细的说明。
2.4.1.1 对象头
Java中对象头由三个部分组成:Mark Word、Klass Pointer、Length。
Mark Word
Mark Word记录了与对象和锁相关的信息,当这个对象作为锁对象来实现synchronized的同步操作时,锁标记和相关信息都是存储在Mark Word中的,具体的相关存储结构如图2-7所示。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-069-01.jpg?sign=1739190581-gzbmbm38GPwhjkFGIH0q99qz9aGffIYe-0-89bfd32faa1a2c268ac79655369516aa)
图2-7 32位系统中Mark Word的存储结构
在32位系统中,Mark Word的长度是4字节,在64位系统中,Mark Word的长度是8字节,如图2-8所示。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-069-02.jpg?sign=1739190581-H8QePWQjuEHdiLtvE1pI4E9HvQs48nme-0-676c2567ebfa287f37f71282fbbfcbbf)
图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 图解分析对象的实际存储
为了让读者更好地理解对象在内存中的布局,我们使用下面这个程序来进行详细说明。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-070-1.jpg?sign=1739190581-tDt49eVHL4m6tDf1BcxZxVu4lAHiss6H-0-e3063a9cbe6318e97b31442d3525d31a)
从上述代码中可以看到,在main()方法中定义了MarkWordExample对象实例,并且该对象包含两个成员变量:id和name。在main()方法运行之后,就会形成如图2-9所示的存储结构。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-071-01.jpg?sign=1739190581-gYNspcgz4k0kItf5AzubrHqqgo81qK2M-0-d3f02383e910b94630738af1adb74a96)
图2-9 对象在内存中的存储结构
2.4.3 通过ClassLayout查看对象内存布局
为了更加直观地看到一个对象的内存布局信息,OpenJDK官方提供了一个JOL(Java Object Layout)工具,使用步骤如下。
第一步,通过maven依赖引入JOL工具。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-071-1.jpg?sign=1739190581-YZPLnSjh0lcBZ0VoETvY2l4RI1OnAb7Z-0-731e1abc48cf885c16cdeae9e7f26c7a)
第二步,创建一个普通对象。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-071-2.jpg?sign=1739190581-PMJ0rKGBY0G4OfYZX9r5N2QUkcGW96Gv-0-06dc8cbc4c730e694c6854127a14a7db)
第三步,通过JOL工具打印对象的内存布局。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-072-1.jpg?sign=1739190581-Xj54eTXs9lCHfU7tAKUQwVfJPLwhcC74-0-69c22581779368427573376a42655863)
第四步,运行结果如下。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-072-2.jpg?sign=1739190581-KjSZqxZNQWmVf4mctzyYA2fCEZXdy50c-0-dd64e305c5ab23eb1b6577bbc539d321)
字段说明:
• 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,得到的结果如下。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-073-1.jpg?sign=1739190581-8AbLhl5xrX6OzoAUuFGHa0tzuMDe5axL-0-7da87f7df426e2664922939a6e9ff04a)
从结果来看,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,由于跨越了两个字长,所以需要访问两次内存,第一次读取第一个字长获得最后三个有效字节,第二次读取第二个字长获得第二个字长的第一个有效字节,然后在寄存器中进行拼接。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-074-01.jpg?sign=1739190581-lMu3GXLDvwBmLm8bke031iz02cRyWjup-0-a43d57093b628213c65f30e00ec24a53)
图2-10 内存布局
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-074-02.jpg?sign=1739190581-dE8cbqXyuatPWs3N9WNuCucYYy4wA3YL-0-9e2e21f0980507146ca78ee0544df2b1)
图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文件中,核心代码如下。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-075-1.jpg?sign=1739190581-w8q4OcNwH8tKFL6l00gTRHg5dyCpxMxj-0-b999b2b8a21d94bb71d5153690d3a2df)
instanceOopDesc继承了oopDesc,oopDesc的定义在oop.hpp文件中,代码如下。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-075-2.jpg?sign=1739190581-IrOPWc5XbAdgRS07LzcMq4h2Dmo16bUq-0-120cbd883915b73f9754695225ab2dfc)
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-076-1.jpg?sign=1739190581-30sioi9pUpUWpiagmxx9ajXf5dIrHQAW-0-bfa70ea2954be1796231bd9236fd85a4)
这种写法给出了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文件中。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-076-2.jpg?sign=1739190581-mgrw2feD0cdBOoveVC6ZHUROEs4TV8Hh-0-5e33f6fcde7459b6482e5028549d81b6)
在Hotspot中,markOopDesc这个类的定义在markOop.hpp文件中,代码如下:
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-076-3.jpg?sign=1739190581-NMN5cjJnEIglp0L3nLo7HWXJFPM1T4Gh-0-73b0241946a507959555d668cba03d6b)
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-077-1.jpg?sign=1739190581-lSXyGqIHgMrcAGJU8DLV7FrZo1JF0mit-0-9cbeb10245dc5d0ded06599432c17402)
实际上,在markOop.hpp文件的注释中,同样可以看到Mark Word在32位和64位虚拟机上的存储布局。
![](https://epubservercos.yuewen.com/11B439/23020636309729206/epubprivate/OEBPS/Images/42136-00-077-2.jpg?sign=1739190581-BGcyIz2sSzdbtq6Necb8ghdts6GnCT92-0-2c86a607ecfacfaeb5930a29e4dac03c)
至此,我们从JVM的源码中完整地验证了与对象头相关的存储信息。