「Java 进阶之路」系列 Day05
写在前面
前四篇一直围绕 synchronized 和 JMM 打转,从这篇开始切换到并发工具包(JUC)里更"高级"的同步手段------Lock 接口。
很多人会背"ReentrantLock 比 synchronized 功能更强,支持公平锁、可中断、超时获取",但问不出个所以然:这些能力到底是怎么实现的? 答案就是今天的主角------AbstractQueuedSynchronizer(AQS)。这是 JUC 几乎所有同步器共用的底层框架,理解了它,后面讲读写锁、Semaphore、CountDownLatch 都会轻松很多。
一、是什么:Lock 接口解决了 synchronized 的什么局限
先对比一下两者的能力差异:
| 特性 | synchronized |
Lock 接口 |
|---|---|---|
| 加锁/释放 | 自动(JVM 管理) | 手动(必须显式 unlock()) |
| 响应中断 | ❌ | ✅ lockInterruptibly() |
| 超时获取 | ❌ | ✅ tryLock(time, unit) |
| 非阻塞尝试 | ❌ | ✅ tryLock() |
| 公平锁 | ❌(只有非公平) | ✅ 可选 |
| 多个等待队列 | ❌(只有一个,靠 wait/notify) | ✅ 支持多个 Condition(Day06 讲) |
一句话理解:Lock 就是把 synchronized 那种"要么拿到锁执行,要么无限阻塞等"的粗放模式,拆解成了一组更精细可控的 API。
标准使用模板 (务必记牢,finally 里释放锁是铁律):
java
Lock lock = new ReentrantLock();
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // ⚠️ 必须写在 finally 里,否则临界区抛异常会导致锁永远不释放
}
二、为什么需要 AQS:不想每个锁都重复造轮子
ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch------这些看起来功能完全不同的同步工具,底层其实都基于同一套框架实现:
如果没有 AQS,每个同步器都要自己实现"排队、挂起、唤醒"这套逻辑,重复代码多不说,还容易各写各的 bug。AQS 把这套通用逻辑抽出来做成模板方法模式,子类只需要实现"怎么判断锁是否可用"这一小块逻辑,其余的排队/阻塞/唤醒全部交给父类。
三、AQS 核心数据结构:一个 state + 一条队列
AQS 内部只有两个核心组成部分:
objectivec
AQS
├── volatile int state // 同步状态(核心)
│ 独占锁:0=未锁,>0=重入次数
│ 读写锁:高16位=读锁数,低16位=写锁重入数(Day06 细讲)
│ Semaphore:剩余许可数
│
└── CLH 双向等待队列
┌──────┐ ┌──────┐ ┌──────┐
│ head │ → │ Node │ → │ tail │
└──────┘ └──────┘ └──────┘
每个 Node 保存:线程引用、等待状态(waitStatus)、前驱/后继指针
state 用 volatile 修饰(还记得 Day03 讲的可见性吗?),配合 CAS 操作保证多线程读写这个状态时的原子性和可见性。AQS 支持两种共享模式:
| 模式 | 说明 | 代表实现 |
|---|---|---|
| 独占(Exclusive) | 同时只有一个线程能持有 | ReentrantLock |
| 共享(Shared) | 多个线程可以同时持有 | Semaphore、读写锁的 ReadLock |
子类需要实现的模板方法很少,独占模式只需要两个:
java
protected boolean tryAcquire(int arg) { ... } // 尝试获取锁
protected boolean tryRelease(int arg) { ... } // 尝试释放锁
加锁的完整流程
释放锁的流程
一句话总结 AQS 的原理 :用一个 state 变量表示"锁的状态",用 CAS 去竞争修改这个状态,抢不到的线程封装成 Node 排队,靠 LockSupport.park/unpark 挂起和唤醒------排队和唤醒这套通用机制,就是 AQS 存在的全部意义。
四、公平锁 vs 非公平锁:一行代码的差异
java
// 非公平锁:新来的线程直接 CAS 抢,完全不看队列里还有没有人在排队
if (compareAndSetState(0, 1)) { ... }
// 公平锁:先检查队列里是否已经有等待者,有就老实排队
if (!hasQueuedPredecessors() && compareAndSetState(0, 1)) { ... }
ReentrantLock 默认是非公平锁:
java
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(); // 非公平锁(默认)
为什么默认选非公平:公平锁必须严格按照排队顺序放行,每次都要走一遍挂起/唤醒的完整流程,上下文切换开销大;非公平锁允许"插队"------如果恰好在锁释放的瞬间有新线程杀过来,直接让它抢,不用走排队唤醒那一套,整体吞吐量更高。这是一个用"公平性"换"性能"的经典权衡。
五、ReentrantLock 的可重入实现
可重入的本质就是 state 计数器的累加和递减:
java
lock.lock(); // state: 0 → 1
lock.lock(); // state: 1 → 2(同一线程再次加锁,不会死锁)
lock.unlock(); // state: 2 → 1
lock.unlock(); // state: 1 → 0,这时才真正释放锁
三种典型使用场景,也是 Lock 相比 synchronized 最大的加分项:
java
// ① tryLock() 非阻塞尝试,配合双重 tryLock 可以避免经典的"两把锁互相等待"死锁
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try { /* 同时持有两把锁 */ }
finally { lock2.unlock(); }
}
} finally { lock1.unlock(); }
}
// ② lockInterruptibly() 响应中断,避免线程无限期卡在等锁上
try {
lock.lockInterruptibly();
try { /* 临界区 */ }
finally { lock.unlock(); }
} catch (InterruptedException e) {
// 等锁期间被中断,做清理逻辑
}
// ③ tryLock(timeout) 超时放弃,避免无限等待拖垮接口响应时间
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try { /* 临界区 */ }
finally { lock.unlock(); }
} else {
// 超时没拿到锁,走降级逻辑(比如返回缓存数据、快速失败)
}
六、面试追问
Q1:Lock 接口和 synchronized 的核心区别是什么?
synchronized 由 JVM 自动管理加锁和释放,功能相对固定;Lock 是 JDK 类库层面的接口,需要手动加锁/释放(必须写在 try-finally 里),但换来了更丰富的能力:可中断获取(lockInterruptibly)、超时获取(tryLock(time, unit))、非阻塞尝试(tryLock())、可选公平锁,以及支持多个 Condition 等待队列。
Q2:AQS 是什么,为什么这么多同步器都基于它实现?
AQS(AbstractQueuedSynchronizer)是 JUC 锁的公共底层框架,核心是一个 volatile int state + 一条 CLH 双向等待队列。它用模板方法模式把"排队、挂起、唤醒"这套通用逻辑固化在父类里,子类只需要实现 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)这几个方法去定义"什么时候算获取/释放成功",避免每个同步器都要重复造一遍排队唤醒的轮子。
Q3:ReentrantLock 默认是公平锁还是非公平锁,为什么?
默认是非公平锁。因为公平锁要求严格按照排队顺序获取锁,每次都要走挂起/唤醒的完整流程,上下文切换开销大;非公平锁允许新来的线程直接抢锁(尤其是刚好赶上锁释放的瞬间),减少了不必要的挂起唤醒次数,整体吞吐量更高,这是用一定的"排队公平性"换性能的权衡。
Q4:ReentrantLock 的可重入是怎么实现的?
靠 AQS 里的 state 变量做计数:同一个线程每次成功获取锁,state 加一;每次调用 unlock(),state 减一;只有当 state 归零时才真正释放锁、唤醒其他等待线程。AQS 在判断是否允许重入时,会检查当前持锁线程是不是发起请求的这个线程,是的话直接放行并给 state 加一。
Q5:为什么用 Lock 时一定要把 unlock() 写在 finally 里?
因为 Lock 的加锁/释放是完全手动的,不像 synchronized 那样由 JVM 保证"代码块退出(哪怕抛异常)一定会释放锁"。如果临界区代码抛出异常而 unlock() 又没有被执行到,这把锁就会永远处于持有状态,其他线程再也无法获取,相当于人为造成了死锁。写在 finally 里能保证无论正常返回还是异常退出,锁都一定会被释放。
下一篇预告
Day06 讲 ReentrantReadWriteLock(读写锁)和 Condition------读多写少的场景下怎么用读写锁提升并发度,以及 Condition 相比传统 wait/notify 强在哪里。