3. 线程同步与锁:Lock锁机制、RLock可重入锁、死锁的产生与避免

说到线程同步,锁这个东西,几乎是每个多线程程序员都绕不开的坎。我刚开始写并发代码那会儿,总觉得锁就是加个 synchronized 就完事了。后来项目越做越大,才发现事情没那么简单。

今天咱们就聊聊 Java 里的 Lock 锁机制、RLock 可重入锁,还有那个让人头疼的死锁问题。嗯,这部分内容,说白了就是让你在多个线程抢资源的时候,能稳稳地控制住局面。

3.1 Lock 锁机制:比 synchronized 更灵活的选择

Java 5 之后,我们有了 java.util.concurrent.locks.Lock 接口。它比 synchronized 关键字灵活得多。为什么这么说?

  • 可中断锁:线程在等待锁的时候,可以响应中断信号
  • 超时获取:等不到锁的时候,可以设置一个超时时间,不用死等
  • 非阻塞尝试:tryLock() 方法让你能尝试获取锁,拿不到就干别的事

我在项目中遇到过这样一个场景:一个后台任务需要定时刷新缓存,但刷新过程中如果另一个线程也在操作,就会出问题。用 synchronized 的话,线程会一直阻塞在那里。换成 Lock 的 tryLock(2, TimeUnit.SECONDS),两秒拿不到锁就直接放弃,不影响主流程。这个设计,我个人觉得非常实用。

核心用法示例:

Lock lock = new ReentrantLock();

// 标准用法:一定要在 finally 中释放锁
lock.lock();
try {
    // 临界区代码
    System.out.println("线程 " + Thread.currentThread().getName() + " 获取到了锁");
} finally {
    lock.unlock();
}

💡 我的习惯:每次写 Lock 的时候,我都会先写 try-finally 结构,再往里面填业务代码。这样永远不会忘记释放锁。你想想看,要是忘了 unlock,那这个锁就永远解不开了,整个系统都可能卡死。

3.2 RLock 可重入锁:同一个线程能反复进入

RLock,全称 ReentrantLock,意思是「可重入锁」。什么叫可重入?说白了,就是同一个线程可以多次获取同一把锁,而不会把自己给锁死。

举个例子:

public class ReentrantExample {
    private final Lock lock = new ReentrantLock();

    public void methodA() {
        lock.lock();
        try {
            System.out.println("进入 methodA");
            methodB();  // 同一个线程再次获取锁
        } finally {
            lock.unlock();
        }
    }

    public void methodB() {
        lock.lock();
        try {
            System.out.println("进入 methodB");
        } finally {
            lock.unlock();
        }
    }
}

你看,methodA 获取了锁,然后调用 methodB,methodB 又去获取同一把锁。如果锁不可重入,那 methodB 就会永远等下去——因为锁已经被自己占着了。这就是死锁的一种。

ReentrantLock 内部维护了一个计数器。每次 lock() 就加 1,每次 unlock() 就减 1。只有计数器归零了,其他线程才能拿到锁。这个设计,我觉得非常巧妙。

⚠️ 注意:获取了几次锁,就要释放几次。我曾经见过一个同事,在递归方法里用了 ReentrantLock,lock 了 3 次但只 unlock 了 2 次。结果那个线程一直占着锁不放,其他线程全堵死了。排查了半天才发现是 unlock 次数不对。

3.3 死锁的产生:四个必要条件

死锁这东西,说白了就是两个或多个线程互相等待对方释放资源,结果谁也动不了。我记得刚工作那会儿,写一个转账功能,A 账户转给 B 账户,B 账户转给 A 账户,两个线程同时执行,结果系统卡死了。嗯,那就是典型的死锁。

死锁的产生需要同时满足四个条件:

条件 说明
互斥条件 资源一次只能被一个线程占用
请求与保持 线程占有一个资源,同时请求另一个资源
不可剥夺 资源不能被强制从线程中拿走
循环等待 多个线程形成环形等待链

你想想看,只要破坏掉其中一个条件,死锁就不会发生。最常见的做法就是破坏「循环等待」——给资源排个序,所有线程都按同样的顺序去获取锁。

3.4 死锁的避免:实战中的几种策略

我在实际项目中,总结了几种避免死锁的方法:

  1. 固定锁顺序:所有线程都按相同的顺序获取锁。比如先锁 accountA,再锁 accountB。
  2. 使用 tryLock 超时:拿不到锁就放弃,避免无限等待。
  3. 缩小锁范围:只在真正需要的地方加锁,别把整个方法都锁住。
  4. 使用并发工具类:比如 java.util.concurrent 包里的工具,很多已经帮你处理好了锁的问题。

避免死锁的代码示例:

public class SafeTransfer {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public void transfer() {
        // 固定顺序:先锁 A,再锁 B
        synchronized (lockA) {
            System.out.println("获取到锁 A");
            synchronized (lockB) {
                System.out.println("获取到锁 B");
                // 执行转账逻辑
            }
        }
    }
}

💡 避坑指南:我曾经在一个支付系统中,用 tryLock 设置了 3 秒超时。如果拿不到锁,就记录日志并重试。这样即使出现死锁,系统也能自我恢复,不会永久卡死。这个做法,我建议你在高并发场景下也试试。

3.5 知识体系总览

下面这张图,是我梳理的本章核心知识结构。你可以把它当作一个思维导图来看:

线程同步与锁 Lock 锁机制 • 可中断锁 • 超时获取 • 非阻塞尝试 RLock 可重入锁 • 同一线程可重复获取 • 计数器管理 • 必须成对 unlock 死锁 • 互斥条件 • 请求与保持 • 不可剥夺 • 循环等待 避免策略:固定顺序 | tryLock | 缩小范围

这张图把本章的核心内容串起来了。从左到右,从 Lock 机制到 RLock,再到死锁的产生和避免,其实是一条线:怎么安全地让多个线程共享资源。

我个人觉得,锁这个东西,用好了是利器,用不好就是灾难。关键是要理解它的原理,然后在实践中不断积累经验。嗯,今天就先聊到这儿,希望这些内容对你有帮助。