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 死锁的避免:实战中的几种策略
我在实际项目中,总结了几种避免死锁的方法:
- 固定锁顺序:所有线程都按相同的顺序获取锁。比如先锁 accountA,再锁 accountB。
- 使用 tryLock 超时:拿不到锁就放弃,避免无限等待。
- 缩小锁范围:只在真正需要的地方加锁,别把整个方法都锁住。
- 使用并发工具类:比如
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,再到死锁的产生和避免,其实是一条线:怎么安全地让多个线程共享资源。
我个人觉得,锁这个东西,用好了是利器,用不好就是灾难。关键是要理解它的原理,然后在实践中不断积累经验。嗯,今天就先聊到这儿,希望这些内容对你有帮助。