Java高并发底层原理(九)—— ReentrantLock 为什么能够实现互斥
synchronized 可以通过对象监视器实现互斥,同一时刻只允许一个线程进入临界区。Java 还提供了另一种常用锁:
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
ReentrantLock 没有使用 synchronized 包围临界区,却同样可以保证只有一个线程修改 count。除此之外,它还支持可重入、公平锁、可中断等待和超时获取等能力。
本章只围绕一个主问题展开:ReentrantLock 为什么能够实现互斥。后文会从锁状态开始,逐步引出 CAS、owner、重入计数、等待队列、公平策略和内存语义。每一节只在前文基础上增加一个必要条件,不重复证明已经建立过的结论。
一、锁需要记录什么状态
先不看 ReentrantLock 的源码,只考虑一把最简单的互斥锁需要解决什么问题。
一把锁至少要区分两种状态:空闲和已占用。可以先用一个整数表示:state == 0 表示锁空闲,state == 1 表示锁已被占用。
class SimpleLock {
private final AtomicInteger state = new AtomicInteger(0);
public boolean tryLock() {
return state.compareAndSet(0, 1);
}
public void unlock() {
state.set(0);
}
}
这段 SimpleLock 不是完整锁,只用来建立第一个概念:互斥首先需要一个可被竞争的状态位。线程调用 tryLock() 时,如果能把 state 从 0 改成 1,就说明它成功占用了锁;如果改失败,就说明锁已经被其他线程占用。
所以,锁的本质不是某个特殊的物理对象,而是一组围绕状态变化建立的规则:谁能把空闲状态改成占用状态,谁就能进入临界区;谁持有占用状态,谁才有资格释放它。
二、为什么获取锁需要 CAS
上一节的 state 只是记录状态,还没有说明多个线程同时修改它时如何保证安全。问题出在普通判断和赋值不是一个不可分割的整体。
假设线程 A 和线程 B 同时执行下面的逻辑:
if (state == 0) {
state = 1;
}
可能出现这样的多线程交错:
| 步骤 | Thread A | Thread B | state |
|---|---|---|---|
| 1 | 读取到 0 |
0 |
|
| 2 | 读取到 0 |
0 |
|
| 3 | 写入 1 |
1 |
|
| 4 | 写入 1 |
1 |
两个线程都基于“我刚才看到的是 0”做出判断,最后都会认为自己获得了锁。这里破坏互斥的根因是:读取、判断、写入被拆成了多个步骤,中间被另一个线程插入了。
CAS 要解决的正是这个问题:它把“当前值是否仍然等于期望值”和“如果相等就写入新值”合并成一个原子操作。
state.compareAndSet(0, 1);
其中 0 是期望值,1 是新值。只有执行 CAS 的那一刻,state 仍然是 0,当前线程才会成功写入 1。多个线程同时竞争时,最多只有一个线程能完成从 0 到 1 的状态转换。
OpenJDK 中的 ReentrantLock 在锁空闲时,也会先通过 CAS 修改同步状态;CAS 成功后,当前线程才进一步成为持锁线程。到这里,我们只解决了“谁先占住状态位”的问题,还没有解决“占住之后应该由谁释放”的问题。
三、只记录 state 为什么还不够
沿用前文的 SimpleLock,如果锁只记录 state,它只能知道“锁是否被占用”,却不知道“锁被谁占用”。这会带来一个直接问题:没有获得锁的线程也可能错误地释放锁。
例如线程 A 已经获得锁,线程 B 没有获得锁,但 B 错误调用了 unlock()。如果 unlock() 只是简单地把 state 改回 0,线程 C 就可能随后获得锁。此时线程 A 仍在临界区中,线程 C 也进入了临界区,互斥就被破坏了。
因此,独占锁不能只记录状态,还必须记录当前持锁线程:
| 字段 | 作用 |
|---|---|
state |
记录锁是否被占用,后面还会扩展为重入次数 |
owner |
记录当前持有这把锁的线程 |
有了 owner 之后,释放锁前必须校验当前线程是否就是持锁线程:
if (owner != Thread.currentThread()) {
throw new IllegalMonitorStateException();
}
这一步补上了前文 state 模型缺失的身份约束:CAS 决定谁能第一次占住锁,owner 决定谁有资格继续操作这把锁。
四、什么是可重入
有了 owner 之后,还可以继续解决一个常见场景:同一个线程已经持有锁,又在临界区中调用另一个也需要同一把锁的方法。
public void methodA() {
lock.lock();
try {
methodB();
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
// 执行业务
} finally {
lock.unlock();
}
}
线程 A 进入 methodA() 后已经持有锁,随后调用 methodB() 时又申请同一把锁。如果锁只允许 state == 0 的线程进入,那么线程 A 会等待自己释放锁,而它又必须等 methodB() 返回后才能继续执行外层 unlock(),这就形成自我阻塞。
可重入锁的处理方式是:如果申请锁的线程已经是 owner,就允许它再次进入,但要把 state 从单纯的占用标记扩展为重入计数。
state 值 |
含义 |
|---|---|
0 |
锁空闲 |
1 |
owner 获取了一次锁 |
2 |
owner 重入了一次,总共获取两次 |
n |
owner 总共获取了 n 次 |
简化后的获取逻辑如下:
boolean tryAcquire() {
Thread current = Thread.currentThread();
int currentState = getState();
if (currentState == 0) {
if (compareAndSetState(0, 1)) {
owner = current;
return true;
}
} else if (owner == current) {
setState(currentState + 1);
return true;
}
return false;
}
这段逻辑是在前文基础上的增量扩展:锁空闲时仍然通过 CAS 竞争;锁已被占用时,如果 owner 是当前线程,就增加重入计数;如果 owner 不是当前线程,获取失败。
五、重入之后如何释放锁
如前所述,state 已经从“是否占用”扩展成了“重入次数”。因此,释放锁也不能简单地把 state 改回 0,而是每次 unlock() 只减少一次计数。
对于前文的 methodA() 和 methodB() 嵌套调用,线程 A 一共调用了两次 lock(),也必须调用两次 unlock()。第一次解锁只把 state 从 2 减到 1,线程 A 仍然持有锁;第二次解锁把 state 从 1 减到 0,锁才真正释放。
简化后的释放逻辑如下:
void release() {
Thread current = Thread.currentThread();
if (owner != current) {
throw new IllegalMonitorStateException();
}
int newState = getState() - 1;
if (newState == 0) {
owner = null;
}
setState(newState);
}
这一节只补充释放规则:每一次成功加锁都必须对应一次解锁,只有 state 减到 0,owner 才会被清除,锁才真正进入空闲状态。
六、为什么 unlock 通常不需要 CAS
前文已经说明,获取锁时需要 CAS,是因为多个线程可能同时尝试把 state 从 0 改成 1。但锁被某个线程持有后,情况发生了变化:重入和解锁都只能由 owner 合法执行。
也就是说,在 state > 0 的持锁阶段,不存在两个合法线程同时修改同一把独占锁重入计数的情况。非 owner 调用 unlock() 会被拒绝,而 owner 自己增加或减少计数,不需要和其他线程争夺这次计数修改。
所以 CAS 主要出现在锁所有权发生转移的入口:锁空闲时,多个线程竞争成为 owner;一旦 owner 确定,重入计数的增加和减少就属于 owner 自己的内部状态维护。
这并不是说 unlock() 不重要。相反,unlock() 是后续线程能否继续推进的关键,只是它通常不需要用 CAS 解决“多个合法释放者同时竞争”的问题。
七、获取锁失败后为什么不能一直重试
到目前为止,锁已经能够记录状态、区分 owner,并支持重入。但还有一个问题没有解决:如果锁被其他线程持有,获取失败的线程应该怎么办?
最直接的做法是不断重试:
while (!tryAcquire()) {
}
这种方式称为忙等待。它的问题是,线程虽然没有进入临界区,却一直占用 CPU,不断读取 state、尝试 CAS、失败后再重复。竞争激烈时,多个线程还会反复争夺保存 state 的 Cache Line,进一步放大 CPU 和缓存一致性开销。
短时间自旋有时有价值,因为锁可能很快释放;但如果持锁时间较长,失败线程继续空转就没有意义。ReentrantLock 不能只靠 CAS 循环,还需要一种机制把失败线程保存起来,并让它们暂时停止运行。
这就引出了 AQS。AQS,全名是 AbstractQueuedSynchronizer,在这里先只引入它的一个作用:用等待队列管理获取同步状态失败的线程。后文只围绕这个作用展开,不提前进入 AQS 的完整源码细节。
八、等待队列解决了什么问题
假设线程 A 已经持有锁,线程 B、C、D 依次调用 lock(),并且都获取失败。沿用前文结论,这些失败线程不能无限忙等,所以它们会进入 AQS 等待队列。
队列至少解决两个问题:第一,记录哪些线程正在等待;第二,锁释放时能找到相对靠前的等待线程继续推进。可以把等待关系理解为:B 先等待,C 排在 B 后面,D 排在 C 后面。
AQS 中等待线程会被包装成节点,节点中保存等待线程以及前后节点引用。这里不展开节点字段,只建立本章需要的抽象:获取失败的线程从 CPU 上持续竞争,转为在队列中暂停等待。
这个转变很重要。前文的 CAS 负责解决“谁能成功获得锁”,等待队列负责解决“失败者如何不浪费 CPU”。两者不是替代关系,而是连接关系:先尝试获取,失败后排队;被唤醒后,再重新尝试获取。
九、释放锁后为什么只唤醒一个线程
有了等待队列后,unlock() 的后半段才有意义:当 owner 把 state 减到 0,锁真正释放,队列中的等待线程需要被唤醒。
如果前文的 B、C、D 都在等待,通常只需要优先唤醒靠前的线程,例如 B。因为即使同时唤醒 B、C、D,最终也只有一个线程能通过 CAS 获得锁,另外两个线程恢复运行后还要再次失败并重新等待。这会增加线程调度、上下文切换和 Cache Line 竞争。
因此,释放锁后的策略不是“把锁直接交给所有等待线程”,也不是“直接指定某个线程已经获得锁”,而是唤醒相对靠前的等待线程,让它恢复运行后重新检查状态并尝试获取。
这里要保留一个关键结论:唤醒不等于交付锁。被唤醒线程只是重新获得竞争机会,只有它再次成功修改 state 并成为 owner,才真正进入临界区。
十、公平锁和非公平锁有什么区别
前文已经有了等待队列,接下来才能解释公平锁和非公平锁的差异。差异不在于是否使用 state、CAS、owner 或等待队列,而在于新来的线程是否必须尊重队列中已经等待的线程。
ReentrantLock 默认使用非公平模式:
ReentrantLock lock = new ReentrantLock();
也可以显式创建公平锁:
ReentrantLock lock = new ReentrantLock(true);
公平锁在尝试获取锁前,会检查队列中是否已经有排在前面的等待线程。如果有,新来的线程不能直接插队,而是进入队尾等待。非公平锁则允许新来的线程先尝试 CAS;如果它刚好在锁释放后抢到了 state,就可能先于队列中的旧线程获得锁。
用前文的 B、C、D 等待队列举例:线程 B 已经在队首等待,线程 A 刚释放锁,线程 D 此时正好新调用 lock()。在非公平模式下,D 可能因为正在 CPU 上运行而率先 CAS 成功;在公平模式下,D 会先检查前面是否已有等待线程,如果有,就不能直接绕过 B。
非公平锁允许插队,是为了减少线程切换成本:新来的线程可能已经处于运行态,可以马上进入临界区;队首线程即使被唤醒,也可能还要等待操作系统调度。公平锁减少插队带来的饥饿风险,但可能牺牲一部分吞吐。
公平锁也不等于绝对时间顺序。线程可能被中断、取消或受到操作系统调度影响。它表达的是一种获取锁策略:尽量尊重等待队列,不让新线程随意绕过已有前驱。
十一、ReentrantLock 如何保证可见性
前文主要解释互斥:同一时刻只有 owner 能进入临界区。但锁还必须解决另一个问题:前一个线程在临界区中修改的数据,后一个线程获得同一把锁后必须能够看到。
// Thread A
lock.lock();
try {
data = 42;
} finally {
lock.unlock();
}
// Thread B
lock.lock();
try {
System.out.println(data);
} finally {
lock.unlock();
}
如果线程 A 成功释放 lock,线程 B 随后成功获得同一把 lock,那么线程 A 在释放锁之前对共享数据做出的修改,必须对线程 B 可见。这是锁的内存同步语义。
换句话说,state、CAS、owner 和等待队列解释的是“线程如何排他地进入临界区”;锁的内存语义解释的是“临界区中的数据如何在线程之间正确传递”。如果只有互斥而没有可见性,后一个线程虽然不会和前一个线程同时执行临界区,却仍可能看不到前一个线程的修改,这样锁就不能完成并发控制的目标。
因此,ReentrantLock 和 synchronized 一样,不只是控制进入顺序,还在成功释放和后续成功获取同一把锁之间建立可见性保证。
十二、为什么 unlock 必须写在 finally 中
ReentrantLock 与 synchronized 在使用方式上有一个重要区别:synchronized 会在代码块退出时自动释放 monitor,而 ReentrantLock 必须显式调用 unlock()。
标准写法是:
lock.lock();
try {
// 访问共享数据
} finally {
lock.unlock();
}
finally 的必要性来自前文的 owner 和等待队列规则。线程获得锁后,如果临界区抛出异常并提前退出,但没有执行 unlock(),state 就不会归零,owner 也不会被清除,等待队列中的线程就可能长期无法继续。
需要注意,lock() 应放在 try 之前。如果 lock() 本身没有成功获得锁,就不应该在 finally 中执行 unlock()。标准结构表达的是:只有成功进入临界区之后,才确保退出时释放锁。
十三、ReentrantLock 的完整执行过程
现在可以把前面的增量条件合并成一条执行链。
线程调用 lock() 时,先根据 state 判断锁是否空闲。若 state == 0,多个线程通过 CAS 竞争,成功者把自己记录为 owner 并进入临界区。若 state > 0 且 owner 是当前线程,说明发生重入,只需要增加 state。若 state > 0 且 owner 不是当前线程,说明锁被其他线程持有,当前线程获取失败并进入 AQS 等待队列。
线程调用 unlock() 时,先检查当前线程是否为 owner,然后把 state 减一。如果 state 仍大于 0,说明只是退出了一层重入,锁还没有真正释放;如果 state 变成 0,owner 被清除,锁进入空闲状态,并唤醒等待队列中相对靠前的线程。被唤醒线程不会直接拥有锁,而是重新回到获取流程,通过 CAS 再次竞争。
在这条链路中,每个组件都有明确分工:state 承载锁状态和重入计数,CAS 保证空闲锁只能被一个线程抢占,owner 限制重入和释放资格,AQS 队列保存失败线程,唤醒动作把等待线程重新送回竞争入口,锁的内存语义保证临界区修改能被后续持锁线程看到。
写后去重检查
| 检查项 | 处理结果 |
|---|---|
state 的含义是否重复定义 |
只在第一节完整定义,第四节仅增量扩展为重入计数 |
| CAS 竞争过程是否重复画图 | 只在第二节用一次多线程交错表说明,后文均回指 |
| owner 的必要性是否重复证明 | 只在第三节用非 owner 错误解锁说明,后文直接使用结论 |
| 可重入例子是否重复展开 | 只在第四节完整展开 methodA() / methodB(),第五节只承接释放规则 |
| 等待队列结构是否重复绘制 | 未重复绘制队列图,只用文字说明队首、队尾和唤醒关系 |
| 总结是否逐节复述 | 已改成因果链整合,不逐条重复各节结论 |
本章总结
ReentrantLock 的实现可以看成一条逐步收紧的因果链:共享变量需要互斥访问,所以必须有一个状态表示锁是否空闲;状态会被多个线程同时竞争,所以获取入口必须使用 CAS;CAS 只能决定谁抢到锁,不能说明谁有资格继续操作,所以还要记录 owner;同一个 owner 可能嵌套申请同一把锁,所以 state 又从占用标记扩展为重入计数;重入计数只有归零才表示所有临界区层级都已经退出,因此真正释放锁发生在最后一次 unlock()。
一旦释放动作出现,问题又从“谁持有锁”转向“失败线程如何继续”。如果失败线程一直自旋,CPU 会被无效竞争消耗,所以 AQS 用等待队列保存它们;如果释放时唤醒所有等待线程,又会制造新的竞争浪费,所以通常只推进相对靠前的线程;被唤醒线程仍要重新竞争,是因为锁的所有权始终只能通过状态转换确认,而不是由唤醒动作直接赠送。
因此,ReentrantLock 并不是由某一个技巧实现互斥,而是把状态竞争、持有者身份、重入计数、失败等待、释放唤醒和内存语义连接成一条完整路径:先用 CAS 建立唯一 owner,再用 owner 和 state 维持独占关系,最后通过 AQS 队列和锁的同步语义,把竞争失败的线程和临界区数据一起安全地推进到下一轮执行。
更多推荐



所有评论(0)