由 并发编程中常见的锁策略 总结可知,synchronized 具有以下几个特性:
- 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
- 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。
- 实现轻量级锁时,大概率用自旋锁策略。
- 是一种不公平锁。
- 是一种可重入锁。
- 不是读写锁。
本文介绍synchronized的几种优化操作,包括锁升级、锁消除和锁粗化。
一、锁升级
JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁这四种状态。在加锁过程中,会根据实际情况,依次进行升级。(**目前主流的 JVM 的实现,只能锁升级,不能锁降级!**不是无法实现,只不过可能是因为存在一些代价,使得这样做的收益和代价不成比例,因此就没有实现。)
整体的加锁过程(锁升级过程):刚开始加锁,是偏向锁状态;遇到锁竞争后,升级成自旋锁(轻量级锁);当竞争更激烈时,就会变成重量级锁(交给内核阻塞等待)。
1、偏向锁(Biased Locking)
第一个尝试加锁的线程优先进入偏向锁状态。偏向锁是Java虚拟机(JVM)中用于提高线程同步性能的一种优化技术。在多线程环境中,对共享资源进行同步操作,需要使用锁(synchronized)来保证线程的互斥访问。传统的锁机制存在竞争和上下文切换的开销,对性能会有一定的影响。而偏向锁则是为了减少无竞争情况下的锁操作开销而引入的。
偏向锁不是真的“加锁”,只是先让线程针对锁对象有个标记,记录某个锁属于哪个线程。
它的基本思想是,当一个线程获取锁并访问同步代码块时,如果没有竞争,那么下次该线程再次进入同步块时,无需再次获取锁。这是因为在无竞争的情况下,假设一个线程反复访问同步代码块,无需每次都去竞争锁,只需判断锁是否处于偏向状态;如果是,那么直接进入同步代码块即可。
通俗来说就是,如果后续没有其他线程再来竞争该锁,那么就不用真的加锁了,从而避免了加锁解锁的开销。 但一旦还有其他线程来尝试竞争这个锁,偏向锁就立即升级成真的锁(轻量级锁),此时别的线程就只能等待了。这样做既保证了效率,也保证了线程安全。
如何判定有没有别的线程来竞争该锁?
偏向锁本质上是“延迟加锁”,即能不加锁就不加锁,尽量避免不必要的加锁开销;但是该做的标记还是得做的,否则就无法区分何时需要真正加锁。
举个栗子理解偏向锁
2、自旋锁
随着其他线程进入锁竞争,偏向锁状态会被消除,进入轻量级锁状态,即自适应的自旋锁。
此处的轻量级锁是通过 CAS 来实现。通过 CAS 检查并更新一块内存 (比如比较 null 与该线程引用是否相等),如果更新成功,则认为加锁成功;如果更新失败,则认为锁被占用,继续自旋式的等待,期间并不放弃 CPU 资源。
(见 详解CAS算法)
由于自旋操作是一直让 CPU 空转,比较浪费 CPU 资源,因此此处的自旋不会一直持续进行,而是达到一定的时间或重试次数就不再自旋了。这也就是所谓的 “自适应”。
3、重量级锁
如果竞争进一步激烈,自旋不能快速获取到锁状态。就会膨胀为重量级锁。
如果竞争进一步激烈,自旋不能快速获取到锁状态。就会膨胀为重量级锁。
此处的重量级锁就是指内核提供的 mutex 。
- 某线程执行加锁操作,先进入内核态。
- 在内核态判定当前锁是否已经被别的线程占用 。
- 如果该锁没有占用,则加锁成功,并切换回用户态。
- 如果该锁被占用,则加锁失败。此时线程进入锁的等待队列并挂起,等待被操作系统唤醒。
- 经历了一系列的“沧海桑田”,这个锁终于被其他线程释放了,此时操作系统也想起了这个被挂起的线程,于是唤醒这个线程,并让它尝试重新获取锁。
二、锁消除
锁消除也是“非必要,不加锁”的一种体现。与锁升级不同,锁升级是程序在运行阶段 JVM 做出的优化手段。而锁消除是在程序编译阶段的优化手段。编译器和 JVM 会检测当前代码是否是多线程执行或是否有必要加锁。如果无必要,但又把锁给写了,那么在编译的过程中就会自动把锁去掉。
有些应用程序代码中可能会用到没有必要用到的 synchronized。例如 StringBuffer 就是线程安全的,它的每一个关键方法都加了synchronized关键字:
但这里就有一个问题:如果是在单线程中使用StringBuffer,是不涉及线程安全问题的。这个时候其实就没必要加锁。那么这时编译器就会出手,发现synchronized是没必要加的,就会在编译阶段把synchronized去掉,相当于加锁操作没有真正被编译。
锁消除整体来说是一个比较保守的优化手段,毕竟编译器肯定得保证消除的操作是靠谱的。所以只有十拿九稳的时候才会实施锁消除,否则仍然会上锁,这时就会交给其它的操作策略来对锁进行优化(比如上面的锁升级)。
三、锁粗化
锁的粒度指的是 synchronized 代码块中包含代码的多少。代码越多,粒度越大;代码越少,粒度越小。
一般我们在写代码时,多数情况下是希望锁的粒度更小一点。(锁的粒度小就意味着串行执行的代码更少,并发执行的代码更多)。如果某个场景需要频繁地加锁解锁,此时编译器就可能把这个操作优化成个粒度更粗的锁,即锁的粗化。
举个栗子理解锁粗化
上班时要向领导汇报工作。你的领导给你安排了三个工作:A、B、C。
汇报方式有:
- 先打个电话,汇报工作 A 的进展,挂了电话;再打个电话,汇报工作 B 的进展,挂了电话;再打个电话,汇报工作C的进展,挂了电话。(你给领导打电话,领导接你的电话,领导就干不了别的;别人要给领导打电话,就只能阻塞等待。每次锁竞争都可能引入一定的等待开销,此时整体的效率可能反而更低。)
- 打个电话,一口气汇报 工作 A,工作B,工作 C,挂了电话。
显然第二种方式是更加高效的。
可见,synchronized 的策略是比较复杂的,它是一个很“智能”的锁。