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 工作方式

不排队,谁抢到算谁。

当一个线程尝试获取锁时:

  1. 先尝试一次 CAS 抢锁
  2. 抢到就执行临界区
  3. 抢不到才进入等待队列尾部

释放锁的瞬间,新来的线程可能比队列里等了很久的线程先拿到锁

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),新来的线程不能插队,必须排到队尾。

当一个线程尝试获取锁时:

  1. 检查等待队列是否为空,或者自己就是队头
  2. 不是队头就直接进入队列尾部(不抢)
  3. 锁释放时,唤醒队头

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 问题分析

  1. Earthquake_epicenter 是先到的,应该优先服务
  2. 但因为非公平,后到的 EQ_Fracture、EQ_Traffic 反而先抢到
  3. 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 入队

释放锁后按顺序出锁:

  1. Earthquake_epicenter(先到先服务)✅
  2. EQ_Station
  3. EQ_Fracture
    ...

每张图最终都会轮到,不会饿死。

`


十、常见误区

误区 1:公平锁一定慢

错。锁持有时间越长,公平锁的性能损失越小。

在"ms/s 级"临界区下,公平锁与非公平锁的性能差异 < 5%。

误区 2:默认就用非公平

不一定。JDK 默认非公平是因为"大部分场景临界区短、吞吐优先"。

但在你的业务场景下,必须根据业务特性来选。

误区 3:公平锁就一定好

错。公平锁也有适用场景,不是银弹。

短临界区用公平锁反而拖慢整体性能。

误区 4:synchronized 是公平锁

错。synchronized 是非公平锁,不能保证 FIFO。


十一、总结

短临界区 + 不在乎顺序 = 非公平;长临界区 + 必须有序 = 公平。

选用原则就两句话:

  1. 在乎"先到先服务" → 公平锁
  2. 在乎"整体吞吐最大" → 非公平锁

你的 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

作者简介:后端开发工程师,专注于高并发、分布式系统、应急指挥系统。

本文基于作者在"地震应急出图系统"中的真实踩坑经验整理。