为什么非公平锁在真是场景中比公平锁要多
直接给出核心答案:为了性能(更高的吞吐量)。
在绝大多数真实场景中,非公平锁的性能要比公平锁高出 5-10 倍 甚至更多。
为了让你彻底理解,我们从现实生活的例子 、底层技术原理 和副作用三个角度来拆解。
- 现实生活中的例子:食堂打饭
想象一下大家在食堂排队打饭:
-
公平锁(Fair): 窗口里的人刚打完饭离开。此时窗口空了。 就算你刚才正巧 走到窗口旁边,你也绝对不能直接插进去打饭。你必须先回头看看后面队伍里有没有人排队。如果有,你必须乖乖走到队尾去排队,让队头那个还在低头玩手机的人(被挂起的线程)走上来打饭。
-
非公平锁(Non-Fair): 窗口里的人刚离开。 你正巧 走到窗口旁,看窗口没人,直接一步跨上去就打饭。 后面排队的人虽然心里不爽(不公平),但因为你动作很快,打个饭只要 2 秒钟,等你打完了,队头那个人才刚刚把手机收起来走到窗口前。
关键点来了: 在"非公平"的场景下,窗口(CPU)几乎没有空闲时间 ,一直在给人打饭。 而在"公平"的场景下,队头那个人从"收到信号"到"走到窗口"的这段时间,窗口是闲置的,这就浪费了资源。
- 技术层面的核心原因:上下文切换的开销
在计算机底层,线程的挂起(Suspend)和唤醒(Resume)是非常昂贵的操作,需要操作系统从用户态切换到内核态,这就是上下文切换(Context Switch)。
A. 只有公平锁时(巨大的时间空隙)
-
线程 A 释放锁。
-
AQS 队列发现队头是线程 B,于是去唤醒线程 B。
-
【关键耗时】 :线程 B 从"睡眠"到"真正被 CPU 调度运行"需要时间(比如 10 微秒)。在这 10 微秒里,CPU 是空闲的,锁也是空闲的。
-
线程 B 拿到锁,开始执行。
B. 非公平锁时(填补空隙)
-
线程 A 释放锁。
-
AQS 队列去唤醒线程 B。
-
【关键时刻】 :在线程 B 还没彻底醒过来的这 10 微秒里,线程 C 刚好请求锁。
-
因为是非公平,线程 C 发现锁没人用,直接占有锁,开始执行。
-
线程 C 执行速度很快(比如只用了 5 微秒),执行完释放锁。
-
此时,线程 B 刚好彻底醒过来,顺利拿到锁。
结果对比:
-
在同样的时间段内,非公平模式下,CPU 额外处理掉了线程 C 的任务。
-
这就是为什么非公平锁能极大提升系统吞吐量(Throughput)。
- 还有一个原因:CPU 缓存(Cache Locality)
-
刚来的线程 C:它大概率正在 CPU 上运行,或者它的数据还在 CPU 的高速缓存(L1/L2 Cache)里,是"热"的。让它直接拿锁执行,效率极高。
-
排队的线程 B:因为它被挂起了,它的数据可能已经从 CPU 缓存里被挤出去了,是"冷"的。唤醒它不仅慢,执行起来还需要重新加载缓存。
所以,把锁给"热"的线程 C,从系统整体利用率来看是最优解。
- 所谓的"非公平"究竟是怎么运作的?
很多人误以为非公平锁就是"随机乱抢",其实不是。
Java 的 ReentrantLock 实现非公平锁的逻辑是: 当一个线程请求锁时:
-
先尝试插队(直接 CAS 修改状态尝试拿锁)。
-
如果插队成功,直接拿走锁(这就是非公平的体现)。
-
如果锁已经被占用了,或者插队失败,它还是得乖乖去队尾排队。
所以,非公平锁只是给了新来的线程一次"由于线程唤醒延迟而带来的捡漏机会"。如果没捡到漏,它依然遵循 FIFO(先进先出)的规则。
- 非公平锁的代价:线程饥饿(Starvation)
非公平锁这么好,为什么还需要公平锁?
因为非公平锁可能导致线程饥饿 。 如果一直有源源不断的新线程(线程 C, D, E...)过来插队,并且它们执行时间都很短,那么排在队头的线程 B 可能永远抢不到锁,一直被按在地上摩擦。
但在真实场景中:
-
大部分业务逻辑(Web 请求、数据库操作)持有锁的时间都非常短。
-
新线程到达的频率通常也不会极端到"无缝衔接"。
-
所以线程 B 最终还是能拿到锁的,只是偶尔多等一会儿。
公平锁和非公平锁的使用场景
一、 非公平锁 (Non-Fair Lock) 的使用场景
口诀: 只要业务没有强制的"先来后到"要求,默认全部使用非公平锁。
- 绝大多数的高并发业务 (Web服务器)
-
场景描述: 像 Tomcat 处理 HTTP 请求、Spring Boot 的 Controller 逻辑。
-
为什么:
-
这些请求通常执行速度非常快(毫秒级)。
-
如果在这么短的任务间强行排队,线程唤醒和切换的耗时(Context Switch)可能比执行任务本身还长。
-
使用非公平锁可以让刚释放锁的线程再次拿锁(或者新来的线程插队),利用 CPU 缓存热度,极大提高每秒处理请求数(QPS)。
-
-
例子:
synchronized关键字就是强制非公平的;ReentrantLock默认也是非公平的。
- 短时间的临界区 (Short Critical Sections)
-
场景描述: 只需要修改一个状态值、从 HashMap 中取个值、或者做一个简单的计算。
-
为什么: 拿锁 -> 改值 -> 释放锁,整个过程可能只需要几纳秒。如果为了这几纳秒去维护一个庞大的等待队列并严格按顺序唤醒,属于"杀鸡用牛刀",浪费资源。
- 允许少量"插队"或"饥饿"的场景
-
场景描述: 抢红包、秒杀(在某些层面)、非严格顺序的日志记录。
-
为什么: 用户并不在乎我是第 1000 个请求还是第 1001 个,只要系统不崩、能响应即可。个别线程多等了几毫秒,用户是无感知的。
公平锁 (Fair Lock) 的使用场景
口诀: 只有当"插队"会导致严重后果,或者持有锁的时间特别长时,才考虑公平锁。
- 持有锁时间很长 (Long Hold Times)
-
场景描述: 线程拿到锁后,要执行因为 I/O 密集型操作(如读写大文件、慢速网络请求、复杂的图像处理)。
-
为什么:
-
如果是由于任务本身耗时长(比如 1秒),那么线程切换的开销(10微秒)就显得微不足道了。
-
在这种情况下,非公平锁的"插队"优势消失。反而是如果允许插队,可能导致排在队尾的线程长时间拿不到锁(饥饿),最终导致请求超时报错。公平锁能确保每个线程在有限时间内都能执行。
-
- 严格要求"先来后到"的业务 (First-Come-First-Served)
-
场景描述: * 打印机队列: 先点打印任务的必须先打印。
-
公平交易撮合: 股票交易系统中,同价格下,先提交的订单必须先成交。
-
排队系统: 比如银行叫号系统的内部逻辑模拟。
-
-
为什么: 这是业务逻辑的硬性要求,性能必须让位于顺序。
- 避免"线程饥饿" (Starvation Avoidance)
-
场景描述: 或者是系统负载极高,且每个请求都至关重要,不能接受任何一个请求因为一直被插队而超时失败。
-
例子: 发送心跳包的线程。如果因为非公平锁导致心跳线程一直抢不到锁,可能会被误判为服务宕机,导致不必要的重启。
三、 决策指南:怎么选?
为了方便记忆,我为你总结了一个简单的决策流程图:
核心对比总结
| 维度 | 非公平锁 (Non-Fair) | 公平锁 (Fair) |
|---|---|---|
| Java默认 | synchronized, ReentrantLock() |
new ReentrantLock(true) |
| 吞吐量 | 极高 (减少唤醒开销) | 一般 (严格排队) |
| 上下文切换 | 少 | 多 (每次都要唤醒队头) |
| 饥饿现象 | 可能发生 (某线程一直抢不到) | 几乎不会 (保证执行) |
| 适用场景 | 高并发、任务短、追求性能 | 任务长、强顺序、防饥饿 |