在 Java 并发编程中,ReentrantLock 是我们最常用的同步工具之一。与 synchronized 关键字不同,ReentrantLock 提供了更高的灵活性,其中一个显著的特性就是支持公平锁和非公平锁的选择。
一、 什么是公平锁与非公平锁?
1. 公平锁 (Fair Lock)
定义:是指多个线程按照申请锁的顺序来获取锁。
比喻:这就像是文明排队买票,先来的人先买,后来的人只能站在队尾等待。
特点:严格遵循 FIFO(先进先出)原则,所有的线程都会进入队列排队。
2. 非公平锁 (Unfair Lock)
定义:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
比喻:这好比在排队买票时,窗口刚空出来,一个刚到的人恰好看到,直接插队买走了票,而不用去队尾排队。
特点:允许“插队”。如果申请锁时锁恰好是空闲的,线程可以直接尝试获取,而不需要判断队列中是否有其他线程在等待。
二、 代码实现
在 Java 的 java.util.concurrent.locks.ReentrantLock 中,我们可以通过构造函数指定锁的类型:
// 1. 创建公平锁
// 传入 true 表示公平锁,遵循先来后到
Lock fairLock = new ReentrantLock(true);
// 2. 创建非公平锁
// 传入 false 表示非公平锁,允许插队
Lock unfairLock = new ReentrantLock(false);
// 3. 默认实现
// 为了性能考虑,无参构造函数默认创建的是【非公平锁】
Lock defaultLock = new ReentrantLock();三、 为什么默认是非公平锁?(核心原理)
这是面试中最高频的问题之一:既然“公平”听起来更合理,为什么 Java 默认选择“非公平”?
这就涉及到了性能与线程调度开销的权衡。
1. 恢复挂起线程的时间差(CPU 角度)
当一个持有锁的线程释放锁时,如果采用公平策略,需要唤醒队列中等待的下一个线程。
从“释放锁”到“等待线程被唤醒并真正开始运行”,这中间存在着一段时间差。虽然在人眼看来微乎其微,但在 CPU 的高速运算下,这段延迟是非常明显的。
- 非公平锁的策略:在这段“空档期”,如果有一个新的线程刚好尝试获取锁,它就可以立刻拿到锁并执行。这样可以填补 CPU 的空闲时间,让 CPU 跑得更满。
2. 线程切换的开销(Context Switch)
使用多线程时,线程切换(上下文切换) 是性能杀手之一。
- 公平锁:每次释放锁,都必须唤醒队列中的下一个线程。这意味着频繁的阻塞和唤醒,导致大量的上下文切换。
- 非公平锁:刚释放锁的线程,往往其相关数据还在 CPU 缓存(Cache)中,如果它(或刚刚到达的新线程)能立刻获取锁,就能利用缓存优势,且避免了挂起和恢复的开销。
3. 为什么刚释放锁的线程容易再次获取?
在非公平模式下,当线程 A 释放锁时,如果线程 A 自己(或者恰巧同时到来的线程 B)立刻再次请求锁,由于它们当前处于运行状态(RUNNABLE) ,不需要经历“唤醒”过程,因此它们获取锁的速度要远快于那些躺在队列里等待被唤醒的线程。
总结:非公平锁的设计初衷是为了更高的吞吐量和更少的线程切换开销。
四、 优缺点对比与风险
| 特性 | 公平锁 (Fair) | 非公平锁 (Unfair) |
|---|---|---|
| 获取策略 | 严格排队,先来先得 | 可以插队,抢占式 |
| 吞吐量 | 较低(频繁上下文切换) | 较高(充分利用 CPU 时间片) |
| 线程开销 | 大(唤醒成本高) | 小 |
| 潜在风险 | 性能较差 | 饥饿 (Starvation) :队尾线程可能一直抢不到锁 优先级反转:低优先级线程可能抢在前面 |
五、 如何选择?
我们在开发中应该如何抉择?
- 追求高吞吐量(默认推荐) :
如果你的业务场景对执行顺序没有严格要求,且希望系统性能最大化,请使用非公平锁(即默认设置)。绝大多数并发场景下,非公平锁都是更好的选择,因为它能节省大量的线程切换时间。 - 追求绝对公平:
如果你的业务逻辑要求必须按照请求顺序来执行(例如:按照先来后到的顺序处理订单、打印任务等),或者持有锁的时间非常长(导致插队也没意义,因为都要等很久),那么应该使用公平锁。
💡 面试题总结
Q1: 什么是公平锁和非公平锁?它们有什么区别?
A:
- 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列排队,FIFO(先进先出)。
- 非公平锁:线程获取锁时,会先尝试直接占有锁(插队),如果失败再进入队列排队。
- 区别:公平锁保证了顺序,但吞吐量低;非公平锁效率高,但可能导致某些线程“饥饿”。
Q2: 为什么 ReentrantLock 默认是非公平锁?
A:
主要是为了性能和吞吐量。
- 减少上下文切换:唤醒挂起的线程需要时间,从 CPU 角度看开销很大。
- 利用时间差:在恢复一个阻塞线程的过程中,CPU 可能处于空闲状态。非公平锁允许新来的线程利用这段空闲时间立即执行,提高了 CPU 利用率。
Q3: 非公平锁有什么缺点?
A:
主要缺点是可能造成优先级翻转或者饥饿(Starvation)。
即排在队列后面的线程可能因为一直有新线程插队而迟迟获取不到锁,极端情况下可能永远执行不到。
Q4: 什么情况下应该使用公平锁?
A:
当业务场景严格要求先来后到(如排队业务),或者锁的持有时间相对较长(此时插队的性能优势不明显,反而由于饥饿问题弊大于利)时,应考虑使用公平锁。