今天笔者在阅读《Java并发编程的艺术》时,阅读到了Java并发机制的底层实现原理一章中的synchronized部分,觉得作者对Java对象头的描述有些模糊,然后在笔者脑海中,就想起来了前一阵阅读《深入理解Java虚拟机》时,正好阅读过Java对象的内存布局,不过由于笔者智商较低,对这一部分的记忆也有些许模糊了…
正所谓:好记性不如烂笔头。于是!笔者将这两个“模糊”的部分结合到一个文章中,以加深自己的记忆。
这篇文章完全是站在巨人的肩膀上,如果想深入了解Java并发编程与JVM可以去阅读下面的参考书籍
参考书籍:《Java并发编程的艺术》与《深入理解Java虚拟机·JVM高级特性与最佳实践》
吐槽:Typora为什么没有合并单元格的功能,HTML手敲表格真的好累!!!!!
synchronized的实现原理
这里直接引用书中的内容
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
monitorenter是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorexit指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
synchronized的应用
Java中的每一个对象都可以作为锁,也就是我们常说的“Java中每个对象都持有一把锁”,这是synchronized实现同步的基础。
synchronized实现同步具体表现为以下三种形式:
- 对于普通同步方法,锁是当前实例对象
- 对于静态同步方法,锁是当前类的Class对象
- 对于同步方法块,锁是synchronized括号里配置的对象
学习过多线程的读者都知道,当一个线程试图访问同步代码块时,它必须首先得到对象的锁,退出或者抛出异常时必须释放锁。
那么锁到底在哪里呢?锁里面会存储什么信息呢?
Java对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对其填充(Padding),下面分别讲述这三部分。
Java对象头
HotSpot虚拟机对象的对象头包括两类信息。但如果对象是一个Java数组,那么对象头中还必须有一块用于记录数组长度的数据,这部分就不作重点说明了,我们主要看前两类信息。
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机中分别为32个比特和64个比特,官方称它为“Mark Word”。
以32位HotSpot虚拟机为例,当对象未被同步锁锁定时,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0。如下表所示:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的HashCode | 对象分代年龄 | 0 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4中数据,如表所示:
锁状态 | 25bit | 4bit | 1bit | 2bit | |
---|---|---|---|---|---|
23bit | 2bit | 是否是偏向锁 | 锁标志位 | ||
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标记 | 空 | 11 | |||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
在64位虚拟机下,Mark Word的存储结构如表所示:
锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit | 2bit |
---|---|---|---|---|---|---|
cms_free | 分代年龄 | 偏向锁 | 锁标志位 | |||
无锁 | unused | HashCode | 0 | 01 | ||
偏向锁 | ThreadID(54bit) Epoch(2bit) | 1 | 01 |
对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定是哪个类的实例。
实例数据部分
实例数据部分是对象真正存储的有效信息,即我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPS),从以上默认的分配策略中可以看到,相同宽度的字段总是被被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
对齐填充
对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起者占位符的作用。由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数,因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
锁的升级与优化
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但却不可以降级,目的是为了提高获得锁和释放锁的效率。
偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引人了偏向锁。
下面就分析一下线程获得偏向锁所经历的过程:
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进人和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
如果测试成功,表示线程已经获得了锁。
如果测试失败则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):
- 如果没设置,则使用CAS竞争锁;
- 如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
对于CAS这里要简单说明一下:Java的Atomic包使用CAS算法来更新数据,而不需要加锁。也就是一种更新数据的方法。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着
- 如果线程不处于活动状态,则将对象头设置成无锁状态
- 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁
最后唤醒暂停的线程
轻量级锁
轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
最后希望大家保持自律,努力学习!