《菜鸟读并发》一文读懂Synchronize锁优化过程

《菜鸟读并发》一文读懂Synchronize锁优化过程_第1张图片

概念

随着JVM的升级,几乎不需要修改代码,就可以直接享受JVM在内置锁上的优化成果。从简单的重量级锁,到逐渐膨胀的锁分配策略,使用了多种优化手段解决隐藏在内置锁下的基本问题。

我们来回顾一下,新版的锁的类型一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级.

锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。

锁的四个状态

JDK 1.6 引入了偏向锁和轻量级锁,锁拥有了四个状态

  1. 无锁状态(unlocked)
  2. 偏向锁状态(biasble)
  3. 轻量级锁状态(lightweight locked)
  4. 重量级锁状态(inflated)

虚拟机对象头的内存布局( Mark Word)

tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了
《菜鸟读并发》一文读懂Synchronize锁优化过程_第2张图片
下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息
《菜鸟读并发》一文读懂Synchronize锁优化过程_第3张图片
轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态

Mark Word的图解

《菜鸟读并发》一文读懂Synchronize锁优化过程_第4张图片

锁膨胀方向

无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

锁优化技术

锁优化技术是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率

主要包括:锁消除,锁粗化,偏向锁,轻量级锁,自旋锁,自适应自旋锁,重量级锁

锁消除(Lock Eliminate)

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除

比如:A方法,调用B方法,B方法将内部创建的一个局部对象,返回给了A,那么这个B中的变量就属于逃逸了,就存在被其他线程访问的可能,简单说除了你写代码之外,Java底层包括从编译器到JVM还有很多工作人员在忙活,人家通过算法一看,你这根本就没有必要使用同步,就会在实际执行的时候把你的同步去掉

当然在实际开发中,我们很清楚的知道那些地方是线程独有的,不需要加同步锁,你可能以为,我自己哪有写很多synchronized修饰的方法,但是在JDK提供的方法、别人提供的Jar包中的方法中有很多方法都是加了同步的synchronized,对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁在这里插入图片描述

String 是一个不可变的类,Javac编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作
《菜鸟读并发》一文读懂Synchronize锁优化过程_第5张图片

众所周知,StringBuidler是安全同步的,每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

锁粗化(Lock Coarsening)

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了《菜鸟读并发》一文读懂Synchronize锁优化过程_第6张图片

不一定是调用同一个对象的同一个方法, 假如一个A方法,中有三个对象b,c,d,分别调用他们的方法而且都是同步方法,而且是同一个锁也是一样的原理

《菜鸟读并发》一文读懂Synchronize锁优化过程_第7张图片

偏向锁(BiasedLocking)

JDK 1.6 引入偏向锁(Mark Word是实现轻量级锁和偏向锁的关键)

如果需要,使用参数-XX:-UseBiasedLocking禁止偏向锁优化(默认打开)​

引入原因
  • 在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的(需要对MarkDown字段进行维护,以及复制MarkDown,以及多次CAS操作)
  • 锁不仅不存在多线程竞争,而且总是由同一线程多次获得
偏向锁的目标

减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS

核心思想
  • 偏向锁使用了一种等待竞争出现才会释放锁的机制
  • 当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁
  • 锁会偏向第一个获取它的线程,如果不存在竞争,只有一个线程,则持有偏向锁的线程永远不需要同步
  • 如果没有竞争,可以看到出来,偏向锁的可以约等于是无锁的
特点
  • 偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定
  • “偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁)
  • 只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁
  • 以后当前线程等于owner就可以零成本的直接获得锁
  • 否则,说明有其他线程竞争,膨胀为轻量级锁。
实现步骤
  1. 偏向锁的思想是偏向于让第一个获取锁对象的线程

  2. 这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

  3. 当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01

  4. 同时使用 CAS 操作将线程 ID 记录到 Mark Word 中

  5. 如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

  6. 当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。《菜鸟读并发》一文读懂Synchronize锁优化过程_第8张图片

  7. 偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)

  8. 会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着

  9. 如果线程不处于活动状态,直接将对象头设置为无锁状态

  10. 如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁

使用场景
  • 无竞争
  • 只有一个线程使用锁
缺点

如果明显存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁(不过这个副作用已经小的多)

图示

《菜鸟读并发》一文读懂Synchronize锁优化过程_第9张图片

轻量级锁(Lightweight Locking)

JDK 1.6 引入轻量级锁(Mark Word是实现轻量级锁和偏向锁的关键)

引入原因( 使用CAS 操作来避免重量级锁使用互斥量的开销)
  • 自旋锁的目标是降低线程切换的成本
  • 如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞
  • 如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的
  • 本质是借助于CAS操作,对于竞争不激烈的场景下,可以减少重量级锁的使用
轻量级锁的目标

减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步

特点

轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁

实现步骤
  1. 使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record
    Mark Word是对象头的一部分;每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息
  2. 当尝试获取一个锁对象时,会判断当前锁状态是否是无锁状态
  3. 如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态
  4. 在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(LockRecord)的空间,用于存储锁对象目前的MarkWord的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward
  5. 此时虚拟机在当前线程的虚拟机栈中创建 Lock Record
    《菜鸟读并发》一文读懂Synchronize锁优化过程_第10张图片
  6. 然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针
  7. 如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态
    《菜鸟读并发》一文读懂Synchronize锁优化过程_第11张图片
  8. 如果成功说明没有竞争,那么执行同步代码体
  9. 如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈
  10. 如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行
  11. 否则说明这个锁对象已经被其他线程线程抢占了,发生了锁竞争(不适合继续使用轻量级锁)。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位10.Mark Word中存储的是指向重量级锁的指针
  12. 轻量级解锁时,当持有线程执行结束后,会使用原子的CAS操作将Displaced Mark Word替换回到对象头中
  13. 如果成功,则表示没有发生竞争关系,锁释放完成
  14. 如果失败,表示当前锁存在竞争关系,Mark Down字段会被修改,CAS操作会失败,锁就会膨胀成重量级锁,将会释放锁并且唤醒被阻塞的线程,两个线程同时争夺锁

总结

  • 对于轻量级锁,核心就是CAS操作,因为一旦出现竞争,MarkDown信息将会被修改,而CAS操作的原理就是新值与旧值的对比后再操作,所以CAS操作的成功与否,可以推断是否有竞争
  • 有竞争那么就会升级为重量级锁,其他请求线程会被阻塞,该线程执行结束后会唤醒其他阻塞线程;否则无竞争就会释放退出
  • 很显然,如果竞争激烈的场景,很快就会升级为重量级锁,而关于轻量级锁所有的一切都是徒劳的
  • 不过幸运的是,实践表明,大多数场景并不会竞争激烈
使用场景
  • 轻量级锁天然瞄准不存在锁竞争的场景,无实际竞争,多个线程交替使用锁
  • 允许短时间的锁竞争,如果存在锁竞争但不激烈,仍然可以用自旋锁优化(自旋失败后再膨胀为重量级锁)
缺点
  • (同自旋锁相似)如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁,那么维持轻量级锁的过程就成了浪费
  • 轻量级锁每次申请、释放锁都至少需要一次CAS,产生的性能消耗
图示

《菜鸟读并发》一文读懂Synchronize锁优化过程_第12张图片

自旋锁

JDK 1.4引入自旋锁

引入的原因
  • 在没有加入锁优化时,Synchronized非常重。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响
  • 在挂起线程和恢复线程的操作都需要转入内核态中完成,这为操作系统的并发性能带来了很大的压力
  • 但通过自旋锁,可以减少线程阻塞造成的线程切换(包括挂起线程和恢复线程)
使用场景
  • 共享数据的锁定状态只会持续很短的一段时间
  • 锁竞争不激烈的场景
  • 锁的粒度小,锁的持有时间比较短
  • 多核处理器条件下
原理

如果锁的粒度小,那么锁的持有时间比较短(尽管具体的持有时间无法得知,但可以认为,通常有一部分锁能满足上述性质)。那么,对于竞争这些锁的而言,因为锁阻塞造成线程切换的时间与锁持有的时间相当,减少线程阻塞造成的线程切换,能得到较大的性能提升

实现步骤
  • 当前线程竞争锁失败时,打算阻塞自己,但此时不直接阻塞自己,而是自旋(空等待,比如一个空的有限for循环)一会
  • 在自旋的同时重新竞争锁
  • 如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己
  • 如果在自旋的时间内,锁就被旧owner释放了,那么当前线程就不需要阻塞自己(也不需要在未来锁释放时恢复),减少了一次线程切换。
  • “锁的持有时间比较短”这一条件可以放宽。实际上,只要锁竞争的时间比较短(比如线程1快释放锁的时候,线程2才会来竞争锁),就能够提高自旋获得锁的概率。这通常发生在锁持有时间长,但竞争不激烈的场景中
缺点
  • 自旋的时间是固定,如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋(这里则需要自适应自旋锁!)
  • 单核处理器上,不存在实际的并行,当前线程不阻塞自己的话,旧owner就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。
  • 自旋锁要占用CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。
  • 如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。
图示

《菜鸟读并发》一文读懂Synchronize锁优化过程_第13张图片

其他
  • 自旋锁早在JDK1.4中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。
  • 使用-XX:-UseSpinning参数关闭自旋锁优化;-XX:PreBlockSpin参数修改默认的自旋次数(自旋锁默认的自旋次数为10次)

自适应自旋锁(Adaptive Spinning)

JDK 1.6引入自适应自旋锁

自旋锁的时间
  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
  • 相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源
  • 自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
优点
  • 自适应自旋解决的是“锁竞争时间不确定”的问题。JVM很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM的设计初衷。
  • 自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。
实现步骤
  1. 如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间,比如增加到100此循环。
  2. 相反,如果对于某个锁,自旋很少成功获取锁。那以后再要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
  3. 有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。
缺点

自适应自旋也没能彻底解决该问题,如果默认的自旋次数设置不合理(过高或过低),那么自适应的过程将很难收敛到合适的值

重量级锁

内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)

这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。

使用场景
  • 有实际竞争
  • 锁竞争时间长

锁分配和膨胀过程(全)

《菜鸟读并发》一文读懂Synchronize锁优化过程_第14张图片

特别说明两点
  1. CAS记录owner时,expected == null,newValue == ownerThreadId,因此,只有第一个申请偏向锁的线程能够返回成功,后续线程都必然失败(部分线程检测到可偏向,同时尝试CAS记录owner)。
  2. 内置锁只能沿着偏向锁、轻量级锁、重量级锁的顺序逐渐膨胀,不能“收缩”。这基于JVM的另一个假定,“一旦破坏了上一级锁的假定,就认为该假定以后也必不成立”。

锁升级对比

《菜鸟读并发》一文读懂Synchronize锁优化过程_第15张图片
《菜鸟读并发》一文读懂Synchronize锁优化过程_第16张图片

小结

  1. 轻量级锁和偏向锁都是借助于CAS
  2. 如果出现问题,将会进行锁的升级,升级是不可逆的,也就是说只能从低到高,也就是偏向–>轻量级–>重量级,不能够降级
  3. 偏向锁是对于轻量级锁的更进一步优化,当然这是有前提的,那就是“场景”
  4. 对于偏向锁和轻量级锁,如果不是同一线程或者线程竞争激烈,将会迅速的从偏向锁升级为轻量级锁,然后迅速变为重量级锁,而偏向锁和轻量级锁带来的一切开销,都将是额外的开销,所以二者的开启与否要根据业务来,不同版本的JDK开启状态有所不同.

如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

原创不易,欢迎转发,关注公众号“码农进阶之路”,获取更多面试题,源码解读资料!
在这里插入图片描述

你可能感兴趣的:(Java,JUC并发编程系列,菜鸟读并发)