彻底搞懂 ReentrantLock 与 tryLock:从秒杀实战到 AQS 独占模式源码剖析
在 Java 高并发编程中,synchronized 是我们最熟悉的互斥手段。然而在面对"高并发秒杀"这类典型场景时,synchronized 的缺点也会被无限放大:抢不到锁的线程只能死等。当百万请求瞬间涌入,大量线程堆积阻塞在锁上,极易引发上下文切换风暴,甚至直接拖垮整个系统。
业务真正需要的,往往是"尝试获取 + 超时放弃 + 灵活响应"。JDK 提供的显式锁 ReentrantLock 及其 tryLock(timeout) 机制,正是为了将锁的控权重新交还给程序员。
为什么需要 ReentrantLock?
ReentrantLock 是 java.util.concurrent.locks.Lock 接口的实现,底层完全基于 AQS(AbstractQueuedSynchronizer)的独占模式构建。
它与关键字 synchronized 的核心功能契合,但赋予了更丰富的控锁能力:
| API 方法 | 核心语义 | 关键特性 |
|---|---|---|
lock() |
阻塞式获取锁 | 抢不到一直等,行为与 synchronized 一致 |
tryLock() |
非阻塞尝试 | 立即返回 true/false,完全不等待(且忽略公平性规则) |
tryLock(time, unit) |
限时尝试 | 指定时间内抢到返回 true,超时返回 false,可响应中断 |
unlock() |
释放锁 | 必须放在 finally 中,否则必定导致锁泄漏 |
lockInterruptibly() |
可中断获取 | 在等待锁的过程中允许响应 Thread.interrupt() |
可重入性(Reentrant) :同一线程可多次调用
lock(),内部计数器 state 累加;每次lock()必须对应一次unlock(),当计数归零时锁才算真正释放。
实战演练:tryLock 解决秒杀限时抢购
以"100 万线程争抢 100 件库存"为例,核心思路就是:LOCK.tryLock(1000, TimeUnit.MILLISECONDS)------给线程 1 秒钟的缓冲期,抢得到就扣减,抢不到直接超时放弃,绝不卡死线程池。
Java
java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class SecKillDemo {
private static final ReentrantLock LOCK = new ReentrantLock();
private static int stock = 100;
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
new Thread(() -> {
SecSkillService service = new SecSkillService();
if (service.secSkill()) {
System.out.println(Thread.currentThread().getName() + " 抢购成功,剩余库存:" + stock);
} else {
System.err.println(Thread.currentThread().getName() + " 抢购失败(系统繁忙或已售罄)");
}
}).start();
}
}
static class SecSkillService {
public boolean secSkill() {
try {
// 限时尝试:1秒内抢到锁返回 true,超时返回 false,不阻塞线程
if (LOCK.tryLock(1000, TimeUnit.MILLISECONDS)) {
try {
if (stock > 0) {
Thread.sleep(10); // 模拟扣减库存的业务耗时
stock--;
return true;
}
return false; // 库存不足
} finally {
LOCK.unlock(); // ★ 只要加锁成功,必须在 finally 中释放
}
} else {
return false; // 1秒内没抢到锁,直接超时放弃
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}
}
执行流转逻辑:
scss
线程调用 secSkill()
│
├──> tryLock(1000ms)
├─ [抢锁成功] ──> 判断库存 ──> 扣减 ──> finally { unlock() } ──> 返回 true
└─ [超时未抢到] ──────────────────────────────────────────> 返回 false
最佳实践扩展 :在生产环境中,可将布尔返回值重构成状态枚举
SecKillStatus { SUCCESS, STOCK_EMPTY, LOCK_TIMEOUT, INTERRUPTED },方便前端区分是"已售罄"还是"系统繁忙请求超时"。
撬开底层:AQS 独占模式如何运作?
ReentrantLock 本身并不直接操作线程排队,它的加解锁完全委派给内部类 Sync(继承自 AbstractQueuedSynchronizer)。
1. AQS 核心三要素
-
volatile int state:0 表示锁空闲;大于 0 表示锁已被占用,数值代表重入次数。 -
exclusiveOwnerThread:记录当前持有锁的线程,用来判断"当前线程是否在重入"。 -
CLH 虚拟双向队列 :抢锁失败的线程封装为
Node节点,在此队列中挂起等待。
2. 核心决策:tryAcquire 抢锁三步法
当执行 lock() 或 tryLock() 时,底层的 tryAcquire 会经历三步决策:
scss
tryAcquire(1) 决策流程
│
┌─────────────┴─────────────┐
▼ ▼
[① state > 0 且 holder == self] [② state == 0 (锁空闲)]
│ │
(满足重入条件) (尝试 CAS 抢锁)
│ │
▼ ├─ CAS 成功 ──> 设 owner,返回 true
state + 1,返回 true │
└─ CAS 失败 ──> 进入下一步
│
▼
[③ 入 CLH 队列 park()]
(挂起等待前驱唤醒)
| 步骤 | 判断条件 | 对应动作 | 最终结果 |
|---|---|---|---|
| ① 重入判断 | state > 0 且 owner == 当前线程 |
state + 1 |
成功获取,流程直接结束 |
| ② CAS 抢锁 | state == 0(锁目前空闲) |
CAS 将 state 从 0 改为 1 并记录 owner | 成功则结束;失败则走向步骤 ③ |
| ③ 入队挂起 | ① 和 ② 均不成立 | 封装入 CLH 队列,调用 LockSupport.park() |
线程挂起,等待前驱节点唤醒 |
3. CLH 队列中的接力与挂起
线程抢锁失败入队后,并不是简单地死循环,而是遵循严格的接力机制:
lua
+------+ next +------------+ next +------------+
| Head | ------> | head.next | ------> | Node B | ...
| (哨兵) | <------ | (队头等待者) | <------ | (普通等待) |
+------+ prev +------------+ prev +------------+
-
哨兵 Head 节点:队列头节点是一个虚节点(哨兵),代表"当前正持有锁或刚释放锁的线程"。
-
队头争抢权 :只有
head.next(队头第一个有效等待节点)才有资格在被唤醒后调用tryAcquire抢锁。 -
单向唤醒接力 :当持锁线程调用
unlock()时,tryRelease将state减为 0,随后唤醒head的后继节点(head.next)。被唤醒的线程重新检查前驱是否为head,若是则尝试 CAS 拿锁,成功后把自己提升为新的head。
核心差异对比
1. 公平锁 vs 非公平锁
Java
ini
// 默认非公平锁
ReentrantLock nonFairLock = new ReentrantLock();
// 公平锁
ReentrantLock fairLock = new ReentrantLock(true);
两者的加锁区别仅在于 tryAcquire 中的一行关键代码:
scss
[非公平锁 NonfairSync]:
抢锁时直接 CAS(0->1) 插队!插队失败才去检查重入和入队。
[公平锁 FairSync]:
抢锁前先调用 hasQueuedPredecessors()!
└─ 检查 CLH 队列中是否有排在自己前面的线程。
├─ 有人排队:放弃抢锁,乖乖入队。
└─ 无人排队:才允许 CAS 抢锁。
| 维度 | 非公平锁(默认) | 公平锁 |
|---|---|---|
| 抢锁行为 | 新线程刚来就尝试插队抢锁 | 严格遵守 FIFO,排队优先 |
| 吞吐量 | 更高(减少了线程唤醒与上下文切换开销) | 较低 |
| 适用场景 | 追求高并发、高吞吐的绝大多数业务 | 对排队顺序敏感、防线程"饥饿"的场景 |
关键特例 :无参的
tryLock()无论底层是公平锁还是非公平锁,都会直接插队 !只有带超时参数的tryLock(time, unit)才会遵循公平锁的排队规则。
2. ReentrantLock vs synchronized
| 比较维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层级 | JVM 层面(monitorenter/monitorexit) |
JDK API 层面(AQS + CAS) |
| 灵活度 | 隐式自动加解锁,无法中断或超时 | 显式手动加解锁,支持 tryLock 与中断 |
| 公平性 | 仅支持非公平 | 支持公平锁与非公平锁切换 |
| 锁升级 | 支持偏向锁/轻量级锁/重量级锁升级 | 无锁升级概念(依赖 AQS 队列排队) |
避坑指南
-
忘写
unlock()导致锁泄漏 :ReentrantLock没有 JVM 的自动释放机制。铁律 :lock()或tryLock() == true之后,必须紧跟try { ... } finally { LOCK.unlock(); }。 -
在
finally中无条件unlock():如果tryLock()返回false(未拿到锁),绝对不能在finally中调用unlock(),否则会抛出IllegalMonitorStateException。 -
重入次数不匹配 :调用了 2 次
lock(),就必须调用 2 次unlock()。若释放次数不够,state无法归零,锁将永远无法被其他线程获取。 -
误解
tryLock()的公平性 :无参tryLock()不会遵守公平锁规则,如需公平超时抢锁,请务必使用tryLock(0, TimeUnit.SECONDS)。
自测思考
-
tryLock()和tryLock(1, TimeUnit.SECONDS)在公平锁模式下的行为有何不同? -
CLH 队列中的
head节点为什么是一个不带线程引用的"哨兵节点"? -
如果一个线程在等待
tryLock(5, TimeUnit.SECONDS)的过程中被中断,会发生什么?