synchronized 锁优化(二):锁的状态及锁膨胀

本文中所提及的锁指的均是 JVM 提供的 synchronized.

在并发编程中,synchronized 一直被称为重量级锁,但是随着 JDK1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重了。本文将介绍从 JDK1.6 开始引入的新的锁状态的概念以及锁升级的过程(即所谓的锁膨胀)。

 

Java 对象头

在正式介绍之前,先要明确一下对象头这个概念。

synchronized 用的锁是存在 Java 对象头里的。

如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32 bit,如下表所示

synchronized 锁优化(二):锁的状态及锁膨胀_第1张图片
Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。

32 位 JVM 的 Mark Word 的默认存储结构如下所示。

在这里插入图片描述

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下 4 种数据。(下面表格的内容你现在可以不用记住,但是后续的介绍会多次提及 Mark Word ,请自行返回来查看表格内容)
synchronized 锁优化(二):锁的状态及锁膨胀_第2张图片
注意:上述表格中的四行,都是独立的,换句话说,Mark Word 只能处于这几种状态中的一种(算上默认的无锁状态的话,就是只能处于 5 种状态中的 1 种)

另外,上述表格图片非原创,源自《Java 并发编程的艺术》 - 方腾飞 魏鹏 程晓明著
 

Java 锁(synchronized)的四种状态

从 JDK 1.6 开始引入了偏向锁轻量级锁,从而让锁拥有了四个状态:

  • 无锁状态(unlocked):锁标志位为 01
  • 偏向锁状态(biasble):锁标志位为 01(无锁状态标志位也为 01,但是注意上面的 Mark Word 状态变化的表格,有 1bit 的空间用来指示是否是偏向锁,是的话该 1bit 为 1,反之则为 0,即处于偏向锁状态的话,这 1bit 位为 1,而无锁这 1bit 位为 0,但这两个状态的锁标志位都是 01)
  • 轻量级锁状态(lightweight locked):锁标志位为 00
  • 重量级锁状态(inflated):锁标志位为 10

这几种锁的级别从低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。这几个状态会随着竞争情况逐渐升级,但要注意的是除了偏向锁可以恢复到无锁状态以外,只允许锁升级不允许降级,比如由偏向锁升级成轻量级锁之后,不能再降级为偏向锁。

由于无锁状态比较简单,而重量级锁就是我们最熟悉的锁,所以后文将仅对 JDK1.6 新引入的这两个锁状态:偏向锁、轻量级锁进行详细的介绍。

一、偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁的核心思想是偏向于第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

(1) 加锁过程

工作流程是这样的,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构(上述表格中的最后一行,请返回查看),并且使用 CAS 操作将当前线程的 ID 记录到对象头的 Mark Word 中。当该线程再次请求锁时,无需再做任何同步操作,连 CAS 都不需要了,只需要检查对象头中 Mark Word 里的 Thread ID 是否是此线程的 ID,是的话就说明此线程获取锁成功。

如果不是的话,那么还需检查 Mark Word 中偏向锁的标识是否设置成 1(就是之前我说的那个 1bit 标识位,1 表示当前是偏向锁)如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。

(2) 偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,
持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(Safe point 安全点这个概念对了解 Java 虚拟机的读者应该并不陌生,GC 的工作也需要在安全点进行,在这个时间点上没有正在执行的字节码)。

它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他
线程,要么恢复到无锁(此时锁降级为无锁状态)或者标记对象不适合作为偏向锁(此时升级为轻量级锁),最后唤醒暂停的线程。

(3) 补充

偏向锁从 JDK1.6 以后开始是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay = 0

如果你确定应用程序里所有的锁通常情况下都处于竞争状态,可以通过 JVM 参数来关闭偏向锁:-XX:-UseBiasedLocking = false,此时程序默认会进入轻量级锁状态。

 

二、轻量级锁

(1) 轻量级锁加锁:

步骤1:

线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录(Lock Record)的空间,并将对象头中的 Mark Word (此时 Mark Word 处于默认存储结构即无锁状态)复制到锁记录(Lock Record)中,官方称为 Displaced Mark Word 。

此时的 Mark Word:
在这里插入图片描述
将其拷贝到当前线程的栈帧中的锁记录(Lock Record)的空间,具体来说是拷贝到下图中左侧当前线程的栈帧中的 displaced hdr 的位置。

如下所示,左侧为当前线程的栈帧,它是线程私有的,右侧即为 Mark Word。

synchronized 锁优化(二):锁的状态及锁膨胀_第3张图片

步骤2:

然后线程尝试使用 CAS 将对象头的 Mark Word 替换为指向锁记录(Lock Record)的指针,将 owner 指针更改为指向对象头的 Mark Word 指针(这里看不明白的请返回 Java 对象头那一小节中查看 Mark Word 状态变化表那里,结合轻量级锁的存储布局来理解)。

如果成功,当前线程获得锁,并且对象头的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。,如果失败,表示其它线程竞争锁,当前线程便尝试使用自旋来获取锁。

synchronized 锁优化(二):锁的状态及锁膨胀_第4张图片
如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行。

否则说明多个线程竞争锁,那轻量级锁就不再有效,要膨胀为重量级锁。此时锁标志的状态变为 ”01“ ,Mark Word 存储的就是指向重量级锁(互斥量)的指针(结合对象头那 Mark Word 状态变化表那里,结合重量级锁的存储布局来理解)。

(2) 轻量级锁解锁:

轻量级锁解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生,整个同步过程就完成了。

如果失败,表示当前锁存在竞争(由上面的轻量级锁加锁过程我们这里应该知道,此时锁已经膨胀成重量级锁了)。此时释放锁,并唤醒被挂起的线程。

 

几种锁的适用场景

从上面的叙述我们可以总结一下这几种锁的适用场景:

  • 偏向锁:只有一个线程进入临界区
  • 轻量级锁:多个线程交替进入临界区
  • 重量级锁:多个线程同时进入临界区

而且不难看出:

  • 偏向锁使用 CAS 操作 + 检查 Mark Word 中的 Thread ID 来获取锁,所以它是一种乐观锁
  • 轻量级锁使用 CAS 操作 + 锁自旋来获取锁,所以它也是一种乐观锁

重量级锁是悲观锁。

到这里,相信你也应该明白了为什么文章一开头说,在 JDK1.6 对 synchronized 进行了优化后,它变得没那么重量级了。(对 synchronized 的优化还不仅仅如此,还有诸如自旋锁、自适应自旋锁、锁粗化、锁消除等优化,这些并非是本文的重点,这里不多介绍)

 

锁膨胀

看完了偏向锁和轻量级锁的介绍,也应该对锁膨胀的流程有个大致的了解了,我们接下来再梳理一下锁膨胀的整个过程。

锁膨胀方向:无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁

注:我们之前经常会听到锁升级是不允许降级的,这指的是 ”偏向锁 —> 轻量级锁 —> 重量级锁“ 这个过程,不包括 ”无锁 —> 偏向锁 “,当然你也可以这么理解,无锁状态本身就没有锁,所以何来锁升级一说呢?

1. 从无锁到偏向锁

一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁,偏向第一个线程。这个线程在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的 ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS.

2. 从偏向锁到轻量级锁

一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁( 偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。

3. 从轻量级锁到重量级锁

轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止 CPU 空转。

你可能感兴趣的:(Java并发)