引言

在 Java 并发里,synchronized 和 ReentrantLock 是两大基础同步工具。

  • 面试喜欢问:“synchronized 和 ReentrantLock 有什么区别?”

  • 实战中,大家又经常被问:“为什么会死锁?怎么避免?”

  • 再加上一个高频词:“可重入锁(Reentrant)” —— 更容易搞混。

可重入解决的是“自己锁自己”的问题,死锁解决的是“互相等待”的问题。
这篇文章就围绕一个主线,把这两个概念一次性讲透:

ReentrantLock 与死锁:从可重入原理到死锁复现,再到如何用 ReentrantLock 辅助避免死锁。

一、什么叫“可重入锁”?先把这个概念掰开讲

1.1 可重入的直观理解

可重入(Reentrant)= 同一个线程可以多次获取同一把锁,而不会被自己卡死。

也就是说:

  • 第一次 lock() 成功
  • 在“锁内部”又调用了需要同一把锁的方法,再次 lock()
  • 不会阻塞自己,而是简单地把“持有计数 +1”
  • 只有 unlock() 到计数为 0,锁才真正释放

典型场景:方法嵌套调用。

public class ReentrantDemo {

    public synchronized void a() {
        System.out.println("in a");
        b(); // a() 里面再调用 b()
    }

    public synchronized void b() {
        System.out.println("in b");
    }
}

两个 synchronized 都是 synchronized(this):

  • a() 拿到 this 的锁
  • a() 内部调用 b(),b() 再次请求 this 的锁
  • 如果锁不可重入,这里会:自己等自己释放锁 → 自我死锁
  • 但由于 JVM 的 Monitor 锁是可重入的,同一线程可以重复进入

可重入解决的是:“同一个线程在锁内再次请求同一把锁会不会卡死自己”。

1.2 如果锁不可重入,会怎么死?

假设 synchronized 是不可重入锁(只是假设):

public synchronized void a() {
    System.out.println("in a");
    b();
}

public synchronized void b() {
    System.out.println("in b");
}

时间线:

  1. 线程 T1 调用 a()

    • 获取 this 的锁,进入 a()

  2. a() 中调用 b()

    • b() 是 synchronized(this),需要再次获取 this 的锁

  3. 锁已经被 T1 占用,且“不可重入”

    • T1 被阻塞在 b() 的入口

  4. 但 T1 卡在这里,就没法执行完 a()

    • 自然也释放不了 this 的锁

  5. 于是:T1 等 T1,自我死锁

这就是“不可重入锁”最致命的问题:同一个线程可以把自己锁死。

小结

  • 可重入锁解决的是“同一个线程重复加锁”的问题
  • synchronized 本身就是可重入锁
  • ReentrantLock 在名字里直接把这个特性写出来

下一节,我们正式看下 ReentrantLock 的可重入特性和基本用法。

二、ReentrantLock:名字里就写着“可重入”

ReentrantLock 在 java.util.concurrent.locks 包中,是一个“显式锁类”。

2.1 基础用法模板(必须写对)

import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void incr() {
        lock.lock();           // 加锁
        try {
            count++;
        } finally {
            lock.unlock();     // 解锁必须写在 finally
        }
    }

    public int get() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

关键点:

  • 一定要记住:lock() / unlock() 要成对出现,unlock() 放在 finally 里。
  • 否则,一旦中间抛出异常,没解锁 → 永久死锁。

2.2 ReentrantLock 的可重入示例

public class ReentrantLockDemo {

    private final ReentrantLock lock = new ReentrantLock();

    public void a() {
        lock.lock();
        try {
            System.out.println("in a");
            b();   // 内部再调用 b()
        } finally {
            lock.unlock();
        }
    }

    public void b() {
        lock.lock();
        try {
            System.out.println("in b");
        } finally {
            lock.unlock();
        }
    }
}

执行过程(同一线程):

  • a() lock() 成功,计数从 0 → 1
  • a() 调用 b(),再次 lock(),计数从 1 → 2
  • b() 执行完 unlock(),计数从 2 → 1(锁还没真正释放)
  • a() 执行完 unlock(),计数从 1 → 0(锁真正释放)

一句话:

同一线程可以多次 lock() 同一把 ReentrantLock,内部维护一个“持有计数”,unlock() 到 0 才释放。

小结

  • synchronized 和 ReentrantLock 都是可重入锁
  • 可重入 = 避免自己锁死自己
  • 这和我们接下来要讲的“死锁”不是一回事,死锁是“互相等待”

下面进入本文重点:死锁到底是怎么发生的?ReentrantLock 又能怎么帮忙?

三、死锁到底是怎么“死”的?

先强调一点:

可重入锁 ≠ 不会死锁。
可重入锁只保证“同一个线程不会被自己锁死”,但多个线程之间还是可能死锁。

真正的死锁,通常是这种形态:

  • 至少两个线程
  • 至少两把锁
  • 每个线程都“手里拿着一把锁,再去等另一把锁”
  • 锁的获取顺序“刚好反着来”
  • 最后谁也等不到谁 → 永久互相等待 → 死锁

3.1 最经典的 synchronized 死锁例子

public class DeadlockDemo {

    private static final Object LOCK_A = new Object();
    private static final Object LOCK_B = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK_A) {
                System.out.println("T1 拿到了 LOCK_A");

                try { Thread.sleep(100); } catch (InterruptedException ignored) {}

                System.out.println("T1 想要 LOCK_B...");
                synchronized (LOCK_B) {
                    System.out.println("T1 拿到了 LOCK_B");
                }
            }
        }, "T1");

        Thread t2 = new Thread(() -> {
            synchronized (LOCK_B) {
                System.out.println("T2 拿到了 LOCK_B");

                try { Thread.sleep(100); } catch (InterruptedException ignored) {}

                System.out.println("T2 想要 LOCK_A...");
                synchronized (LOCK_A) {
                    System.out.println("T2 拿到了 LOCK_A");
                }
            }
        }, "T2");

        t1.start();
        t2.start();
    }
}

输出大概是:

T1 拿到了 LOCK_A
T2 拿到了 LOCK_B
T1 想要 LOCK_B...
T2 想要 LOCK_A...
(然后程序卡死,不再继续)

3.2 死锁的过程拆开看

  • T1:先拿到 A,然后想拿 B → 等待 B

  • T2:先拿到 B,然后想拿 A → 等待 A

此时状态是:

T1 拿着 A 等 B
T2 拿着 B 等 A

谁也不会先释放自己手中的锁,于是就这样互相等,一直僵持下去。

这就是死锁的本质:循环等待。

小结

  • 可重入:解决“同线程重复加锁”的问题;

  • 死锁:多个线程拿多把锁,加锁顺序不一致,互相等待导致的“环形依赖”。

可重入 ≠ 防死锁。
这两个概念要分开。

接下来,我们看 ReentrantLock 的高级能力如何在“避免死锁”这件事上帮上忙。

四、ReentrantLock 提供了哪些“防死锁的武器”?

synchronized 的问题是:

一旦进入等待锁的状态,就只能干等,既不能中断、也不能限时、也不能快速失败。

而 ReentrantLock 提供了更多可控的方式:

  • tryLock():尝试加锁,拿不到直接返回 false,不死等
  • tryLock(timeout, unit):带超时的尝试
  • lockInterruptibly():等待锁时可以被中断
  • 公平锁:避免某些线程长期得不到锁(虽然和死锁不是一回事,但有助于避免“饥饿”)

4.1 用 tryLock 改造“死锁”示例

我们试着用 ReentrantLock 做同样的事情,但加上 tryLock:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class DeadlockAvoidDemo {

    private static final ReentrantLock LOCK_A = new ReentrantLock();
    private static final ReentrantLock LOCK_B = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            try {
                if (LOCK_A.tryLock(500, TimeUnit.MILLISECONDS)) {
                    System.out.println("T1 拿到了 LOCK_A");
                    try {
                        Thread.sleep(100);

                        System.out.println("T1 尝试获取 LOCK_B...");
                        if (LOCK_B.tryLock(500, TimeUnit.MILLISECONDS)) {
                            try {
                                System.out.println("T1 拿到了 LOCK_B");
                            } finally {
                                LOCK_B.unlock();
                            }
                        } else {
                            System.out.println("T1 获取 LOCK_B 失败,放弃本次操作");
                        }
                    } finally {
                        LOCK_A.unlock();
                    }
                } else {
                    System.out.println("T1 获取 LOCK_A 失败");
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "T1");

        Thread t2 = new Thread(() -> {
            try {
                if (LOCK_B.tryLock(500, TimeUnit.MILLISECONDS)) {
                    System.out.println("T2 拿到了 LOCK_B");
                    try {
                        Thread.sleep(100);

                        System.out.println("T2 尝试获取 LOCK_A...");
                        if (LOCK_A.tryLock(500, TimeUnit.MILLISECONDS)) {
                            try {
                                System.out.println("T2 拿到了 LOCK_A");
                            } finally {
                                LOCK_A.unlock();
                            }
                        } else {
                            System.out.println("T2 获取 LOCK_A 失败,放弃本次操作");
                        }
                    } finally {
                        LOCK_B.unlock();
                    }
                } else {
                    System.out.println("T2 获取 LOCK_B 失败");
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "T2");

        t1.start();
        t2.start();
    }
}

这段代码的意义是:

  • 如果在指定时间内拿不到第二把锁,就放弃本次操作,并且释放自己手里的锁

  • 不会出现“双方永远等下去”的情况

这是 ReentrantLock 在“辅助避免死锁”上的一个非常重要的能力:
我们可以通过业务逻辑 + tryLock 来打破“循环等待”。

当然,这不是自动防死锁,而是:
你有了工具,可以在设计时主动规避死锁。

小结

  • synchronized:要么拿到锁、要么一直等
  • ReentrantLock:可以 tryLock(),可以设置超时,可以在等待时被中断
  • 这些能力让你有机会在“快要死锁”的情况下主动退出,避免彻底卡死系统

五、实战中如何减少死锁风险?(和用什么锁同样重要)

无论是 synchronized 还是 ReentrantLock,只要有“多锁 + 多线程”,就存在死锁可能。
要减少死锁风险,更多靠设计习惯。

5.1 统一加锁顺序(最有效策略)

规则:所有线程获取多把锁时,顺序必须一致。

比如约定:所有地方都先锁 A,再锁 B:

// 正确写法示例
synchronized (A) {
    synchronized (B) {
        // ...
    }
}

只要所有线程都一致这么写,就不会出现:

线程 1:A → B
线程 2:B → A

也就不会产生“循环等待”。

这是业界防止死锁的第一原则。

5.2 尽量减少锁的颗粒度 & 数量

锁越多、嵌套越深,死锁风险越高。
典型的工程实践:

  • 避免在锁内调用外部不受控的方法(如第三方库回调)
  • 避免在锁内做 I/O、网络访问、数据库操作(服务很慢,锁就一直占着)

5.3 用 ReentrantLock 的 tryLock 做兜底

核心思路:

  • 如果拿不到第二把锁,就放弃本次操作,释放已有的锁,进行降级处理/重试
  • 不要无休止等待

可以用于:

  • 高并发下的资源竞争
  • 避免因为极端时序导致系统整体挂死

小结

解决死锁,核心是设计,ReentrantLock 只是给你提供了更多“可选的策略接口”。

六、总结:ReentrantLock 与死锁的真正关系

最后用几句话把全文收一下:

  1. 可重入锁(Reentrant)解决的是:同一个线程在锁内部再次请求同一把锁会不会被自己卡死的问题。

  • synchronized 和 ReentrantLock 都是可重入锁
  • 内部用计数方式:每次加锁 +1,解锁 -1,减到 0 才真正释放
  1. 死锁(Deadlock)解决的是:多个线程之间互相等待对方的锁,造成循环等待的问题。

    • 典型模式:

  • T1 拿锁 A 等锁 B
  • T2 拿锁 B 等锁 A
  • 这和“可重入”完全是两个维度

  1. ReentrantLock 不会自动帮你“防死锁”,但它提供了 tryLock / 可中断 / 超时 等机制,让你有机会在业务层设计“避免死锁”的策略。

  2. 写并发代码的原则是:

  • 简单场景:synchronized 足够
  • 高并发 + 高可用需求:用 ReentrantLock + 合理的锁设计(统一加锁顺序、尽量少锁、加 tryLock 等兜底)

下一篇: 一文搞懂 Java 死锁:四大必要条件、经典死锁 Demo、如何用代码和思想避免死锁

更多推荐