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