前言

本文隶属于专栏《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。

在这里插入图片描述

加锁过程

  1. 在线程栈中创建一个Lock Record,将其obj(即上图的Object reference)字段指向锁对象。
  2. 直接通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。
  3. 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。然后结束。
  4. 走到这一步说明发生了竞争,需要膨胀为重量级锁。

解锁过程

  1. 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
  2. 如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue。
  3. 如果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中的。

  1. 当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID
  2. 当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针
  3. 当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针

锁的降级

当 JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级。

关于安全点请参考我的这篇博客——安全点和安全区域是什么意思?

自旋锁

竞争锁的失败的线程,并不会真实的在操作系统层面挂起等待,而是JVM会让线程做几个空循环(基于预测在不久的将来就能获得),在经过若干次循环后,如果可以获得锁,那么进入临界区,如果还不能获得锁,才会真实的将线程在操作系统层面进行挂起。

适用场景

自旋锁可以减少线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的代码块来说,有较大的性能提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。

如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,造成cpu的浪费。

偏向锁的局限性

偏向锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善。

实践中对于偏向锁的一直是有争议的,有人甚至认为,当你需要大量使用并发类库时,往往意味着你不需要偏向锁。

从具体选择来看,笔者建议需要在实践中进行测试,根据结果再决定是否使用。

还有一方面是,偏向锁会延缓 JIT 预热的进程,所以很多性能测试中会显式地关闭偏向锁,命令如下:

-XX:-UseBiasedLocking

关于 Java 并发的更多内容可以关注我的另一篇专栏—— 100个问题搞定Java并发

上一篇 下一篇