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<String, ReentrantLock> workspaceLockMap = new ConcurrentHashMap<>();
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<String, Object> mapStyle, Workspace workspace, String workSpacePath) { ReentrantLock lock = workspaceLockMap.get(workSpacePath); if (lock == null) { synchronized (workspaceLockMap) { lock = workspaceLockMap.get(workSpacePath); if (lock == null) { lock = new ReentrantLock(true); // 公平锁 workspaceLockMap.put(workSpacePath, lock); } } } boolean locked = false; try { log.info("【{}】等待工作空间锁 | path={}", mapType, workSpacePath); lock.lockInterruptibly(); locked = true; log.info("【{}】拿到工作空间锁 | path={}", mapType, workSpacePath); doMapInner(eqId, eqTaskId, filePath, mapType, eqTime, eqAddr, eqLon, eqLat, eqDepth, eqMag, mapTitle, mapStyle, workspace, workSpacePath); } finally { if (locked) { lock.unlock(); } } }
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
作者简介:后端开发工程师,专注于高并发、分布式系统、应急指挥系统。
本文基于作者在"地震应急出图系统"中的真实踩坑经验整理。