一、为什么面试官总爱问“锁策略”
多线程并发编程一直是 Java 后端面试的高频考点,而在并发编程中,“锁”又是绕不开的核心主题。很多同学能背出 synchronized、ReentrantLock、CAS、乐观锁、悲观锁这些名词,但一旦面试官追问“你为什么选择公平锁而不是非公平锁”“偏向锁到底解决了什么问题”“StampedLock 和 ReentrantReadWriteLock 的区别是什么”,往往就答得支离破碎。
根本原因在于,大家在学习锁的时候更多是在记忆 API,而没有形成一套完整的“锁策略”视角。所谓锁策略,并不是某一个具体的类或关键字,而是一组解决并发安全问题的设计思想和取舍原则。同一个锁实现可能同时具备多种策略特征,例如 ReentrantLock 既可以是非公平锁,也可以是公平锁;既可以作为互斥锁使用,也可以通过 Condition 实现等待唤醒。
这篇文章会从面试官视角出发,系统梳理多线程中常见的锁策略,包括乐观锁与悲观锁、公平锁与非公平锁、独占锁与共享锁、可重入锁、自旋锁、偏向锁、轻量级锁、重量级锁、分段锁、锁消除与锁粗化、死锁与活锁等内容,并结合 synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock、AQS、CAS 等具体实现进行深入分析。全文约两万字,建议收藏后分章节阅读。
阅读提示:如果你准备的是初中级岗位,重点掌握第一节到第十一节;如果目标是高级或专家岗位,请特别关注 AQS 源码思路、锁升级流程、StampedLock 读写模式以及实际项目中的锁选型。
二、锁策略的总览框架
在深入具体策略之前,我们先建立一张总体知识地图。多线程中的常用锁策略可以从以下几个维度进行划分:
| 分类维度 | 策略名称 | 核心思想 | 典型实现 |
|---|---|---|---|
| 是否假设竞争 | 乐观锁 / 悲观锁 | 悲观锁认为冲突一定发生,乐观锁认为冲突不一定发生 | synchronized / CAS |
| 是否排队公平 | 公平锁 / 非公平锁 | 公平锁按等待顺序获取,非公平锁允许插队 | ReentrantLock |
| 允许多少线程同时访问 | 独占锁 / 共享锁 | 独占锁一次只允许一个线程,共享锁允许多个读线程 | ReentrantReadWriteLock |
| 同一线程能否重复获取 | 可重入锁 / 不可重入锁 | 可重入锁允许同一线程多次获取同一把锁 | synchronized、ReentrantLock |
| 获取失败后是否等待 | 自旋锁 / 阻塞锁 | 自旋锁反复尝试,阻塞锁让出 CPU | AtomicInteger、AQS |
| 是否按对象状态优化 | 偏向锁 / 轻量级锁 / 重量级锁 | 根据竞争情况逐步升级锁状态 | synchronized |
| 是否拆分锁粒度 | 分段锁 / 细粒度锁 | 把一个全局锁拆成多个小锁减少竞争 | ConcurrentHashMap |
下面我们逐一拆解每种策略的原理、优缺点和使用场景。
三、乐观锁与悲观锁
3.1 什么是悲观锁
悲观锁的核心假设是:共享资源每次被访问时都大概率会发生冲突,所以必须先加锁,再操作数据。它的思路非常直接,就像一个人总是往坏处想:只要我不锁起来,别人一定会来改。因此悲观锁在操作数据前,会先通过锁机制把资源保护起来,其他线程只能阻塞等待。
在 Java 中,典型的悲观锁实现包括 synchronized 关键字和各种 Lock 接口实现。例如:
public class PessimisticLockDemo { private int count = 0; private final Object lock = new Object(); public void increment() { synchronized (lock) { count++; } } }悲观锁的优点是实现简单、语义清晰,能够保证线程安全。缺点也很明显:无论是否真的发生冲突,每次都要付出加锁和解锁的代价;在高并发场景下,如果竞争激烈,会导致大量线程阻塞和上下文切换,性能下降。此外,悲观锁如果使用不当,还容易引发死锁问题。
3.2 什么是乐观锁
乐观锁的核心假设是:共享资源大多数情况下不会发生冲突,所以先不上锁,直接操作,在更新时再检查是否有其他线程修改过数据。如果发现数据已经被修改,就放弃本次更新或进行重试;如果没有被修改,则提交更新。
乐观锁最经典的实现机制是CAS(Compare And Swap,比较并交换)。CAS 包含三个操作数:内存位置 V、期望原值 A、新值 B。只有当 V 的值等于 A 时,才把 V 更新为 B,否则不做任何操作,并返回失败信息。
Java 中的原子类就是基于 CAS 实现的:
import java.util.concurrent.atomic.AtomicInteger; public class OptimisticLockDemo { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } }乐观锁的优点是在冲突较少的场景下性能很高,因为不需要加锁和阻塞线程。缺点是一旦冲突频繁,CAS 会反复失败并重试,浪费 CPU 资源。此外,乐观锁还存在著名的ABA 问题,我们稍后详细讨论。
3.3 乐观锁与悲观锁的对比
| 对比项 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心假设 | 冲突一定会发生 | 冲突不一定会发生 |
| 加锁时机 | 操作数据前加锁 | 操作数据时不加锁,更新时校验 |
| 实现方式 | synchronized、Lock | CAS、版本号机制 |
| 线程阻塞 | 可能阻塞 | 一般不阻塞,失败重试 |
| 适用场景 | 写多读少、冲突激烈 | 读多写少、冲突较少 |
| 潜在风险 | 死锁、性能下降 | ABA 问题、CPU 空转 |
实际开发中,并不能简单地说乐观锁一定比悲观锁好。如果竞争非常激烈,CAS 反复失败会导致 CPU 长时间空转,此时悲观锁反而更合适。选择锁策略一定要结合具体业务场景和并发压力进行压测。
四、公平锁与非公平锁
4.1 基本概念
公平锁和非公平锁描述的是多个线程在等待同一把锁时的获取顺序。
- 公平锁:多个线程按照申请锁的先后顺序,先到先得。新来的线程如果发现锁已经被占用,会进入等待队列排队,不会插队。
- 非公平锁:多个线程获取锁的顺序不一定是先到先得。新来的线程在尝试获取锁时,如果锁刚好被释放,可以直接抢占,不必排队。
ReentrantLock 提供了公平锁和非公平锁两种实现。默认构造方法创建的是非公平锁,传入 true 则创建公平锁:
import java.util.concurrent.locks.ReentrantLock; public class FairLockDemo { // 非公平锁 private final ReentrantLock nonFairLock = new ReentrantLock(); // 公平锁 private final ReentrantLock fairLock = new ReentrantLock(true); }synchronized 在 Java 层面只能使用非公平锁,无法指定为公平锁。这是因为 synchronized 依赖 JVM 底层的监视器机制,而该机制并未提供公平性保证。
4.2 公平锁与非公平锁的优缺点
公平锁的优点是每个线程都能得到执行机会,不会出现某些线程长期等待甚至“饥饿”的情况,尤其在任务执行时间差异较大的场景下更加友好。
公平锁的缺点是性能较低。为了保证顺序,公平锁需要维护严格的等待队列,并且每次线程切换都伴随大量上下文切换。很多锁在释放的瞬间,等待队列中的线程还没来得及被唤醒,如果此时强行要求公平,就会白白浪费一次加锁机会。
非公平锁的优点是吞吐量更高。因为新线程有机会直接获取刚释放的锁,减少了线程阻塞和唤醒的次数,提升了整体执行效率。
非公平锁的缺点是可能造成线程饥饿。某些优先级较低或运气较差的线程可能长时间拿不到锁,不过在实际应用中,长时间饥饿的情况相对较少。
4.3 ReentrantLock 公平锁与非公平锁的源码体现
在 ReentrantLock 中,公平锁和非公平锁的核心差异主要体现在 tryAcquire 方法。公平锁会先判断等待队列中是否有前驱节点,只有队列为空或当前线程是队首时才能尝试获取锁:
// 公平锁的 tryAcquire 简化逻辑 protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { // 可重入逻辑 int nextc = c + acquires; setState(nextc); return true; } return false; }关键就在于hasQueuedPredecessors(),它会检查当前线程前面是否还有等待节点。非公平锁则直接尝试 CAS 抢占,不会进行这层检查。
4.4 面试常见追问
问:为什么默认使用非公平锁?因为非公平锁在多数场景下吞吐量更高。线程被唤醒本身就存在时间差,非公平锁可以利用这个时间差让新线程快速完成加锁与释放,避免 CPU 空转。
问:公平锁就真的完全公平吗?公平锁只是尽量保证按队列顺序获取,但并不能保证绝对公平。线程调度由操作系统控制,一个线程即使拿到了锁的执行资格,也可能因为 CPU 时间片轮转而被暂停。
五、独占锁与共享锁
5.1 概念解析
独占锁和共享锁描述的是同一时刻允许多少个线程持有锁。
- 独占锁:也常被称为互斥锁、排他锁或写锁。同一时刻只允许一个线程持有该锁,其他线程必须等待。
- 共享锁:也常被称为读锁。同一时刻允许多个线程同时持有该锁,但这些线程通常只能进行读操作,不能修改数据。
Java 中的 synchronized 和 ReentrantLock 都是独占锁。而 ReentrantReadWriteLock 则把读写操作区分开,内部维护了一把读锁和一把写锁,实现了读写分离。
import java.util.concurrent.locks.ReentrantReadWriteLock; public class ReadWriteLockDemo { private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock(); private int data; public int read() { readLock.lock(); try { return data; } finally { readLock.unlock(); } } public void write(int value) { writeLock.lock(); try { data = value; } finally { writeLock.unlock(); } } }5.2 读写锁的核心价值
在很多业务场景中,读操作远远多于写操作,而且读操作之间并不需要互斥。如果所有操作都使用独占锁,那么多个读线程也必须串行执行,严重浪费并发能力。引入共享锁后,多个读线程可以同时读取数据,只有写线程才需要独占访问,从而大幅提升读多写少场景下的吞吐量。
不过读写锁也带来了新的问题:读写互斥、写写互斥的规则使得锁的调度更加复杂。特别是在写线程不断到达时,读线程可能一直无法获取读锁,出现读线程饥饿问题。
5.3 读写锁的互斥规则
| 当前持有锁 | 请求读锁 | 请求写锁 |
|---|---|---|
| 无锁 | 允许 | 允许 |
| 读锁 | 允许 | 不允许 |
| 写锁 | 不允许 | 不允许 |
简单记忆:读读可以共存,读写互斥,写写互斥。需要注意的是,在 ReentrantReadWriteLock 的默认实现中,如果已经有读线程持有读锁,此时写线程请求写锁需要等待;同时,如果写线程已经在排队,后续到达的读线程是否还能获取读锁,取决于锁的公平性设置。
六、可重入锁与不可重入锁
6.1 什么是可重入锁
可重入锁也叫递归锁,指的是同一个线程在持有锁的情况下,可以再次获取同一把锁而不会被自己阻塞。如果锁不可重入,线程在递归调用或嵌套调用加锁方法时,会因为无法再次获得已经被自己持有的锁而死锁。
Java 中的 synchronized 和 ReentrantLock 都是可重入锁。synchronized 的可重入性由 JVM 内部实现,每次重入时监视器的重入次数加一,退出时减一,次数归零时才会真正释放锁。
public class ReentrantDemo { public synchronized void methodA() { System.out.println("methodA start"); methodB(); } public synchronized void methodB() { System.out.println("methodB start"); } }在上面的代码中,方法 methodA 和 methodB 都被 synchronized 修饰。当线程调用 methodA 后,又在其内部调用 methodB,因为 synchronized 是可重入锁,所以 methodB 能够顺利获取同一把对象锁,而不会发生死锁。
6.2 ReentrantLock 的可重入原理
ReentrantLock 内部通过一个 state 变量记录重入次数。每次成功加锁,state 加一;每次解锁,state 减一。当 state 归零时,才真正释放锁并唤醒等待队列中的线程。
// ReentrantLock 非公平锁 tryAcquire 的简化实现 final boolean nonfairTryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) { throw new Error("Maximum lock count exceeded"); } setState(nextc); return true; } return false; }一旦当前线程已经是锁的持有者,就会直接累加 state 而不会发生阻塞,这就是可重入的具体体现。
6.3 不可重入锁有什么问题
不可重入锁在获得锁之后,如果当前线程再次尝试获取同一把锁,就会自己阻塞自己,最终导致死锁。
public class NonReentrantLock { private boolean locked = false; public synchronized void lock() { while (locked) { try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } locked = true; } public synchronized void unlock() { locked = false; notify(); } }在上面的实现中,lock()方法已经用synchronized修饰,同时内部又通过wait()让竞争线程等待。如果当前线程已经获得锁,又在同一线程内再次调用lock(),由于locked已经被置为true,线程会进入while (locked)的等待分支,自己把自己阻塞住;而释放锁又需要同一个线程继续执行到unlock(),于是形成死锁。
这也解释了为什么 synchronized 和 ReentrantLock 会专门设计可重入能力:在实际项目中,递归调用、嵌套调用、模板方法等场景非常普遍,如果锁不可重入,代码很容易在无意中自锁。
七、自旋锁与阻塞锁
7.1 什么是自旋锁
自旋锁描述的是线程获取锁失败后,不立即挂起休眠,而是在原地循环尝试,直到成功获取锁为止。它的核心思想是:如果锁很快就会被释放,与其让线程进行昂贵的上下文切换,不如让 CPU 忙等一小段时间。
在 Java 中,自旋锁通常依赖 CAS 实现。下面的例子通过 AtomicReference 模拟一个简单的自旋锁:
import java.util.concurrent.atomic.AtomicReference; public class SimpleSpinLock { private final AtomicReference<Thread> owner = new AtomicReference<>(); public void lock() { Thread current = Thread.currentThread(); while (!owner.compareAndSet(null, current)) { // 自旋等待,不做任何事 } } public void unlock() { Thread current = Thread.currentThread(); owner.compareAndSet(current, null); } }7.2 自旋锁的优缺点
优点是避免了线程阻塞和唤醒带来的上下文切换开销。如果锁的持有时间非常短,自旋等待的代价远小于两次线程切换的代价。
缺点是如果锁被长时间持有,自旋会白白占用 CPU,甚至出现多个线程一起空转,导致系统吞吐量下降。因此,自旋锁适合锁竞争不激烈、临界区非常短的场景。
7.3 阻塞锁与自适应自旋锁
与自旋锁相对的是阻塞锁:线程获取锁失败后主动挂起,让出 CPU,当锁释放后再被唤醒。阻塞锁能避免 CPU 空转,但线程切换成本更高。
JVM 对 synchronized 做了自适应自旋优化:如果同一次自旋曾经成功获取过锁,下一次就适当增加自旋次数;如果自旋很少成功,就减少甚至直接阻塞,从而在自旋和阻塞之间动态平衡。
八、偏向锁、轻量级锁与重量级锁
8.1 锁升级的核心思想
偏向锁、轻量级锁、重量级锁是 synchronized 在 JVM 层面的锁升级过程。JVM 会根据竞争激烈程度,逐步把锁从低开销状态升级到高开销状态,目的是:在竞争不激烈时尽量少付出锁开销,在竞争激烈时保证线程安全。
锁的升级顺序一般是:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这些状态记录在对象头的 Mark Word 中。
8.2 偏向锁
偏向锁假设锁总会被同一个线程获取。当某个线程第一次获取锁时,JVM 会把锁“偏向”给这个线程,并在对象头中记录线程 ID;以后该线程再次进入同步块时,不需要任何 CAS 操作,直接判断 Mark Word 即可,开销极低。
偏向锁适合只有一个线程反复获取同一把锁的场景。一旦其他线程也来竞争,偏向锁会撤销,升级为轻量级锁。需要注意的是,JDK 15 开始默认禁用偏向锁,因为现代应用中的竞争模式已经发生变化,偏向锁的维护成本有时反而更高。
8.3 轻量级锁
当多个线程竞争但冲突不激烈时,JVM 使用轻量级锁。线程会通过 CAS 把 Mark Word 替换成指向栈中锁记录的指针,失败则进入自旋等待。轻量级锁避免了操作系统层面的互斥量操作,减少了线程阻塞。
8.4 重量级锁
当竞争非常激烈、自旋长时间无法成功时,锁会升级为重量级锁。重量级锁依赖操作系统的 monitor 互斥量,竞争失败会被阻塞挂起,涉及用户态和内核态的切换,开销最大,但 CPU 消耗小。
8.5 升级与降级
一般情况下,锁只能从低级向高级升级。重量级锁释放后,对象会回到无锁状态,而不是逐步降回偏向锁。理解这个过程,有助于解释“为什么 synchronized 后来性能优化了”——它不再是简单的一把重量级锁,而是能根据场景动态调整策略。
九、分段锁
9.1 什么是分段锁
分段锁的核心思想是把一个全局锁拆分成多个小锁,不同部分的数据由不同锁保护,从而减少锁竞争。最经典的例子就是 JDK 7 的 ConcurrentHashMap。
JDK 7 把哈希表分成多个 Segment,每个 Segment 独立加锁,写入不同 Segment 的数据可以并行进行,整体锁粒度从“整个表”降为“某一段”。
9.2 分段锁的演进
JDK 8 对 ConcurrentHashMap 做了重构,不再使用 Segment,而是改用CAS + synchronized:插入时通过 CAS 初始化数组节点,发生哈希冲突时只对桶的首节点加锁。这样锁的粒度进一步细化为“单个桶”,并减少了分段设计带来的复杂度。
9.3 分段锁的优缺点
优点是在高并发场景下显著降低竞争,提高吞吐量;缺点是当需要执行跨多段的全局操作时,必须同时获取多把锁,实现复杂且容易出错。分段锁适合“大部分操作只影响局部数据”的场景。
十、锁消除与锁粗化
10.1 锁消除
锁消除是 JVM 在 JIT 编译阶段的一项优化。编译器通过逃逸分析判断某个对象是否可能被多个线程访问,如果对象只会被当前线程使用,那么对这个对象加的锁完全可以去掉。
典型例子是在方法内部使用 StringBuffer 或 Vector,虽然它们的每个方法都有 synchronized,但由于对象不会逃逸到其他线程,JVM 会直接消除锁,避免无意义的同步开销。
10.2 锁粗化
锁粗化与锁消除相反,它是把多次细粒度的加锁解锁合并为一次更大的锁。例如在一个循环里反复对同一把锁加锁、解锁,JVM 可能把锁的粒度扩大到整个循环外部,减少加锁次数:
// 原始代码:循环内频繁加锁 public void append(StringBuilder sb, String... values) { for (String value : values) { synchronized (this) { sb.append(value); } } } // 锁粗化后的等价语义:整个循环只需要一把锁 public synchronized void append(StringBuilder sb, String... values) { for (String value : values) { sb.append(value); } }需要注意的是,锁粗化虽然是 JVM 的自动优化,但我们在写代码时也应避免在循环内反复加锁,从源头减少不必要的同步。
十一、死锁与活锁
11.1 死锁的四个必要条件
产生死锁必须同时满足四个条件:
- 互斥条件:资源一次只能被一个线程占用。
- 占有且等待:线程已经占有一个资源,同时还在等待另一个资源。
- 不可剥夺:线程已获得的资源在未用完前不能被强制抢占。
- 循环等待:存在一条线程与资源的循环等待链。
只要破坏其中任意一个条件,就可以避免死锁。
11.2 经典的死锁示例
public class DeadlockDemo { private final Object lockA = new Object(); private final Object lockB = new Object(); public void method1() { synchronized (lockA) { synchronized (lockB) { System.out.println("method1"); } } } public void method2() { synchronized (lockB) { synchronized (lockA) { System.out.println("method2"); } } } }线程 T1 持有 lockA 等待 lockB,线程 T2 持有 lockB 等待 lockA,双方都无法继续执行,形成死锁。
11.3 如何排查与预防死锁
排查死锁可以借助 JDK 自带的工具,例如使用jstack -l 进程号查看线程状态,JVM 通常会在输出中明确指出发生死锁的线程和锁信息;也可以通过 JConsole、VisualVM 或 ThreadMXBean 进行检测。
预防死锁的常见方式包括:
- 多个锁的获取顺序保持一致,破坏循环等待。
- 尽量使用 tryLock 并设置超时时间,避免无限等待。
- 减少锁持有时间,缩小临界区。
- 能使用一把锁完成时,不要嵌套使用多把锁。
11.4 活锁与饥饿
活锁指的是线程虽然没有阻塞,但一直在重复执行无意义的动作,导致整体无法推进。典型例子是:线程 A 和线程 B 互相谦让锁,A 释放后 B 也同时释放,结果谁都没拿到锁。
饥饿指的是某些线程因为优先级低或调度策略问题,始终得不到执行机会。公平锁可以减少饥饿,但也可能牺牲部分吞吐量。解决活锁通常需要引入随机退避或让线程的等待时间错开。
十二、总结与面试清单
12.1 锁策略速查
| 思考维度 | 你要回答的关键点 |
|---|---|
| 竞争是否必然 | 悲观锁先加锁,乐观锁用 CAS 或版本号后校验 |
| 获取顺序是否公平 | 公平锁先到先得,非公平锁允许插队,吞吐量更高 |
| 同时允许多少线程 | 独占锁单线程,共享锁多读单写 |
| 是否可重入 | synchronized、ReentrantLock 都可重入,用 state 计数 |
| 失败后是否等待 | 自旋锁忙等,阻塞锁挂起让出 CPU |
| synchronized 如何优化 | 偏向锁、轻量级锁、重量级锁逐步升级 |
| 锁粒度如何控制 | 分段锁、细粒度锁、锁消除和锁粗化 |
12.2 面试高频追问清单
- synchronized 和 ReentrantLock 在锁策略上有哪些异同?
- 可重入锁为什么不会把自己锁死?它的底层 state 是怎么变化的?
- 自旋锁适合什么场景?为什么不能无限制自旋?
- 偏向锁、轻量级锁、重量级锁分别在什么条件下触发?
- ConcurrentHashMap 的锁粒度是如何演进的?
- 如何排查线上死锁?如何避免活锁和饥饿?
12.3 最后建议
学习锁策略不要背结论,而要抓住一条主线:每种锁策略本质上都是在“线程安全”和“并发性能”之间做取舍。回答面试题时,先讲清策略的核心思想,再结合具体实现、适用场景和可能的风险,最后补一个实际项目中的选型例子,会比单纯背诵概念更有说服力。
进阶方向:如果你对锁的底层实现感兴趣,可以进一步阅读 AQS 的 acquire / release 源码、ReentrantReadWriteLock 的读写状态位设计,以及 StampedLock 的乐观读模式,并与本文的锁策略框架对照理解。