前言
本文隶属于专栏《100个问题搞定Java虚拟机》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!
本专栏目录结构和参考文献请见100个问题搞定Java虚拟机
正文
Java 虚拟机中 对 synchronized 关键字的实现,按照代价由高至低可分为重量级锁、轻量级锁和偏向锁三种。
当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。
偏向锁
JVM 会利用 CAS 操作,在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。
在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。
这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。
当没有竞争出现时,默认会使用偏向锁。它针对的是锁仅会被同一线程持有的情况。
实现原理
对象创建
当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(默认所有class的偏向模式都是是开启的),那新创建对象的mark word将是可偏向状态,此时mark word中的thread id 为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。
加锁过程
场景 1
当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改成当前线程Id。
- 如果成功,则代表获得了偏向锁,继续执行同步块中的代码。
- 否则,将偏向锁撤销,升级为轻量级锁。
场景 2
当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后,会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;
由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。
场景 3
当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint 中去查看偏向的线程是否还存活
- 如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;
- 如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。
偏向锁升级的时机
当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。
当然这个说法不绝对,因为还有批量重偏向这一机制。
解锁过程
当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。
因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock record的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。
锁状态的转换流程
另外,偏向锁默认不是立即就启动的,在程序启动后,通常有几秒的延迟,可以通过命令
-XX:BiasedLockingStartupDelay=0 来关闭延迟。
轻量级锁
如果有另外的线程试图锁定某个已经被偏向过的对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。
轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。
轻量级锁针对的是多个线程在不同时间段申请同一把锁的情况。
实现原理
线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。
下图右边的部分就是一个Lock Record。
加锁过程
- 在线程栈中创建一个Lock Record,将其obj(即上图的Object reference)字段指向锁对象。
- 直接通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。然后结束。
- 走到这一步说明发生了竞争,需要膨胀为重量级锁。
解锁过程
- 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
- 如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue。
- 如果Lock Record的Displaced Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为Displaced Mark Word。如果成功,则continue,否则膨胀为重量级锁。
重量级锁
重量级锁会阻塞、唤醒请求加锁的线程。
它针对的是多个线程同时竞争同一把锁的情况。
Java 虚拟机采取了自适应自旋,来避免线程在面对非常小的 synchronized 代码块时,仍会被阻塞、唤醒的情况。
实现原理
重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现 Java 中的线程同步。
重量级锁的状态下,对象的 markword 为指向一个堆中 monitor 对象的指针。
一个 monitor 对象包括这么几个关键字段: cxq (下图中的 ContentionList ), EntryList , WaitSet , owner 。
其中 cxq , EntryList , WaitSet 都是由 ObjectWaiter 的链表结构, owner 指向持有锁的线程。
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个 ObjectWaiter 对象插入到 cxq 的队列尾部,然后暂停当前线程。
当持有锁的线程释放锁前,会将 cxq 中的所有元素移动到 EntryList 中去,并唤醒 EntryList 的队首线程。
如果一个线程在同步块中调用了 Object # wait 方法,会将该线程对应的 ObjectWaiter 从 EntryList 移除并加入到 WaitSet 中,然后释放锁。 当 wait 的线程被 notify 之后,会将对应的 ObjectWaiter 从 WaitSet 移动到 EntryList 中。
补充
对象头
因为在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(比如当前哪个线程持有锁,哪些线程在等待)。
一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:
- 需要对map做线程安全保障,不同的synchronized之间会相互影响,性能差;
- 另外当同步对象较多时,该map可能会占用比较多的内存。
所以最好的办法是将这个映射关系存储在对象头中,因为对象头本身也有一些hashcode、GC相关的数据,所以如果能将锁信息与这些信息共存在对象头中就好了。
在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。
关于对象头的更多内容请参考我的这篇博客——对象在堆内存中的存储布局是怎样的?
类型指针是指向该对象所属类对象的指针,mark word用于存储对象的HashCode、GC分代年龄、锁状态等信息。
在32位系统上mark word长度为32字节,64位系统上长度为64字节。
为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式如下:
可以看到锁信息也是存在于对象的mark word中的。
- 当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID;
- 当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;
- 当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。
锁的降级
当 JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级。
关于安全点请参考我的这篇博客——安全点和安全区域是什么意思?
自旋锁
竞争锁的失败的线程,并不会真实的在操作系统层面挂起等待,而是JVM会让线程做几个空循环(基于预测在不久的将来就能获得),在经过若干次循环后,如果可以获得锁,那么进入临界区,如果还不能获得锁,才会真实的将线程在操作系统层面进行挂起。
适用场景
自旋锁可以减少线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的代码块来说,有较大的性能提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。
如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,造成cpu的浪费。
偏向锁的局限性
偏向锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善。
实践中对于偏向锁的一直是有争议的,有人甚至认为,当你需要大量使用并发类库时,往往意味着你不需要偏向锁。
从具体选择来看,笔者建议需要在实践中进行测试,根据结果再决定是否使用。
还有一方面是,偏向锁会延缓 JIT 预热的进程,所以很多性能测试中会显式地关闭偏向锁,命令如下:
-XX:-UseBiasedLocking
关于 Java 并发的更多内容可以关注我的另一篇专栏—— 100个问题搞定Java并发