3. 线程同步机制

说到线程同步,我估计不少朋友第一反应就是 synchronized。嗯,它确实好用,但有时候不够灵活。我个人习惯在复杂场景下用 Lock 锁,它给了你更多控制权。今天我们就聊聊 Lock、RLock,还有那个让人头疼的死锁问题。

一、Lock 锁:基础但强大

Lock 是 Java 并发包里的核心接口。它和 synchronized 最大的区别是什么?说白了,synchronized 是隐式的,你没法干预锁的获取和释放。而 Lock 是显式的,你可以手动控制。

核心方法:

  • lock():获取锁,拿不到就阻塞
  • unlock():释放锁,一定要在 finally 里调用
  • tryLock():尝试获取锁,拿不到就返回 false,不阻塞
  • lockInterruptibly():可中断的锁获取

来看个简单例子:

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

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();  // 一定要释放!
        }
    }
}

我在项目中遇到过有人忘记在 finally 里释放锁,结果线程直接卡死。嗯,这种 bug 排查起来特别痛苦。所以我的习惯是:写 lock() 的同时就把 try-finally 结构写好,别等后面再补。

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

你想想看,如果一个线程已经持有了锁,它还能不能再获取同一把锁?Lock 默认是不行的,但 RLock 可以。这就是可重入锁。

ReentrantLock 就是 RLock 的一个实现。它内部维护了一个计数器,每次获取锁 +1,每次释放锁 -1。只有计数器归零了,其他线程才能拿到锁。

什么时候用 RLock?

当一个方法调用了另一个需要同一把锁的方法时,就必须用可重入锁。比如递归调用、或者 A 方法加锁后调用 B 方法,B 方法也需要同一把锁。

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

    public void outer() {
        lock.lock();
        try {
            System.out.println("outer");
            inner();  // 这里会再次获取同一把锁
        } finally {
            lock.unlock();
        }
    }

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

如果这里用的是普通 Lock,outer() 调用 inner() 时就会死锁。因为线程自己把自己锁住了。我曾经在重构一个老系统时遇到过这种问题,当时排查了半天才发现是锁不可重入导致的。

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

死锁这东西,说白了就是线程之间互相等待对方释放资源,结果谁也动不了。它需要同时满足四个条件:

条件 说明
互斥 资源一次只能被一个线程占用
持有并等待 线程持有资源的同时,还在等待其他资源
不可剥夺 资源只能由持有者主动释放
循环等待 线程之间形成循环等待链

来看一个经典死锁例子:

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

    public void method1() {
        synchronized (lockA) {
            System.out.println("持有锁A,等待锁B");
            synchronized (lockB) {
                // 业务逻辑
            }
        }
    }

    public void method2() {
        synchronized (lockB) {
            System.out.println("持有锁B,等待锁A");
            synchronized (lockA) {
                // 业务逻辑
            }
        }
    }
}

当线程1执行 method1(),线程2执行 method2() 时,死锁就发生了。线程1拿着 A 等 B,线程2拿着 B 等 A,谁也等不到谁。

我曾经在线上环境遇到过死锁,现象就是某个接口突然卡住,所有请求都超时。用 jstack 一看,两个线程互相等待,形成了循环等待链。那次教训让我养成了写代码时先想清楚锁的顺序的习惯。

四、死锁的预防:从源头解决

预防死锁,说白了就是打破上面四个条件中的任意一个。最实用的方法有两个:

1. 固定锁顺序

让所有线程都按相同的顺序获取锁。比如都先拿 A 再拿 B,就不会出现循环等待了。

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

    public void method1() {
        synchronized (lockA) {
            synchronized (lockB) {
                // 业务逻辑
            }
        }
    }

    public void method2() {
        synchronized (lockA) {  // 和 method1 顺序一致
            synchronized (lockB) {
                // 业务逻辑
            }
        }
    }
}

2. 使用 tryLock 超时机制

用 tryLock(timeout, unit) 代替 lock(),拿不到锁就放弃,避免无限等待。

public class TryLockDemo {
    private final Lock lockA = new ReentrantLock();
    private final Lock lockB = new ReentrantLock();

    public boolean transfer() {
        try {
            if (lockA.tryLock(1, TimeUnit.SECONDS)) {
                try {
                    if (lockB.tryLock(1, TimeUnit.SECONDS)) {
                        try {
                            // 业务逻辑
                            return true;
                        } finally {
                            lockB.unlock();
                        }
                    }
                } finally {
                    lockA.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false;
    }
}

我的建议:能用固定锁顺序就别用 tryLock,因为 tryLock 的代码可读性差,而且超时时间设置也是个学问。设短了容易误判,设长了又起不到预防作用。

五、知识体系总览

下面这张图把本章的核心逻辑串起来了,你可以对照着回顾一下:

线程同步机制知识体系 Lock 锁 RLock 可重入锁 死锁 显式获取/释放 tryLock 非阻塞 lockInterruptibly 必须 finally 释放 同一线程可重入 计数器管理 递归调用安全 ReentrantLock 实现 互斥条件 持有并等待 不可剥夺 循环等待 预防措施:固定锁顺序 / tryLock 超时

嗯,这张图把 Lock、RLock 和死锁的关系理清楚了。你写代码时,先想清楚用哪种锁,再想清楚锁的顺序,死锁基本就能避免。

可可精品资料库