一、什么是死锁?它就像一场尴尬的交通堵塞
想象一下,你开车到一个狭窄的单行道上,对面也来了一辆车。你们俩都需要通过这条路,但谁都不愿意先倒车。于是,你们就僵持在那里,谁也动不了。在Java多线程的世界里,这种情况就叫“死锁”。
具体来说,死锁通常发生在两个或多个线程互相等待对方释放锁的时候。每个线程都握着自己需要的“钥匙”(锁),同时又在等待对方手里的“钥匙”,结果就是所有线程都卡住,程序“假死”在那里。这听起来很糟糕,但好消息是,我们可以通过一些聪明的规则和设计来避免它。
二、制造一场“死锁”:先看看问题是怎么来的
要解决问题,我们得先知道问题是怎么发生的。下面我们来写一个经典的死锁场景。这个例子非常简单,但非常典型。
技术栈:Java
public class DeadlockDemo {
// 两把不同的“钥匙”(锁对象)
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
// 线程1:先拿lockA,再想拿lockB
Thread thread1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1:成功拿到了锁A");
try {
// 稍微睡一会儿,让线程2有机会拿到锁B
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1:等待获取锁B...");
synchronized (lockB) {
System.out.println("线程1:成功拿到了锁A和锁B");
}
}
});
// 线程2:先拿lockB,再想拿lockA
Thread thread2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2:成功拿到了锁B");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2:等待获取锁A...");
synchronized (lockA) {
System.out.println("线程2:成功拿到了锁B和锁A");
}
}
});
// 启动两个线程
thread1.start();
thread2.start();
}
}
运行这段代码,你很可能会看到程序打印出“线程1:等待获取锁B...”和“线程2:等待获取锁A...”后就停在那里,再也不动了。这就是死锁。两个线程互相等,天荒地老。
三、破解死锁的四大实战心法
知道了死锁的成因,我们就可以对症下药了。下面介绍几个最常用、最有效的解决方案。
心法一:固定顺序拿锁
这是最简单粗暴也最有效的方法之一。回想一下我们的死锁例子,问题出在两个线程拿锁的顺序是反的。如果我们定下一个规矩:不管谁,想用这两把锁,都必须先拿A,再拿B。这样,线程1先拿到A,线程2想拿A就得等,等线程1用完A和B释放后,线程2才能按顺序拿到A和B。交通规则立好了,堵塞自然就解开了。
技术栈:Java
public class FixedOrderLock {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
// 严格遵守顺序:先A后B
synchronized (lockA) {
System.out.println("线程1:拿到锁A");
synchronized (lockB) {
System.out.println("线程1:拿到锁A和锁B,开始工作");
}
}
});
Thread thread2 = new Thread(() -> {
// 同样严格遵守顺序:先A后B
synchronized (lockA) {
System.out.println("线程2:拿到锁A");
synchronized (lockB) {
System.out.println("线程2:拿到锁A和锁B,开始工作");
}
}
});
thread1.start();
thread2.start();
}
}
这个方法的好处是简单明了,但缺点是需要你在设计初期就规划好所有锁的全局顺序,在复杂系统中维护这个顺序可能会比较麻烦。
心法二:尝试拿锁,拿不到就放弃
有时候,我们没必要“在一棵树上吊死”。Java的并发包java.util.concurrent.locks提供了一个强大的工具:ReentrantLock。它有一个tryLock()方法,可以尝试去获取锁,如果获取不到,它不会傻等,而是立刻返回false。这样,线程就有机会释放自己已经持有的锁,退一步海阔天空,过一会儿再重试。
技术栈:Java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
private static final Lock lockA = new ReentrantLock();
private static final Lock lockB = new ReentrantLock();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
boolean gotLockA = false;
boolean gotLockB = false;
try {
// 尝试获取锁A
gotLockA = lockA.tryLock();
if (gotLockA) {
System.out.println("线程1:拿到锁A");
// 拿到A后,尝试获取锁B
gotLockB = lockB.tryLock();
if (gotLockB) {
System.out.println("线程1:成功拿到锁A和锁B,开始工作");
} else {
System.out.println("线程1:没拿到锁B,准备释放锁A重试");
}
}
} finally {
// 最终无论如何,都要确保释放已获得的锁
if (gotLockB) lockB.unlock();
if (gotLockA) lockA.unlock();
// 如果没拿到全部锁,可以在这里加一个短暂的休眠,然后重试整个逻辑
if (!(gotLockA && gotLockB)) {
try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
// 这里可以递归或循环调用自身来重试,示例中省略
}
}
});
Thread thread2 = new Thread(() -> {
// 线程2的逻辑与线程1类似,尝试获取锁A和锁B
boolean gotLockA = false;
boolean gotLockB = false;
try {
gotLockA = lockA.tryLock();
if (gotLockA) {
System.out.println("线程2:拿到锁A");
gotLockB = lockB.tryLock();
if (gotLockB) {
System.out.println("线程2:成功拿到锁A和锁B,开始工作");
} else {
System.out.println("线程2:没拿到锁B,准备释放锁A重试");
}
}
} finally {
if (gotLockB) lockB.unlock();
if (gotLockA) lockA.unlock();
if (!(gotLockA && gotLockB)) {
try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
}
}
});
thread1.start();
thread2.start();
}
}
这种方法给了我们更多的灵活性和控制力,但代码会变得复杂,需要小心处理锁的释放和重试逻辑,否则容易出错。
心法三:给等待加个“闹钟”
synchronized关键字最大的问题之一就是,它会让线程无限期地等待锁。而ReentrantLock的另一个强大功能是tryLock(long time, TimeUnit unit),它可以指定一个超时时间。比如,我尝试拿锁,等5秒还拿不到,我就放弃,去干点别的(比如释放自己的锁、记录日志、抛异常等),绝不无休止地等下去。
技术栈:Java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TimeoutLockDemo {
private static final Lock lockA = new ReentrantLock();
private static final Lock lockB = new ReentrantLock();
public static void work(String threadName, Lock firstLock, Lock secondLock) {
boolean gotFirst = false;
boolean gotSecond = false;
try {
// 尝试获取第一把锁,最多等100毫秒
gotFirst = firstLock.tryLock(100, TimeUnit.MILLISECONDS);
if (gotFirst) {
System.out.println(threadName + ":拿到第一把锁");
// 尝试获取第二把锁,最多等100毫秒
gotSecond = secondLock.tryLock(100, TimeUnit.MILLISECONDS);
if (gotSecond) {
System.out.println(threadName + ":成功拿到两把锁,开始工作");
// 模拟工作
Thread.sleep(200);
} else {
System.out.println(threadName + ":获取第二把锁超时,放弃");
}
} else {
System.out.println(threadName + ":获取第一把锁超时,放弃");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 一定要在finally块中释放锁
if (gotSecond) secondLock.unlock();
if (gotFirst) firstLock.unlock();
System.out.println(threadName + ":释放了所有持有的锁");
}
}
public static void main(String[] args) {
// 线程1按A->B顺序尝试
Thread t1 = new Thread(() -> work("线程1", lockA, lockB));
// 线程2按B->A顺序尝试(故意制造可能死锁的条件)
Thread t2 = new Thread(() -> work("线程2", lockB, lockA));
t1.start();
t2.start();
}
}
使用超时机制,即使发生了锁竞争,最坏的情况也就是线程等一会儿然后失败,而不会导致整个系统僵死。这是构建健壮高并发系统的重要技巧。
心法四:把多把锁“打包”成一把
如果多个资源总是需要被同时访问,那么一个更根本的解决办法是:不要用多把锁,而是用一把更大的锁来保护所有这些资源。虽然这可能会降低一点并发度(因为锁的粒度变粗了),但它彻底消除了因获取多锁顺序不当而导致死锁的可能。在简单场景下,这常常是最省心的方法。
技术栈:Java
public class CoarseGrainedLock {
// 共享资源
private static int sharedResourceX = 0;
private static int sharedResourceY = 0;
// 一把大锁,保护所有相关资源
private static final Object globalLock = new Object();
public static void updateBothResources() {
synchronized (globalLock) {
// 安全地访问和修改resourceX和resourceY
sharedResourceX++;
sharedResourceY--;
System.out.println("更新资源: X=" + sharedResourceX + ", Y=" + sharedResourceY);
}
}
public static void main(String[] args) {
// 多个线程同时调用,但由同一把锁保护
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 3; j++) {
updateBothResources();
}
}).start();
}
}
}
这种方法简单安全,但需要权衡。如果resourceX和resourceY实际上关联不大,频繁被独立访问,那么用一把大锁就会导致不必要的线程等待,影响性能。
四、如何选择与实战要点
应用场景:
- 固定顺序锁:适用于锁的数量不多、顺序容易定义清晰的场景,比如转账业务(总是先锁账户ID小的,再锁账户ID大的)。
- 尝试锁/超时锁:适用于对响应时间有要求、或者需要构建高可用服务的场景,比如电商秒杀系统,不能因为某个锁争用导致整个服务线程池耗尽。
- 粗粒度锁:适用于一组资源总是需要被原子性操作的场景,或者是在快速原型开发阶段,优先保证正确性。
技术优缺点:
- synchronized:优点是用起来简单,JVM自动管理锁的获取和释放,不容易出现忘记解锁的问题。缺点就是功能单一,无法实现尝试锁、超时、公平锁等高级功能。
- ReentrantLock:优点是功能强大,提供了尝试锁、超时锁、可中断锁、公平锁等多种选择,控制灵活。缺点是需要手动
lock()和unlock(),必须写在try-finally块中确保释放,否则会导致严重问题,对开发者要求更高。
注意事项:
- 锁一定要释放:使用
ReentrantLock时,unlock()调用必须放在finally代码块中,确保无论业务逻辑是否抛出异常,锁都能被释放。 - 避免嵌套过深:尽量减少需要同时持有的锁的数量,锁的嵌套层次越深,发生死锁的复杂度就越高。
- 使用工具检测:在测试阶段,可以借助
jstack命令或者JConsole、VisualVM等可视化工具来检测程序中是否存在死锁线程。 - 设计优于补救:在系统设计之初,就规划好资源的并发访问策略,比后期修补死锁问题要有效得多。
文章总结:
死锁是多线程编程中的一个经典难题,但并非无解。其核心在于多个线程对锁资源的循环等待。我们破解它的思路也就围绕于此:要么破坏等待条件(通过固定顺序),要么破坏不可抢占条件(通过尝试锁或超时锁),要么从根本上减少锁的竞争点(使用粗粒度锁)。在实际开发中,synchronized适合大多数简单的同步场景,而ReentrantLock则为我们提供了应对复杂并发问题的工具箱。理解这些原理,并在代码中谨慎地管理锁的获取顺序和时机,是编写出稳定、高效并发程序的关键。记住,多线程编程的艺术,往往在于如何在“尽可能并发”和“保证绝对安全”之间找到那个最佳的平衡点。
评论