多线程知识梳理(3) - synchronized 三部曲之锁优化

一、前言

在 多线程知识梳理(2) - synchronized 基本使用 中,我们介绍了使用重量锁来实现的synchronized。今天,我们就来一起学习一下在JDK 1.6之后,对synchronized所采取的一系列优化措施。

二、对象头 & Monitor Record

在介绍优化方法之前,我们需要介绍两个重要的概念Java对象头和Monitor

多线程知识梳理(3) - synchronized 三部曲之锁优化_第1张图片

2.1 对象头

在 Java&Android 基础知识梳理(3) - 内存区域 中介绍内存区域的时候,对于一个Java对象所占的内存区域是这么介绍的:

多线程知识梳理(3) - synchronized 三部曲之锁优化_第2张图片

在运行过程中,对象头所包含数据的含义不是固定不变的,随着 锁状态标志位(下图中红框的范围)的改变,其它字段所表示的含义也不同,以 32位的虚拟机为例,下图就是锁状态标志位所对应的数据结构含义:
多线程知识梳理(3) - synchronized 三部曲之锁优化_第3张图片

2.2 Monitor Record

Monitor是线程私有的数据结构,由于一个线程可能进入多个不同的同步方法,这些方法有可能会关联到不同的Monitor,因此每一个线程都有一个可用的Monitor列表,同时还有一个全局的可用列表,Monitor数据结构包括以下成员变量:

  • Owner:初始时为空表示当前没有任何线程拥有该Monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为空。
  • EntryQ:关联一个系统互斥锁,阻塞所有试图获得Monitor但是最终失败了的线程。
  • RcThis:表示blockedwaiting在该Monitor上的所有线程的个数。
  • Nest:用来实现重入锁的计数。
  • HashCode:保存从对象头拷贝过来的HashCode值。
  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

三、实现优化

JDK 1.6之后,它对于锁进行了一系列的优化措施,主要包括:自适应自旋锁、锁消除和锁粗化。

3.1 自旋锁

由于线程的阻塞和唤醒需要CPU从用户态转换成核心态,而频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。

因此,我们在发现锁已经被其它线程占有时,并不直接让当前线程进入阻塞状态,而是让线程执行一段无意义的循环,待循环结束后,如何仍然无法获取到锁,那么才进入阻塞状态。

决定自旋锁性能的关键在于自旋次数的选择,在JDK 1.6之后,引入了自适应自旋锁,它会根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定新的自旋次数。

3.2 锁消除

JVM检测到不可能存在共享数据竞争,会对同步锁进行锁消除。

3.3 锁粗化

在使用同步锁的时候,需要让同步块的作用范围尽可能地小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

然而,如果一系列连续加锁解锁操作,可能会导致不必要的性能损耗,所以有时可以将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

四、状态优化

JDK 1.6之前,锁只有两种状态:无锁状态和重量级锁状态,而在这之后增加为四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这种改进基于两点考虑:

  • 无锁状态和重量级锁状态之间的切换是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本很高。
  • 实验研究发现,对于绝大部分的锁,在整个生命周期内都是不存在竞争的。

需要注意,对于锁的这四种状态,它们会随着竞争的激烈而逐渐升级,但是它只允许锁升级,不允许锁降级。

无锁状态和重量级锁状态都比较好理解,下面我们主要介绍新增的两种锁状态:偏向锁状态轻量级锁状态

整个转换的流程图如下所示,在后面的介绍中可以参考:


多线程知识梳理(3) - synchronized 三部曲之锁优化_第4张图片

4.1 偏向锁状态

引入偏向锁的目的是:在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径,它的理想情况下是在无竞争时把整个同步都去掉,连CAS操作都省略。

偏向锁的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其它线程获取,则持有偏向锁的线程将永远不需要再进行同步。

4.1.1 获取偏向锁

(a) 前提条件

获取偏向锁的前提条件是synchronized所修饰的对象处于可偏向状态

  • 锁状态为01
  • 偏向锁状态为1

(b) 获取过程

当满足前提条件时,再去判断对象的Mark Word中的线程ID是否指向当前线程

  • 如果不指向当前线程,那么通过CAS操作竞争锁
    • 竞争成功:将Mark Word的线程ID替换为当前线程ID,接着执行同步代码块
    • 竞争失败:证明存在多线程竞争的情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块
  • 如果指向当前线程,那么执行同步代码块

4.1.2 释放偏向锁

(a) 前提条件

释放偏向锁的前提条件是其它的线程在竞争偏向锁的过程中出现了失败的情况,并且偏向锁的释放需要等待到达全局安全点。

(b) 释放过程

当满足释放偏向锁的前提条件时,首先会暂停拥有偏向锁的线程,接着判断锁对象是否处于被锁定的状态,决定锁标志位下一步的状态:

  • 如果未被锁定,那么将锁标志至为01,偏向锁状态置为0,表示它处于无锁,且不可偏向状态。
  • 如果已经被锁定,那么将锁标志置为00,表示它处于被轻量级锁定的状态。

4.2 轻量级锁状态

引入轻量级锁的目的是:在无多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

4.2.1 获取轻量级锁

(a) 前提条件

获取轻量级锁的前提条件时当前对象处于无锁状态,

  • 锁状态标志位为01
  • 偏向锁标志位为0

(b) 获取过程

JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝,之后JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针:

  • 操作成功:将锁标志置为00,表示处于锁定的状态,之后执行同步操作。
  • 操作失败:那么检查对象的Mark Word是否指向当前线程的栈针
  • 如果是,则直接执行同步代码块
  • 如果不是,说明该锁对象已经被其他线程抢占了,此时轻量级锁升级为重量锁,锁标志位变为10,后面等待的线程将会进入阻塞状态。

4.2.2 释放轻量级锁

(a) 释放过程

轻量级锁的释放也是通过CAS操作来进行的:

  • 取出在获取轻量级锁时,保存在Displaced Mark Word中的数据。
  • CAS操作将取出的数据替换到当前对象的Mark Word中:
  • 如果成功,则说明释放锁成功
  • 如果失败,说明有其它线程尝试获取该锁,那么需要在释放锁的同时,唤醒需要被唤醒的线程

对于轻量级锁,它性能提升的依据是默认"对于绝大部分的锁,在整个生命周期内是不会存在竞争的",如果不符合这种情况,那么除了互斥的开销外,还有额外的CAS操作,这样轻量级锁比重量级锁更慢。

五、参考文章

Java 并发编程:Synchronized 底层优化(偏向锁、轻量级锁)
死磕 Java 并发 -----深入分析 synchronized 的实现原理
深入理解 Java 锁与线程阻塞

你可能感兴趣的:(多线程知识梳理(3) - synchronized 三部曲之锁优化)