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 和死锁的关系理清楚了。你写代码时,先想清楚用哪种锁,再想清楚锁的顺序,死锁基本就能避免。
可可精品资料库