Java 并发编程:ReentrantLock 公平锁与非公平锁深度解析
前言
在 Java 并发编程中,ReentrantLock 是 synchronized 关键字之外最常用的互斥锁实现。
相比 synchronized,它更加灵活:可以手动加锁解锁、尝试加锁、响应中断,
还可以选择公平模式或非公平模式。
但很多开发者在使用时只写
以及什么时候必须用公平锁,什么时候应该用非公平锁。
本文从原理、应用场景、性能对比、选型指南四个维度,带你彻底吃透这对概念。
new ReentrantLock(),没有仔细想过 "非公平" 意味着什么,
一、什么是 ReentrantLock
java.util.concurrent.locks.ReentrantLock 是 JDK 提供的可重入互斥锁。
- 可重入:同一个线程可以多次获得同一把锁(计数 +1),必须释放相同次数
- 可中断:支持
lockInterruptibly(),等待锁的线程可以被中断 - 尝试加锁:支持
tryLock()/tryLock(timeout),拿不到就返回 - 公平 / 非公平:构造参数决定排队策略
// 默认非公平
ReentrantLock lock1 = new ReentrantLock();
// 显式非公平
ReentrantLock lock2 = new ReentrantLock(false);
// 公平锁
ReentrantLock lock3 = new ReentrantLock(true);
它的内部维护一个等待队列,线程来抢锁时根据 "公平 / 非公平" 规则决定谁先拿到。
二、非公平锁(Nonfair Sync)
2.1 工作方式
不排队,谁抢到算谁。
当一个线程尝试获取锁时:
- 先尝试一次 CAS 抢锁
- 抢到就执行临界区
- 抢不到才进入等待队列尾部
注意:并不是锁释放的方法内部主动给新线程插队机会;当锁释放 state 置为 0,队列中的等待线程还未被唤醒时,如果新线程恰好执行 lock (),会直接 CAS 抢占 state,插队成功;队列里已经排队的线程继续阻塞等待。
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),新来的线程不能插队,必须排到队尾。
当一个线程尝试获取锁时:
- 通过
hasQueuedPredecessors()判断队列是否存在排在自己前面的等待线程 - 如果存在前驱等待线程,直接进入队列尾部排队;只有无前驱时,才允许尝试 CAS 获取锁
- 锁释放时,唤醒队列头部的线程
边界:可重入场景,如果当前线程本身就是队列头节点,允许直接获取锁,不需要排队。
3.2 同样场景
时刻 1:A 持有锁,等待队列: B -> C -> D
时刻 2:A 释放锁的瞬间,新线程 E 也来抢锁
公平锁下的执行顺序:
释放锁 → 锁交给 B(B 先到,理所当然)
队列变成: C -> D -> E
E 只能乖乖排到队尾,不能插队。
3.3 优点
- 不会饿死:先到的一定先服务,FIFO 极大降低饥饿概率
- 可预测:知道每个线程大致的等待时间
补充:公平锁是应用层 FIFO 排队,受操作系统线程调度优先级影响,不能做到绝对时序保障。
3.4 缺点
- 吞吐量略低:每次释放锁都要唤醒队头,带来一定线程切换成本
- 但在 "锁持有时间较长" 的场景下,这点开销基本可以忽略
四、原理对比
4.1 AQS 队列
ReentrantLock 内部基于 AbstractQueuedSynchronizer(AQS)实现。
AQS 维护一个变体 CLH 双向阻塞队列;原版 CLH 为自旋锁,AQS 使用
LockSupport.park() 实现线程阻塞,队列中每个 Node 节点代表一个等待线程。- 非公平锁:线程先尝试 CAS 修改 state,失败才入队
- 公平锁:线程先判断是否存在前驱等待节点,有就直接入队
4.2 源码核心差异
// 非公平 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 抢,不管队列
- 公平:发现队列里有排在自己前面的线程,就老老实实排队
⚠️重要隐藏坑:公平锁实例调用无参tryLock(),会无视公平策略直接 CAS 抢锁,可以插队!
lock()/lockInterruptibly()/tryLock(timeout, unit):遵守公平规则tryLock()(无参):无论公平 / 非公平实例,直接抢锁,不做排队判断
ReentrantLock fairLock = new ReentrantLock(true);
fairLock.tryLock(); // ❗无视公平策略,可插队
fairLock.tryLock(5, TimeUnit.SECONDS); // ✅遵守公平排队
fairLock.lock(); // ✅遵守公平排队
五、性能对比
很多人有个误解:"公平锁一定比非公平慢很多"。其实不一定。
5.1 锁持有时间极短时(ns 级)
非公平锁:吞吐量 100%
公平锁 :吞吐量 70% 左右
差异明显,因为唤醒队头的开销占大头。
5.2 锁持有时间较长时(ms/s 级)
非公平锁:吞吐量 100%
公平锁 :吞吐量 95% 左右
差异很小,因为唤醒队头的开销相对临界区可忽略。
5.3 性能压测参考
注:压测数据为理论参考值,不同 CPU、JDK 版本实际 QPS 会浮动。
| 临界区耗时 | 线程数 | 公平锁 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:内存计数器
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
- count++ 一行纳秒级操作
- 唤醒队头线程的开销比这次操作本身还大
- 谁先抢到无所谓,反正所有线程都加一次
场景 B:缓存击穿保护
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:短临界区资源池
// 数据库连接池、线程池内部的任务队列
// 锁保护的就是"取出/放入一个对象"的动作
- 临界区是
queue.poll()/queue.offer()一次操作 - 公平锁的唤醒开销不划算
6.2 选公平锁的场景
特点:锁持有时间长,绝对不能容忍 "先到的一直拿不到"。
提示:公平锁仅保证排队顺序,不能解决任务堆积问题,业务上游仍需要做任务限流,避免大量任务堆积在锁队列。
场景 A:超图 .smwu 出图
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 超时
⚠️业务风险提醒:lockInterruptibly()没有超时能力,如果某个任务卡死没有 unlock,所有后续任务会永久阻塞;如果需要保留原有 60s 超时逻辑,应当使用带超时的tryLock(60, TimeUnit.SECONDS)。
场景 B:文件 / 打印机独占
// 多个任务往同一文件写日志
// 多个任务调用同一台打印机
- 写一次日志可能要几毫秒到几秒
- 公平排队能让每个任务有可预测的等待时间
场景 C:交易 / 订单处理
// 同一账户的多个并发扣款
// 必须按用户请求的顺序处理
- 业务上严格要求时序
- "A 先下单应该先扣款,结果 B 先扣了" 是不能接受的
场景 D:限流代理
// 本地缓存代理外部限流接口(每秒只能调 10 次)
// 所有调用方都要排队
- 锁内要做 "等令牌 → 调外部 → 解析"
- 业务要求 "先到先调"
七、决策树
锁内操作时间?
├── 极短(ns/μs)
│ └── 业务能容忍饿死吗?
│ ├── 能 → 非公平锁
│ └── 不能 → 公平锁(或换无锁方案)
│
└── 较长(ms/s)
└── 业务能容忍饿死吗?
├── 能 → 非公平锁(性能稍好)
└── 不能 → 公平锁(推荐)
更简洁的版本:
如果你在乎 "先到先服务",就用公平锁。如果你只在乎 "整体吞吐最大",就用非公平锁。
八、选型对照表
| 场景 | 推荐 | 理由 |
|---|---|---|
| 内存计数器、缓存重建 | 非公平 | 临界区极短,吞吐优先 |
| 出图、报表、PDF 生成 | 公平 | 持有时间长,绝不能饿死 |
| 文件独占写 | 公平 | 持有时间中等,要求有序 |
| 订单 / 账户处理 | 公平 | 业务要求时序 |
| 短 CAS 保护 | 非公平 | 临界区 ns 级 |
| 限流代理、外部资源串行调用 | 公平 | 持有时间取决于外部,不可预测 |
| 数据库连接池内部 | 非公平 | 临界区极短 |
| 多线程出同一份 .smwu | 公平 | 持有时间长,业务强一致 |
九、实战案例:.smwu 出图为什么必须用公平锁
9.1 业务背景
地震应急出图系统,多个专题图(居民点、地质灾害、震中等)共用同一份
iDesktop Java SDK 不支持多线程并发访问同一 .smwu,必须串行。
旧代码用
gsdzyjct.smwu 工作空间。
synchronized + tryLock(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 公平锁改造
方案一:lockInterruptibly,无超时,会永久阻塞
ReentrantLock lock = new ReentrantLock(true); // 公平锁
lock.lockInterruptibly(); // 获取锁过程响应线程中断,无超时,会永久阻塞
try {
doMapInner(...); // 出图
} finally {
lock.unlock();
}
方案二【业务推荐】:保留原有 60s 超时,公平锁下带超时 tryLock,兼顾排队与超时防护
ReentrantLock lock = new ReentrantLock(true);
boolean locked = lock.tryLock(60, TimeUnit.SECONDS);
if (!locked) {
throw new TimeoutException("获取工作空间锁超时60s");
}
try {
doMapInner(...);
} finally {
lock.unlock();
}
改造后时序:
08:53:15.855 Earthquake_epicenter 入队
08:53:18.XXX EQ_Station 入队
08:53:50.XXX EQ_Fracture 入队
释放锁后按顺序出锁:
1. Earthquake_epicenter(先到先服务)✅
2. EQ_Station
3. EQ_Fracture
...
每张图最终都会轮到,不会饿死。
十、常见误区
误区 1:公平锁一定慢
错。锁持有时间越长,公平锁的性能损失越小。
在 "ms/s 级" 临界区下,公平锁与非公平锁的性能差异 < 5%。
误区 2:默认就用非公平
不一定。JDK 默认非公平是因为 "大部分场景临界区短、吞吐优先"。
但在你的业务场景下,必须根据业务特性来选。
误区 3:公平锁就一定好
错。公平锁也有适用场景,不是银弹。
短临界区用公平锁反而拖慢整体性能。
误区 4:synchronized 是公平锁
错。synchronized 是非公平锁,不能保证 FIFO。HotSpot 的 monitor 锁队列没有公平开关。
误区 5:公平锁实例所有 API 都遵守公平排队
错。公平锁实例调用无参
只有
tryLock() 直接 CAS 抢锁,可以插队;
lock() / lockInterruptibly() / tryLock(time,unit) 才执行公平判断。十一、总结
短临界区 + 不在乎顺序 = 非公平;长临界区 + 必须有序 = 公平。
选用原则就两句话:
- 在乎 "先到先服务" → 公平锁
- 在乎 "整体吞吐最大" → 非公平锁
你的 .smwu 出图属于 "长临界区 + 必须有序",必须用公平锁。
非公平锁在这种场景下不是性能优化,而是bug。
额外业务提醒:
- 公平锁不等于不会阻塞,长临界区业务务必加上锁等待超时,防止任务卡死导致全量阻塞;
- 警惕无参
tryLock()破坏公平语义;- 公平锁只保证应用层排队顺序,上游需要限流,避免任务大量堆积锁队列。
附录:核心代码片段
A. 公平锁的标准用法
public class FairLockDemo {
private final ReentrantLock lock = new ReentrantLock(true);
public void doWork() {
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
}
}
B. .smwu 出图的公平锁改造(推荐带超时版本)
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) throws TimeoutException, InterruptedException {
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);
// 公平锁,最多等待60秒,响应中断
locked = lock.tryLock(60, TimeUnit.SECONDS);
if (!locked) {
throw new TimeoutException("获取工作空间锁超时60s, path:" + workSpacePath);
}
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. 锁关键字纳入可恢复异常白名单
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("datasource")
|| low.contains("timeout");
// 提示:线上环境异常栈多为英文,不建议匹配中文关键字"锁、工作空间",会出现匹配失效
}
浙公网安备 33010602011771号