文章目录
- 为什么需要这么多锁?
- 锁的“状态机”:四种锁状态
- 偏向锁:专一的锁
- 为什么需要偏向锁?
- 偏向锁的工作原理
- 偏向锁的撤销
- 轻量级锁:温和的竞争
- 为什么需要轻量级锁?
- 轻量级锁的工作原理
- 自旋优化:耐心等待的策略
- 重量级锁:真正的强者
- 重量级锁的实现
- 重量级锁的工作流程
- 锁升级的全过程
- 实战场景分析
- 场景一:单线程环境(适合偏向锁)
- 场景二:低竞争环境(适合轻量级锁)
- 场景三:高竞争环境(需要重量级锁)
- 锁优化的其他技术
- 锁粗化(Lock Coarsening)
- 锁消除(Lock Elimination)
- 如何选择合适的锁策略?
- 总结
- 参考文章
大家好,我是你们的技术老友科威舟,今天,我们要一起探索Synchronized锁的升级之路——从偏向锁、轻量级锁到重量级锁的奇幻之旅。如果你曾对Java并发编程感到头疼,那么这篇文章就是你的布洛芬!
深入理解Java并发编程的锁优化,让你的程序性能飞起来!
为什么需要这么多锁?
在开始之前,我们先思考一个简单的问题:为什么Java不直接用最强大的重量级锁,而是要搞这么多锁状态?
想象一下,你去一家快餐店点餐。
重量级锁就像在收银台前修了一个小房间,每次只能进入一个人点餐,其他人必须在外面排队等待。这安全吗?绝对安全!但效率呢?堪忧!
而现实中,大多数情况是:餐厅里其实没什么顾客(没有竞争),或者即使有多个顾客,也是轮流点餐(交替执行),而不是同时挤在收银台前。
JDK的开发者们也意识到了这个问题,于是在JDK 1.6中,对synchronized进行了大幅优化,引入了我们今天要讲的锁升级机制。
锁的“状态机”:四种锁状态
Java中的锁有四种状态,它们的关系如下所示:
| 锁状态 | 标志位 | 特点 |
|---|---|---|
| 无锁 | 01 | 对象未锁定 |
| 偏向锁 | 01 | 优化同一线程重复获取锁的场景 |
| 轻量级锁 | 00 | 优化多个线程交替执行同步块的场景 |
| 重量级锁 | 10 | 真正的互斥锁,适用于高竞争场景 |
锁只能从低到高升级,不能降级(虽然有极少数特殊情况,但一般认为不可降级)。
偏向锁:专一的锁
为什么需要偏向锁?
HotSpot的作者发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。比如,你在一个线程安全的多步操作中,可能会多次进入同一个同步块:
publicclassSafeCounter{privateintcount=0;publicvoidsafeIncrement(){synchronized(this){count++;// 其他操作...synchronized(this){// 再次进入同步块count++;}}}}如果没有偏向锁,每次进入同步块都需要执行CAS操作,而CAS虽然比重量级锁高效,但仍有开销。
偏向锁的工作原理
偏向锁的核心理念是:如果锁始终由同一个线程使用,就不要反复加锁解锁了。
加锁过程:
- 检查对象头的Mark Word,判断是否处于可偏向状态(标志位为01,是否偏向为0)
- 如果是可偏向状态,通过CAS操作将当前线程ID记录到Mark Word中
- 如果CAS成功,该线程以后每次进入这个同步块,都不需要任何同步操作
举个例子:偏向锁就像是你家的门锁。只有你家人(同一线程)有钥匙,每次回家直接开门就行,不需要每次都在门口检查身份证。
偏向锁的撤销
当有另一个线程尝试获取偏向锁时,偏向锁就要被撤销了。这个过程需要等到全局安全点(在这个时间点上没有正在执行的字节码),然后检查原持有偏向锁的线程是否还存活。
- 如果原线程已不存活或不在同步块中:将对象设置为无锁状态,然后新线程可以重新偏向或升级为轻量级锁
- 如果原线程还在同步块中:升级为轻量级锁
偏向锁的适用场景:只有一个线程访问同步块,且不存在竞争的情况。在高并发场景下,偏向锁反而会降低性能(因为多了撤销操作),此时可以通过-XX:-UseBiasedLocking禁用。
轻量级锁:温和的竞争
为什么需要轻量级锁?
当偏向锁遇到竞争时,就会升级为轻量级锁。轻量级锁适应的场景是线程交替执行同步块,而不是真正的同时竞争。
想象一下公司卫生间的使用情况:多个人会使用,但通常是轮流使用,而不是同时挤在门口争夺使用权。
轻量级锁的工作原理
加锁过程:
- 在代码进入同步块时,如果同步对象处于无锁状态,JVM会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间
- 将对象头的Mark Word复制到锁记录中(称为Displaced Mark Word)
- 使用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针
- 如果CAS成功,当前线程获得锁;如果失败,表示存在竞争,尝试自旋获取锁
轻量级锁的释放:
- 使用CAS操作将Displaced Mark Word替换回对象头
- 如果成功,同步完成;如果失败,表示锁已膨胀,需要在释放锁的同时唤醒被挂起的线程
自旋优化:耐心等待的策略
轻量级锁在竞争失败后,不会立即升级为重量级锁,而是会进行自旋等待。
自旋可以理解为:“我再等一会儿,说不定马上就能拿到锁了”。
// 自旋的简单理解for(inti=0;i<MAX_SPIN_TIMES;i++){if(tryGetLock()){// 获取锁成功!return;}// 稍微等待一下再尝试shortWait();}// 自旋多次还没拿到锁,升级为重量级锁upgradeToHeavyweightLock();JDK 1.6引入了适应性自旋,意味着自旋时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态决定。
重量级锁:真正的强者
当轻量级锁自旋超过一定次数(或一个线程持有锁,另一个在自旋,又有第三个来访时),轻量级锁会升级为重量级锁。
重量级锁的实现
重量级锁依赖于操作系统的mutex锁实现,线程的阻塞和唤醒需要从用户态切换到内核态,成本很高。
重量级锁使用ObjectMonitor实现,其主要结构包括:
- ContentionList:竞争队列,所有请求锁的线程首先被放在这个队列中
- EntryList:候选队列,ContentionList中有资格成为候选资源的线程被移动到这里
- WaitSet:等待集合,调用wait()方法的线程被放置在这里
- Owner:当前持有锁的线程
重量级锁的工作流程
- 线程尝试获取锁,如果成功,成为Owner
- 如果失败,线程被封装成ObjectWaiter对象,加入到ContentionList中
- 当持有锁的线程释放锁时,会根据特定策略从ContentionList或EntryList中选取一个线程唤醒
重量级锁就像医院的专家号:每个人必须严格排队,即使医生暂时闲着,也得按规矩来。公平,但效率可能不高。
锁升级的全过程
现在我们把整个锁升级过程串联起来:
- 初始状态:对象被创建后,处于可偏向状态但未偏向任何线程(匿名偏向)
- 第一次加锁:线程A首次进入同步块,使用CAS将线程ID设置到对象头,进入偏向锁状态
- 同一线程重入:线程A再次进入同步块,检查对象头中的线程ID与自己一致,直接通过,无需同步操作
- 出现竞争:线程B尝试获取锁,发现锁已被线程A偏向
- 偏向锁撤销:等待全局安全点,检查线程A状态
- 升级轻量级锁:如果线程A仍需要锁,升级为轻量级锁,线程A成为锁持有者,线程B自旋等待
- 自旋过度:如果线程B自旋等待时间过长,或又有线程C来竞争锁
- 升级重量级锁:轻量级锁升级为重量级锁,线程B和C进入阻塞状态
实战场景分析
场景一:单线程环境(适合偏向锁)
publicclassSingleThreadScenario{publicvoidprocess(){List<String>data=fetchData();synchronized(this){// 处理数据processData(data);}// 其他操作...synchronized(this){// 再次处理furtherProcess(data);}}}这种情况下,偏向锁可以大幅提升性能,因为同一线程多次获取锁时几乎零开销。
场景二:低竞争环境(适合轻量级锁)
publicclassLowContentionScenario{publicvoidprocess(){ExecutorServiceexecutor=Executors.newFixedThreadPool(2);// 两个线程交替执行,不是同时竞争for(inti=0;i<10;i++){executor.submit(()->{synchronized(this){// 短暂的同步操作shortOperation();}});}}}这种情况下,轻量级锁通过自旋避免线程阻塞,提高响应速度。
场景三:高竞争环境(需要重量级锁)
publicclassHighContentionScenario{privatefinalObjectlock=newObject();publicvoidhighContentionMethod(){ExecutorServiceexecutor=Executors.newFixedThreadPool(10);// 10个线程激烈竞争同一把锁for(inti=0;i<100;i++){executor.submit(()->{synchronized(lock){// 较长的同步操作longRunningOperation();}});}}}这种情况下,轻量级锁会导致大量自旋消耗CPU,重量级锁虽然阻塞线程,但总体效率更高。
锁优化的其他技术
除了锁升级,JVM还提供了其他锁优化技术:
锁粗化(Lock Coarsening)
将多个连续的锁操作合并为一个更大范围的锁操作。
// 锁粗化前publicvoidappend(){stringBuffer.append("a");stringBuffer.append("b");stringBuffer.append("c");}// 锁粗化后(JVM自动优化)publicvoidappend(){// 将三次加锁解锁合并为一次synchronized(stringBuffer){stringBuffer.append("a");stringBuffer.append("b");stringBuffer.append("c");}}锁消除(Lock Elimination)
JVM通过逃逸分析技术,发现某些锁操作不可能被其他线程访问,就会将这些锁操作消除。
publicStringcreateString(){// stringBuffer是局部变量,不可能被其他线程访问StringBufferstringBuffer=newStringBuffer();stringBuffer.append("hello");stringBuffer.append("world");returnstringBuffer.toString();}这种情况下,JVM会消除StringBuffer内部的同步操作。
如何选择合适的锁策略?
- 如果确定是单线程环境:可以开启偏向锁(默认开启)
- 如果是低竞争环境:轻量级锁是最佳选择
- 如果是高竞争环境:考虑禁用偏向锁和自旋锁,直接使用重量级锁
- 极端高并发场景:考虑使用Java并发包中的ReentrantLock等更高级的锁机制
可以通过以下JVM参数进行调优:
- 关闭偏向锁:
-XX:-UseBiasedLocking - 关闭自旋锁:
-XX:-UseSpinning - 批量重偏向阈值:
-XX:BiasedLockingBulkRebiasThreshold=20
总结
Java的锁升级机制是一个精美的性能优化方案,它体现了按需分配的思想:根据实际的竞争情况,提供不同级别的锁机制。
偏向锁适用于单线程重复访问的场景,轻量级锁适用于低竞争交替执行的场景,重量级锁适用于高竞争的场景。理解这些锁的工作原理和升级过程,有助于我们编写更高效的并发程序,并在出现性能问题时能准确诊断。
记住,没有绝对的优劣,只有适合的场景。选择合适的锁策略,让你的程序在并发世界中游刃有余!
参考文章
- https://blog.51cto.com/universsky/5377002
- https://blog.csdn.net/chengyan_1992/article/details/124803701
- https://blog.csdn.net/w1475995549/article/details/139992087
- https://blog.csdn.net/lp284558195/article/details/115547269
- https://blog.csdn.net/MariaOzawa/article/details/107665689
希望这篇文章能帮助你理解Java锁升级机制。如果有任何问题,欢迎在评论区留言讨论!下次我们将深入探讨Java并发包中的其他高级特性,敬请期待!
更多技术干货欢迎关注微信公众号科威舟的AI笔记~
【转载须知】:转载请注明原文出处及作者信息