一、常见锁策略
在Java并发编程中,锁是保证线程安全的基石。但很多开发者对锁的理解仅停留在 synchronized 关键字的使用上,对于其背后的策略和优化知之甚少。
本文将带你系统地梳理常见的锁策略,揭开synchronized背后的"进化"过程,并探讨如何根据场景选择合适的锁。
"锁策略"并不是局限于某个语言,任何地方只要用到了 "锁" 就需要关注锁策略。关注锁策略其实就是了解锁在加锁的时候有什么特点、行为,能够帮助我们更合理地使用锁。
1.1 乐观锁 VS 悲观锁
这里的 "乐观" 和 "悲观" 并不是针对某一个具体的锁,而是只某个锁具有 "乐观" 或 "悲观" 特性。
乐观锁
乐观锁倾向于认为:在加锁的时候,接下来的锁竞争的情况不激烈,不需要做额外的工作。体现在:假设数据在一般情况下不会发生并发冲突,所以只在数据进行提交更新的时候才检测其是否发生了并发冲突。适用于 "读频繁操作" 的场景。
悲观锁
悲观锁倾向于认为:在加锁的时候,接下来的锁竞争的情况会非常激烈,需要根据这样的情况做出一些额外的工作。体现在:每次拿数据的时候都会上锁,保障数据的安全性。适用于 "写频繁操作" 的场景。
我们学过的
synchronized是自适应的,在初始状态下是乐观锁策略,当发现锁竞争比较激烈的时候,就会切换成悲观锁策略。
1.2 重量级锁 VS 轻量级锁
我们先来说说锁能够保障原子性的根本原因是什么,锁的这个特性源于 CPU 这样的硬件设备。
- CPU 提供了 "原子操作指令"
- 操作系统则基于 CPU 的原子指令,实现了 mutex 互斥锁
- JVM 则基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。

synchronized 不仅仅是对 mutex 进行封装,在 synchronized 的内部还做了很多其他的工作。
轻量级锁和重量级锁是遇到乐观或悲观场景后的解决策略。
重量级锁
重量级锁(在悲观的场景下)的加锁机制重度依赖操作系统的 mutex 互斥锁,加锁和解锁涉及到内核态和用户态的切换,容易引发线程调度,成本极高。
轻量级锁
轻量级锁(在乐观的场景下)尽量在用户态完成加锁,而不使用 mutex,如果实在搞不定再使用 mutex,减少了内核态切换,性能更高。
synchronized 开始是轻量级锁,当锁冲突比较严重时就会自动变成重量级锁。
1.3 挂起等待锁 VS 自旋锁
挂起等待锁
挂起等待锁是重量级锁的一种典型实现,是操作系统内核级别的。当加锁的时候发现锁竞争,就会使该线程进入阻塞状态,后续就需要内核将该线程唤醒。使用挂起等待锁要获取锁周期更长,很难及时获取到,但是这个过程不会一直消耗 cpu 。
自旋锁
自旋锁是轻量级锁的一种典型实现,是应用程序级别的。当加锁的时候发现锁竞争,不会使该线程进入阻塞状态,而是通过持续循环(也就是短时间的 "忙等"),知道获取锁成功。
为什么可以通过 "忙等" 来获取锁呢,因为虽然没有获取到锁,但是锁会在短时间内被释放,因此没必要放弃 CPU 资源,通过段时间内的忙等循环就可以立即获取到锁。但是如果等待的时间太长,就会消耗比较多的 CPU 资源。
1.4 公平锁 VS 非公平锁
公平锁
遵循线程 "先来后到" 的顺序来获取锁。哪个线程先阻塞哪个线程就在锁释放后先获取到锁。
非公平锁
不遵循 "先来后到" ,而是 "机会均等" ,后阻塞的线程可能先拿到锁,每个线程拿到锁的概率是一样的。
- 操作系统本身的调度就是随机的,因此锁也默认是非公平锁,若要实现公平锁,需要使用额外的数据结构来记录顺序。
- synchronized 是一个非公平锁,ReentrantLock 则可以通过构造函数选择是否为公平锁。
1.5 可重入锁 VS 不可重入锁
可重入锁即一个线程可以多次获得同一把锁,可以理解为针对同一个锁对象可以多次加锁。
一般递归的时候用到可重入锁,因此也称为递归锁。
核心要点:
- 锁要记录当前是哪个线程拿到这把锁的
- 使用计数器统计线程的加锁次数,在合适的时候解锁
Java 中可重入锁都是以 Reentrant 开头来命名的,而且,目前 JDK 所提供的所有现成的 Lock 类(包括 synchronized)都是可重入的。但是 Linux 的 mutex 是不可重入的。
1.6 普通互斥锁 VS 读写锁
在多线程编程中,读取数据的线程相互之间不会出现安全问题,但是写入数据的线程相互之间以及和读取数据的线程之间都会产生安全问题,需要加锁互斥。
如果两种场景下都是用同一种锁,就会产生极大的性能损耗,以此有了读写锁。
读写锁的使用场景是 "读多写少":
该场景下大部分时间都是在读,少部分时间是写,如果把 "读" 和 "写" 操作都加上普通互斥锁,那么就会出现锁冲突。如果使用读写锁,就能确保:读加锁和读加锁之间不会产生互斥;读加锁和写加锁之间或者写加锁和写加锁之间才会产生互斥。在保证线程安全的情况下,降低锁冲突的概率,能够提高效率。
Java 标准库中的读写锁以内部类的形式提供:

通过 lock 和 unlock 等一系列方法来加锁和解锁:

1.7 相关面试题
面试题1:你是怎么理解乐观锁和悲观锁的?具体怎么实现呢?
- 悲观锁认为多个线程访问同⼀个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁,而乐观锁认为多个线程访问同⼀个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据,在访问的同时识别当前的数据是否出现访问冲突。
- 悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex),获取到锁再操作数据. 获取不到锁就等待;乐观锁的实现可以引入⼀个版本号,借助版本号识别出当前的数据访问是否冲突
面试题2:介绍一下读写锁?
- 读写锁就是把读操作和写操作分别加锁。使得读操作加锁和读操作加锁之间不产生互斥,而读操作加锁和写操作加锁及写操作加锁和写操作加锁之间产生互斥。主要应用场景是 "读多写少"。
面试题3:什么是自旋锁?为什么要使用自旋锁策略呢?缺点是什么?
- 自旋锁策略就是当获取锁失败的时候,再次尝试获取锁,无限循环,直到拿到锁为止。第一次获取锁失败,就会在极短时间内再次尝试获取锁,一旦锁释放了,就会立即拿到锁。
- 相比于挂起等待锁,其优点是没有放弃 CPU 资源,一旦锁释放了就能立即拿到锁,更加高效,使用场景是持有锁时间比较短;缺点是在持有锁时间比较长的情况下,消耗的 CPU 资源比较多,会造成资源的浪费。
面试题4:synchronized 是可重入锁吗?
- synchronized 是可重入锁,可重入锁指的是同一个线程可以多次拿到同一个锁。实现的方式是在锁中记录持有该锁的线程的身份和一个计数器,用来记录加锁的次数,如果当前加锁的线程就是持有锁的线程,计数器就自增。
二、synchronized 的原理
2.1 基本原理
结合上面的锁策略,我们可以知道 synchronized 的基本特点(这里只考虑 JDK 1.8):
- 开始是乐观锁,当锁竞争太激烈就会转换为悲观锁
- 开始是实现轻量级锁,当锁被持有的时间太长,就会转换为重量级锁
- 实现轻量级锁的时候大概率用到的是自旋锁策略
- 是一种非公平锁
- 是一种可重入锁
- 不是读写锁
2.2 加锁工作过程
JVM 将 synchronized 分为:无锁、偏向锁、轻量级锁和重量级锁四个部分,根据情况进行升级。

具体的升级过程:
- 当进入了 synchronized 代码块,无锁状态 -> 偏向锁状态
- 拿到偏向锁的线程在运行过程中,发现有其他线程尝试竞争这个锁,此时就 偏向锁状态 -> 轻量级锁(自旋锁)状态
- 当锁竞争的情况非常激烈,此时就 轻量级锁状态 -> 重量级锁状态
偏向锁
偏向锁并不是真的加锁,而是做一个标记,记录这个锁属于哪个线程(这个标记非常轻量,比加锁的效率高),然后根据情况决定后续:
- 如果后续没有线程来竞争这个锁,那就不需要再进行同步操作了,避免了加锁解锁的开销
- 如果后续有其他线程来竞争这个锁,那就取消原来的偏向锁状态,进入一般的轻量级锁状态,也就是抢先一步拿到这个锁
当前的 JVM 只提供了锁升级的逻辑,不能锁降级。
2.3 其他优化操作
锁消除
通过编译器和 JVM 自动判断当前的代码是否真的需要加锁,如果写了 synchronized 但是当前情况并不需要加锁,为了节省资源就会把锁消除掉。
虽然编译器可以自动判断是否需要加锁,但是也不能到处都写 synchronized ,不能太依赖编译器的优化,有时候编译器优化也不是很准确。
锁粗化
如果一段代码中有反复加锁解锁的操作,那么编译器和 JVM 就会将其粗化。
锁的粒度指的是在加锁和解锁之间的代码量,如果代码量多,就说锁的粒度较粗,反之则是粒度较细。
如果代码中反复针对细粒度的代码加锁解锁,就会被优化成粗粒度的加锁解锁。
2.4 相关面试题
面试题1:什么是偏向锁?
- 偏向锁不是真的加锁,而是在锁的对象头中记录一个标记(记录该锁所属的线程)。如果没有其他线程竞争这个锁,就不会真正执行加锁操作,而是执行完后消除标记,从而降低程序开销;如果有其他线程参与竞争这个锁,就会立即取消偏向锁状态,进入轻量级锁状态。
文章到这里就告一段落了,若有错误请尽管指出~
完