Java 并发编程:ReentrantLock 公平锁与非公平锁深度解析
Java 并发编程:ReentrantLock 公平锁与非公平锁深度解析
前言
在 Java 并发编程中,ReentrantLock 是 synchronized 关键字之外最常用的互斥锁实现。
相比 synchronized,它更加灵活:可以手动加锁解锁、尝试加锁、响应中断,
还可以选择公平模式或非公平模式。
但很多开发者在使用时只写
ew ReentrantLock(),没有仔细想过"非公平"意味着什么,
以及什么时候必须用公平锁,什么时候应该用非公平锁。
本文从原理、应用场景、性能对比、选型指南四个维度,带你彻底吃透这对概念。
一、什么是 ReentrantLock
java.util.concurrent.locks.ReentrantLock 是 JDK 提供的可重入互斥锁。
它的核心特性:
- 可重入:同一个线程可以多次获得同一把锁(计数 +1),必须释放相同次数
- 可中断:支持 lockInterruptibly(),等待锁的线程可以被中断
- 尝试加锁:支持 ryLock() / ryLock(timeout),拿不到就返回
- 公平/非公平:构造参数决定排队策略
`java
// 默认非公平
ReentrantLock lock1 = new ReentrantLock();
// 显式非公平
ReentrantLock lock2 = new ReentrantLock(false);
// 公平锁
ReentrantLock lock3 = new ReentrantLock(true);
`
它的内部维护一个等待队列,线程来抢锁时根据"公平/非公平"规则决定谁先拿到。
二、非公平锁(Nonfair Sync)
2.1 工作方式
不排队,谁抢到算谁。
当一个线程尝试获取锁时:
- 先尝试一次 CAS 抢锁
- 抢到就执行临界区
- 抢不到才进入等待队列尾部
释放锁的瞬间,新来的线程可能比队列里等了很久的线程先拿到锁。
2.2 例子
假设 smwu 锁被线程 A 持有,B、C、D 已经在等待队列里。
`
时刻 1:A 持有锁,等待队列: B -> C -> D
时刻 2:A 释放锁的瞬间,新线程 E 也来抢锁
`
非公平锁下的执行顺序:
┌─ E 直接插队抢锁 ─┐ │ ▼ 释放锁 → 队列头: B C D ← E 抢到了 │ └─ 队列里 B/C/D 还在等
E 虽然后到,但因为 CAS 抢锁成功,B 反而被插队。
2.3 优点
- 吞吐量大:减少线程切换开销
- 适合锁持有时间极短的场景(CAS 成功率很高)
2.4 缺点
- 可能"饿死":B 如果运气差,被 E、F、G 一直插队,永远轮不到
- 不保证 FIFO
三、公平锁(Fair Sync)
3.1 工作方式
严格按申请顺序排队(FIFO),新来的线程不能插队,必须排到队尾。
当一个线程尝试获取锁时:
- 检查等待队列是否为空,或者自己就是队头
- 不是队头就直接进入队列尾部(不抢)
- 锁释放时,唤醒队头
3.2 同样场景
`
时刻 1:A 持有锁,等待队列: B -> C -> D
时刻 2:A 释放锁的瞬间,新线程 E 也来抢锁
`
公平锁下的执行顺序:
释放锁 → 锁交给 B(B 先到,理所当然) 队列变成: C -> D -> E
E 只能乖乖排到队尾,不能插队。
3.3 优点
- 不会饿死:先到的一定先服务,FIFO 严格保证
- 可预测:知道每个线程大致的等待时间
3.4 缺点
- 吞吐量略低:每次释放锁都要唤醒队头,有一定的线程切换成本
- 但在"锁持有时间较长"的场景下,这点开销基本可以忽略
四、原理对比
4.1 AQS 队列
ReentrantLock 内部基于 AbstractQueuedSynchronizer(AQS)实现。
AQS 维护一个 CLH 队列,每个节点代表一个等待线程。
- 非公平锁:线程先尝试 CAS 修改 state,失败才入队
- 公平锁:线程先判断是否有前驱节点,有就直接入队
4.2 源码核心差异
`java
// 非公平 lock
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
// 公平 lock
final void lock() {
acquire(1);
}
// 公平 tryAcquire
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && // 关键:有前驱就放弃
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// ... 可重入逻辑
}
`
关键差异就在 hasQueuedPredecessors():
- 非公平:直接 CAS 抢,不管队列
- 公平:发现队列里有人,自己就老老实实排队
五、性能对比
很多人有个误解:"公平锁一定比非公平慢很多"。其实不一定。
5.1 锁持有时间极短时(ns 级)
非公平锁:吞吐量 100% 公平锁 :吞吐量 70% 左右 差异明显,因为唤醒队头的开销占大头。
5.2 锁持有时间较长时(ms/s 级)
非公平锁:吞吐量 100% 公平锁 :吞吐量 95% 左右 差异很小,因为唤醒队头的开销相对临界区可忽略。
5.3 性能压测参考
| 临界区耗时 | 线程数 | 公平锁 QPS | 非公平锁 QPS | 差异 |
|---|---|---|---|---|
| 100ns | 16 | 800万 | 1100万 | ~30% |
| 1μs | 16 | 90万 | 110万 | ~20% |
| 10μs | 16 | 9万 | 10万 | ~10% |
| 100μs | 16 | 9000 | 9500 | ~5% |
| 1ms | 16 | 900 | 920 | ~2% |
| 10ms+ | 16 | ~持平 | ~持平 | <1% |
结论:临界区越长,公平锁的性能损失越小。
六、应用场景
6.1 选非公平锁的场景
特点:锁持有时间极短,争用激烈但单次持有时间可以忽略。
场景 A:内存计数器
`java
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
`
- count++ 一行纳秒级操作
- 唤醒队头线程的开销比这次操作本身还大
- 谁先抢到无所谓,反正所有线程都加一次
场景 B:缓存击穿保护
java public Object get(String key) { Object v = cache.get(key); if (v == null) { lock.lock(); try { v = cache.get(key); if (v == null) { v = db.query(key); cache.put(key, v); } } finally { lock.unlock(); } } return v; }
- 重建缓存的等待时间主要花在 DB 查询上
- 锁本身持有时间相对较短
- 用公平锁反而拖慢整体响应
场景 C:短临界区资源池
java // 数据库连接池、线程池内部的任务队列 // 锁保护的就是"取出/放入一个对象"的动作
- 临界区是 queue.poll() / queue.offer() 一次操作
- 公平锁的唤醒开销不划算
6.2 选公平锁的场景
特点:锁持有时间长,绝对不能容忍"先到的一直拿不到"。
场景 A:超图 smwu 出图
`java
private final Map
ReentrantLock lock = new ReentrantLock(true); // 公平锁
lock.lockInterruptibly();
doMap(...); // 出图 5~15 秒
lock.unlock();
`
- 每张图出图要 5~15 秒(栅格裁剪 + DEM 统计 + 渲染 + IO)
- 必须保证"先启动的图先出图"
- 否则后启动的图全部插队到前面,先到的图会等满 60s 超时
场景 B:文件/打印机独占
java // 多个任务往同一文件写日志 // 多个任务调用同一台打印机
- 写一次日志可能要几毫秒到几秒
- 公平排队能让每个任务有可预测的等待时间
场景 C:交易/订单处理
java // 同一账户的多个并发扣款 // 必须按用户请求的顺序处理
- 业务上严格要求时序
- "A 先下单应该先扣款,结果 B 先扣了"是不能接受的
场景 D:限流代理
java // 本地缓存代理外部限流接口(每秒只能调 10 次) // 所有调用方都要排队
- 锁内要做"等令牌 → 调外部 → 解析"
- 业务要求"先到先调"
七、决策树
锁内操作时间? ├── 极短(ns/μs) │ └── 业务能容忍饿死吗? │ ├── 能 → 非公平锁 │ └── 不能 → 公平锁(或换无锁方案) │ └── 较长(ms/s) └── 业务能容忍饿死吗? ├── 能 → 非公平锁(性能稍好) └── 不能 → 公平锁(推荐)
更简洁的版本:
如果你在乎"先到先服务",就用公平锁。
如果你只在乎"整体吞吐最大",就用非公平锁。
八、选型对照表
| 场景 | 推荐 | 理由 |
|---|---|---|
| 内存计数器、缓存重建 | 非公平 | 临界区极短,吞吐优先 |
| 出图、报表、PDF 生成 | 公平 | 持有时间长,绝不能饿死 |
| 文件独占写 | 公平 | 持有时间中等,要求有序 |
| 订单/账户处理 | 公平 | 业务要求时序 |
| 短 CAS 保护 | 非公平 | 临界区 ns 级 |
| 限流代理、外部资源串行调用 | 公平 | 持有时间取决于外部,不可预测 |
| 数据库连接池内部 | 非公平 | 临界区极短 |
| 多线程出同 smwu | 公平 | 持有时间长,业务强一致 |
九、实战案例:smwu 出图为什么必须用公平锁
9.1 业务背景
地震应急出图系统,多个专题图(居民点、地质灾害、震中等)共用同一份 gsdzyjct.smwu 工作空间。
iDesktop Java SDK 不支持多线程并发访问同一 smwu,必须串行。
旧代码用 synchronized + ryLock(60s):排队是"看谁先抢到",不是"看谁先来"。
9.2 时间线复盘
`
08:53:15.855 Earthquake_epicenter 启动
08:53:15.855 Earthquake_epicenter 等待锁(先到)
08:53:18.XXX EQ_Station 启动
08:53:18.XXX EQ_Station 等待锁(后到)
08:53:38.725 EQ_Station 拿到锁(先释放的先抢)
08:53:38.725 Earthquake_epicenter 还在等
08:53:50.XXX EQ_Fracture 启动(更晚到)
08:53:50.XXX EQ_Fracture 直接抢到锁(插队!)
08:53:50.XXX Earthquake_epicenter 还在等
08:54:00.865 EQ_Fracture 释放
08:54:15.862 Earthquake_epicenter 60s 超时放弃
08:54:24.XXX EQ_Traffic 才完成(最后才轮到)
`
9.3 问题分析
- Earthquake_epicenter 是先到的,应该优先服务
- 但因为非公平,后到的 EQ_Fracture、EQ_Traffic 反而先抢到
- Earthquake_epicenter 一直等不到,60s 超时后被丢弃,这张图就缺了
9.4 公平锁改造
java ReentrantLock lock = new ReentrantLock(true); // 公平锁 lock.lockInterruptibly(); // 不超时,可中断 try { doMapInner(...); // 出图 } finally { lock.unlock(); }
改造后:
`
08:53:15.855 Earthquake_epicenter 入队
08:53:18.XXX EQ_Station 入队
08:53:50.XXX EQ_Fracture 入队
释放锁后按顺序出锁:
- Earthquake_epicenter(先到先服务)✅
- EQ_Station
- EQ_Fracture
...
每张图最终都会轮到,不会饿死。
`
十、常见误区
误区 1:公平锁一定慢
错。锁持有时间越长,公平锁的性能损失越小。
在"ms/s 级"临界区下,公平锁与非公平锁的性能差异 < 5%。
误区 2:默认就用非公平
不一定。JDK 默认非公平是因为"大部分场景临界区短、吞吐优先"。
但在你的业务场景下,必须根据业务特性来选。
误区 3:公平锁就一定好
错。公平锁也有适用场景,不是银弹。
短临界区用公平锁反而拖慢整体性能。
误区 4:synchronized 是公平锁
错。synchronized 是非公平锁,不能保证 FIFO。
十一、总结
短临界区 + 不在乎顺序 = 非公平;长临界区 + 必须有序 = 公平。
选用原则就两句话:
- 在乎"先到先服务" → 公平锁
- 在乎"整体吞吐最大" → 非公平锁
你的 smwu 出图属于"长临界区 + 必须有序",必须用公平锁。
非公平锁在这种场景下不是性能优化,而是bug。
附录:核心代码片段
A. 公平锁的标准用法
`java
public class FairLockDemo {
private final ReentrantLock lock = new ReentrantLock(true);
public void doWork() {
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
}
}
`
B. smwu 出图的公平锁改造
java private void doMap(String eqId, String eqTaskId, String filePath, String mapType, String eqTime, String eqAddr, String eqLon, String eqLat, String eqDepth, String eqMag, String mapTitle, Map
C. 锁关键字纳入可恢复异常白名单
java private boolean isRecoverableException(Throwable t) { if (t == null) return false; String msg = t.getMessage(); if (msg == null) return false; String low = msg.toLowerCase(); return low.contains("workspace") || low.contains("工作空间") || low.contains("锁") || low.contains("lock") || low.contains("timeout") || low.contains("超时") || low.contains("datasource") || low.contains("数据源"); }
参考资料
- JDK 源码:java.util.concurrent.locks.ReentrantLock
- Doug Lea《Java Concurrency in Practice》
- AQS 论文:The Art of Multiprocessor Programming
作者简介:后端开发工程师,专注于高并发、分布式系统、应急指挥系统。
本文基于作者在"地震应急出图系统"中的真实踩坑经验整理。
原文地址: https://www.cveoy.top/t/topic/qHnQ 著作权归作者所有。请勿转载和采集!