[Java并发] synchronize 锁升级 偏向锁 轻量级锁 自旋锁笔记
来自阅读Java并发编程的艺术读书笔记,按照自己思路写了一些整理,综合了网上的一些博文和资料
本篇来源于Java并发编程的艺术 Ch2.2 内容
参考资料:
Java 并发编程的艺术
浅谈偏向锁、轻量级锁、重量级锁
Java并发——Synchronized关键字和锁升级,详细分析偏向锁和轻量级锁的升级
synchronized 实现原理与应用
首先理清synchronized与重量级锁、(偏向锁 轻量级锁 自旋锁)的关系
synchronized
是一个很好用的Java关键字,实现上锁互斥的功能;最初的synchronized仅支持重量级锁(Monitor)性能可能不让人满意- 后序,随着Java升级,添加了自旋锁、偏向锁、轻量级锁等锁提升性能,但是我们无需单独使用这些锁,只需要加上
synchronized
关键字,便可享受Java升级带来的性能提升,其他的事情Java都帮我们做好了
- 其中,自旋锁并不是一种单独的锁机制,它存在于轻量级锁中,用于自旋等待以避免上下文线程切换
- 偏向锁并非真正加锁,也不能主动解除,只能在存在抢占时膨胀到轻量级锁/在不存在抢占时由另一个线程降为无锁
- 轻量级锁是一种利用自旋等待的锁机制,不适用于单CPU / 计算集中型业务;追求响应时间
最初,可以将synchronized理解成重量级锁,但是JSE1.6+对其进行优化引入更清量的锁后,本质上就没那么重了
- synchronized实现同步的基础:Java中的每一个对象都可以作为锁
- 普通同步方法:锁是当前实例对象
- 静态同步方法:锁是当前类Class对象
- 同步代码块:
synchronzied (obj) {}
括号内配置的对象
- JVM中,对于synchronized同步代码块和同步方法基于Monitor对象实现
monitorenter
指令插入到同步代码块的开始位置monitorexit
指令插入到方法结束处和异常处- JVM保证enter/exit成对出现,一般代码块同步使用上述指令,但是方法的同步同样可以使用上述指令完成
Java 对象头 - synchronized 锁
Java对象头内容结构:
其中,Mark Word与锁有关。32位JVM中Mark Word状态如下
-
要注意的是
- 轻量级锁:指向栈中锁记录指针
- 重量级锁:指向 互斥量(重量级锁) 指针
-
最初
synchronized
内置锁可以直接认为对应底层操作的互斥量mutex(保证一个时刻只有一个线程访问),成本很高,需要系统调用引起内核态/用户态切换、线程阻塞导致线程切换等,被称为重量级锁
锁的升级:自旋锁、自适应自旋锁、轻量级锁与偏向锁
内置锁是JVM提供的最便捷的线程同步工具:只要在代码块或方法声明上添加synchronized
关键字,即可使用内置锁。
随着JVM的升级出现偏向锁、轻量级锁等,几乎不需要修改代码就可以享受优化结果。
下面介绍几种升级后的锁,需要结合上面Mark Word的结构图看。首先上总结
由轻量到重量:
偏向锁(无锁,不能主动释放,有竞争就膨胀)
-> 轻量级锁(自旋等待一会)
-> 重量级锁自旋锁/自适应自旋锁是**轻量级CAS失败后尝试动作,用于减少线程切换的开销
详细的加锁流程图,来源DreamToBe
自旋锁
为了减少用户态/内核态切换(以切换运行线程)带来的开销,如果其他线程持有锁时间较短,则竞争锁的线程可以自旋(空转),以避免线程阻塞带来的上下文切换
-
具体如下:
- 当前线程竞争锁失败时,打算阻塞自己
此时不直接阻塞自己,而是自旋(空转一会),空转时尝试竞争锁 - 若自旋时获得锁,则锁获得成功;否则,自旋结束后阻塞自己
- 当前线程竞争锁失败时,打算阻塞自己
-
适用范围:多核处理器
- 锁持有时间比较短(自旋一会,占有锁进程就放开了)
- 锁竞争时间比较短(A线程快要释放时,B线程才来竞争)
即锁持有时间长,但竞争不激烈
-
缺点:
-
单核处理器中,不存在实际上的并行;线程多处理器少也浪费
若线程B不阻塞自己,则线程A(owner)永远不能执行,锁永远不能释放,自旋多久都是浪费;若线程多而处理器少,自旋也会造成浪费 -
自旋 占用CPU,计算密集场景下得不偿失
-
如果锁竞争时间长,则自旋通常不会获得锁,白浪费CPU时间
若锁持有时间长,且竞争激烈,应主动禁用自旋锁-XX:-UseSpinning 参数关闭自旋锁优化 -XX:PreBlockSpin 参数修改默认的自旋次数
-
自适应自旋锁
相对于自旋锁来说,自旋时间不固定,根据前一次在同一个锁上的自旋时间与锁拥有者的状态决定
- 策略:
- 若在同一个锁对象中,自旋刚刚成功获得过锁,并且持有锁的线程正在运行
JVM认为这次自旋也很有可能成功,允许更长时间的等待 - 若对于某个锁,自旋很少成功过
则以后获取这个锁时就要减少自旋、甚至省略自旋
- 若在同一个锁对象中,自旋刚刚成功获得过锁,并且持有锁的线程正在运行
- 问题:如果默认自旋次数不合理(过高或过低),则很难将自旋时间收敛到合适的值
轻量级锁
自旋锁的目标是降低线程切换成本(或者说降低线程切换次数)
如果锁竞争激烈,则我们不得不依赖重量级锁,让竞争失败的线程阻塞;如果没有实际竞争,则申请重量级锁是浪费的。
轻量级锁的目的在于:减少 无实际竞争的情况下,使用重量级锁的性能损耗(包括系统调用-内核态/用户态切换、线程阻塞造成线程切换等)
![image-20210125150843245](https://cse2020-dune.oss-cn-shanghai.aliyuncs.com/20210125150846.png)
-
原理:不需要申请互斥量
-
仅仅将Mark Word中部分字节CAS更新到指向线程栈中的Lock Record
-
若更新成功,则轻量级锁获取成功,将状态设置为轻量级锁
-
若更新失败,则已有线程获得轻量级锁,目前发生锁竞争,接下来膨胀成重量级锁
-
膨胀过程:将MarkWord修改为(重量级锁指针, 10),然后申请锁的线程阻塞
当持有轻量级锁线程在CAS更新Mark Word以解锁时失败,即释放锁并等待竞争
-
-
-
使用条件:锁竞争不是那么激烈的时候
- 如果锁竞争激烈,将很快膨胀成重量级锁
- 维持轻量级锁的过程就造成浪费
-
如果存在锁竞争,但是不太激烈,依然可以用自旋锁优化
偏向锁
如果实际场景根本就没有竞争,使用锁的线程只有一个,那么使用轻量级锁都根本是浪费
![image-20210125152642220](https://cse2020-dune.oss-cn-shanghai.aliyuncs.com/20210125152645.png)
- 基本原理
- 当线程1访问代码块并尝试获取锁对象时,先比较当前线程
threadId
和Java对象头中threadId
(偏向锁Mark Word)是否一致;- 如果一致,则说明还是线程1在获取(即重入),无须加锁解锁
- 如果不一致,其他线程占有偏向锁,因为偏向锁不能主动释放,则查看owner是否存活
- 如果不存活,则直接重置到***无锁状态***,其他线程可以竞争将其设置为偏向锁
- 如果存活,则等待占有锁的线程进入安全区后暂停
若其还继续占有,则对其撤销偏向锁,升级为轻量级锁
若其不再继续占有,则设置为无锁,重新偏向新的线程
- 当线程1访问代码块并尝试获取锁对象时,先比较当前线程
偏向锁的目标:减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁的性能损耗
-
偏向锁 vs 轻量级锁
- 轻量级锁每次申请/释放锁都至少需要一次CAS
- 偏向锁只有初始化才需要CAS
-
缺点:如果明显存在其他线程竞争锁,则很快膨胀成轻量级锁(不过副作用少很多)
使用参数-XX:-UseBiasedLocking=false 禁止偏向锁优化(默认打开) Java6/7中默认启用,但是在应用程序启动后几秒钟才会激活 可以使用参数关闭延迟:-XX:BiasedLockingStartupDelay=0
锁对比
锁不可降级
- Java中的锁一旦升级就不可降级,但是偏向锁可以降级为无锁
锁粗化
- 按理讲,同步代码块越小越好;但是如果临近两个临界区被分开,频繁的加锁解锁竞争锁也会造成不必要的性能损失
- 所以临近的同步代码块,可以粗化为同一个
锁消除
- Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间