13. 可重入锁:Reentrant 到底是什么意思
发布于 • 阅读量 1
上一节开始用 ReentrantLock 了。
这个类名里有一个词:Reentrant。
中文一般翻译成“可重入”。
刚开始看这个词,确实有点抽象。什么叫“重入”?为什么一把锁还要强调可重入?
我现在的理解是:
同一个线程已经拿到一把锁以后,可以再次拿到这把锁,不会把自己卡死。
这个能力就叫可重入。
先看一个例子
新建类:
com.succos.thread.ReentrantDemo
代码如下:
package com.succos.thread;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantDemo {
private final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
ReentrantDemo demo = new ReentrantDemo();
demo.methodA();
}
public void methodA() {
lock.lock();
try {
System.out.println("进入 methodA:" + Thread.currentThread().getName());
methodB();
System.out.println("离开 methodA:" + Thread.currentThread().getName());
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
System.out.println("进入 methodB:" + Thread.currentThread().getName());
} finally {
lock.unlock();
}
}
}
运行结果大概是:
进入 methodA:main
进入 methodB:main
离开 methodA:main
程序不会卡住。
这里有一个关键点:
methodA();
里面先拿了一次锁:
lock.lock();
然后 methodA() 里又调用了:
methodB();
而 methodB() 里面又执行了一次:
lock.lock();
也就是说,同一个线程 main 对同一把锁连续拿了两次。
程序还能正常执行,这就是可重入。
如果锁不可重入,会发生什么?
假设这把锁不可重入。
那么执行流程可能会变成这样:
main 线程进入 methodA;
main 线程拿到 lock;
main 线程调用 methodB;
methodB 也想拿 lock;
但是 lock 已经被 main 线程自己拿着;
main 线程只能等待 lock 被释放;
可是 lock 要等 methodA 执行完才会释放;
methodA 又卡在 methodB 里出不来。
这就尴尬了。
线程自己把自己锁死了。
所以可重入锁解决的就是这个问题:
同一个线程已经持有锁时,再次请求同一把锁,可以直接进入。
它不会让自己等自己。
可重入不是随便进入
这里要注意,可重入只针对同一个线程。
如果是另一个线程来拿这把锁,还是要等。
比如:
Thread thread1 = new Thread(() -> {
demo.methodA();
}, "thread-1");
Thread thread2 = new Thread(() -> {
demo.methodA();
}, "thread-2");
如果 thread-1 已经拿到锁进入 methodA(),那么 thread-2 再调用 methodA() 时,还是拿不到锁。
它要等 thread-1 完全释放锁以后才能进去。
所以可重入不是说这把锁没有限制了。
它只是说:
同一个线程可以重复进入;
不同线程之间仍然互斥。
这个区别要分清楚。
可重入锁内部可以理解成有一个计数器
为了理解这个过程,可以先用一个简化模型来看。
一把可重入锁内部可以理解为有两个东西:
owner:当前持有锁的线程;
count:当前线程持有这把锁的次数。
当 main 第一次执行:
lock.lock();
锁内部大概变成:
owner = main
count = 1
然后 main 又在 methodB() 里执行一次:
lock.lock();
因为发现当前请求锁的线程还是 main,也就是锁的持有者自己,所以允许再次进入。
这时变成:
owner = main
count = 2
执行完 methodB() 后,调用一次:
lock.unlock();
计数减一:
owner = main
count = 1
注意,这时候锁还没有真正释放。
因为 count 还不是 0。
等 methodA() 最后再调用一次:
lock.unlock();
计数再减一:
count = 0
owner = null
这时锁才算真正释放,其他线程才有机会拿到这把锁。
lock 几次,就要 unlock 几次
这个点非常重要。
既然可重入锁内部有持有次数,那么就意味着:
lock() 几次,就必须 unlock() 几次。
如果我在 methodA() 和 methodB() 里各 lock() 一次,但是只 unlock() 一次,那锁不会真正释放。
比如这种写法就有问题:
public void methodA() {
lock.lock();
try {
methodB();
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
System.out.println("进入 methodB");
} finally {
// 这里故意不 unlock
// lock.unlock();
}
}
执行过程大概是:
methodA lock 一次,count = 1;
methodB lock 一次,count = 2;
methodB 没有 unlock,count 还是 2;
methodA unlock 一次,count = 1;
count 没有归零,锁没有真正释放。
这样其他线程再来拿锁,就可能一直等。
所以用 ReentrantLock 时,不能只想着“我加锁了”,还要保证每一次加锁都有对应的释放。
这也是为什么上一节一直强调:
lock.lock();
try {
// 业务代码
} finally {
lock.unlock();
}
这个结构不能省。
synchronized 也是可重入的
可重入不只是 ReentrantLock 有。
synchronized 也是可重入的。
比如:
package com.succos.thread;
public class SynchronizedReentrantDemo {
public static void main(String[] args) {
SynchronizedReentrantDemo demo = new SynchronizedReentrantDemo();
demo.methodA();
}
public synchronized void methodA() {
System.out.println("进入 methodA:" + Thread.currentThread().getName());
methodB();
System.out.println("离开 methodA:" + Thread.currentThread().getName());
}
public synchronized void methodB() {
System.out.println("进入 methodB:" + Thread.currentThread().getName());
}
}
这段代码也不会卡住。
因为 methodA() 和 methodB() 锁的都是同一个对象:
this
main 线程进入 methodA() 时,已经拿到了 this 的对象锁。
然后它调用 methodB(),又要拿 this 这把锁。
因为还是同一个线程,所以可以再次进入。
这也是可重入。
所以:
synchronized 是可重入锁;
ReentrantLock 也是可重入锁。
只是 ReentrantLock 把这个特性直接写在名字里了。
为什么需要可重入?
我觉得可重入这个能力在实际代码里挺自然的。
因为方法之间经常会互相调用。
比如:
public synchronized void updateTask() {
checkPermission();
updateStatus();
}
public synchronized void updateStatus() {
// 修改任务状态
}
如果没有可重入,updateTask() 已经拿到了锁,再调用 updateStatus() 时就会卡住。
那很多正常的代码结构都会变得很难写。
可重入让同一个线程内部的方法调用不至于被自己锁住。
尤其是在业务代码里,一个加锁方法调用另一个加锁方法是很常见的。
可重入和递归也有关系
可重入锁还有一个典型场景:递归。
比如:
public synchronized void recursive(int n) {
if (n <= 0) {
return;
}
System.out.println("n = " + n);
recursive(n - 1);
}
这个方法每次递归调用自己,都会重新进入同一个 synchronized 方法。
如果锁不可重入,这种代码第一次递归就会卡死。
但因为 synchronized 是可重入的,所以同一个线程可以不断进入。
当然,递归本身要注意结束条件,不然会栈溢出。这里说的是锁不会因为“同一个线程重复进入”而死锁。
和 PDF 项目有什么关系?
PDF 水印项目里,不一定会直接写出很复杂的锁嵌套。
但理解可重入以后,后面看一些业务代码会更清楚。
比如我有一个任务状态管理类:
public class PdfTaskManager {
private final ReentrantLock lock = new ReentrantLock();
public void finishTask(String taskId) {
lock.lock();
try {
updateStatus(taskId, "SUCCESS");
addLog(taskId, "任务处理成功");
} finally {
lock.unlock();
}
}
private void updateStatus(String taskId, String status) {
lock.lock();
try {
System.out.println("更新任务状态:" + taskId + " -> " + status);
} finally {
lock.unlock();
}
}
private void addLog(String taskId, String log) {
lock.lock();
try {
System.out.println("记录日志:" + taskId + " -> " + log);
} finally {
lock.unlock();
}
}
}
这里 finishTask() 先拿锁。
然后它调用 updateStatus() 和 addLog(),这两个方法内部也拿同一把锁。
因为是同一个线程在执行,所以不会卡死。
不过这种写法虽然能运行,但不一定是最推荐的设计。因为锁嵌套多了以后,代码会比较难看。
我这里主要是为了说明:可重入让这种调用关系可以成立。
不要滥用锁嵌套
虽然可重入锁允许同一个线程重复拿锁,但不代表我应该到处写锁嵌套。
如果一个类里很多方法都在重复 lock()、unlock(),而且互相调用很复杂,后面维护起来会很痛苦。
我更倾向于把锁的边界控制得清楚一点。
比如:
对外方法加锁;
内部私有方法默认认为已经在锁保护范围内;
不要每个小方法都重复加锁。
比如可以改成这样:
public void finishTask(String taskId) {
lock.lock();
try {
updateStatusWithoutLock(taskId, "SUCCESS");
addLogWithoutLock(taskId, "任务处理成功");
} finally {
lock.unlock();
}
}
private void updateStatusWithoutLock(String taskId, String status) {
System.out.println("更新任务状态:" + taskId + " -> " + status);
}
private void addLogWithoutLock(String taskId, String log) {
System.out.println("记录日志:" + taskId + " -> " + log);
}
这样锁的范围更清楚。
当然,具体怎么写要看业务。但至少要知道,可重入是兜底能力,不是鼓励我随便嵌套锁。
可重入锁和死锁不是一回事
还有一个概念容易混:可重入锁能避免“自己等自己”,但不能避免所有死锁。
比如一个线程拿了锁 A,又想拿锁 B。
另一个线程拿了锁 B,又想拿锁 A。
这种情况还是可能死锁:
thread-1 拿到 lockA,等待 lockB;
thread-2 拿到 lockB,等待 lockA;
两个线程互相等。
可重入解决的是:
同一个线程重复拿同一把锁。
它解决不了:
多个线程互相等待不同的锁。
所以不要以为用了 ReentrantLock 就不会死锁。
ReentrantLock 只是可重入,不是自动防死锁。
我怎么记这个概念
我现在对“可重入”的记忆方式比较简单:
同一个线程已经拿到锁了,再进一次,不用等。
再补一句:
但是进几次,就要出几次。
对应到 ReentrantLock 就是:
lock 几次,unlock 几次。
对应到 synchronized,JVM 会帮我维护这个进入次数和退出次数,不用手动释放。
这一节小结
这一节我主要记住几点:
1. 可重入指的是同一个线程可以重复获得同一把锁;
2. ReentrantLock 是可重入锁;
3. synchronized 也是可重入锁;
4. 可重入锁内部可以理解为 owner + count;
5. lock() 几次,就要 unlock() 几次;
6. 可重入能避免同一个线程自己等自己,但不能避免所有死锁;
7. 锁嵌套能运行,不代表设计一定好,锁的边界还是要尽量清楚。
用一句话总结:
可重入锁允许同一个线程重复进入同一把锁保护的代码,但必须按次数释放。
下一节开始看 Semaphore。
它和前面的锁不太一样。锁通常是为了保护共享数据,而 Semaphore 更像是控制“同一时间最多允许几个线程进入”。