跳到正文
hello world

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 更像是控制“同一时间最多允许几个线程进入”。