在 Java 并发编程中,synchronized 锁是常用的同步机制,但使用不当极易引发死锁。本文将详细剖析 3 种必死锁的场景:一是多线程嵌套获取不同顺序的锁,因锁获取顺序混乱导致相互等待;二是单例模式中同步代码块设计不合理,引发线程间的循环等待;三是线程池与锁结合使用时,任务依赖关系不当造成的死锁。通过对这些场景的原理分析、代码示例讲解及解决方案探讨,帮助开发者规避 synchronized 锁使用中的陷阱,提升并发程序的稳定性。
一、多线程嵌套获取不同顺序的锁
在 Java 并发编程里,多线程环境下若存在嵌套获取锁且顺序不一致的情况,死锁极易发生。这是因为当多个线程都持有对方需要的锁,且都在等待对方释放锁时,就会陷入无限等待的状态。
比如,有两个线程 Thread1 和 Thread2,Thread1 先获取锁 A,再尝试获取锁 B;而 Thread2 先获取锁 B,再尝试获取锁 A。当 Thread1 持有锁 A 等待锁 B,Thread2 持有锁 B 等待锁 A 时,死锁就产生了。
从代码层面来看,可能是这样的情况:
public class DeadLockDemo1 {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread1获取了lockA");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println("Thread1获取了lockB");
}
}
}).start();
在上述代码中,两个线程分别以不同的顺序获取锁 A 和锁 B,运行后很可能出现死锁。要解决这种场景下的死锁,关键在于保证所有线程获取锁的顺序一致。例如,规定所有线程都先获取锁 A,再获取锁 B,这样就不会出现相互等待的情况。
二、单例模式中同步代码块设计不合理
单例模式是 Java 中常见的设计模式,为了保证线程安全,常使用 synchronized 锁。但如果同步代码块设计不合理,也可能导致死锁。
以懒汉式单例为例,有些开发者可能会在获取实例的方法中使用双重检查锁定,但若同步代码块的范围或锁定对象设置不当,就可能引发问题。
错误的代码示例可能如下:
在这个例子中,构造方法中又对 Singleton.class 进行了锁定,而 getInstance 方法中的同步代码块也锁定了 Singleton.class。当一个线程在 getInstance 方法的同步块中创建实例,进入构造方法并持有 Singleton.class 锁时,另一个线程可能在 getInstance 方法的外层判断 instance 为 null 后,尝试进入同步块,此时就会等待 Singleton.class 锁,而持有锁的线程在构造方法中可能还需要完成其他操作,若此时又有依赖关系,就可能导致死锁。
要避免这种情况,需要合理设计同步代码块,尽量避免在构造方法中进行可能引发锁竞争的操作,或者调整锁定的对象和范围,确保不会出现循环等待的情况。可以采用静态内部类的方式实现单例,这种方式能更好地保证线程安全且避免死锁。
三、线程池与锁结合使用时任务依赖关系不当
线程池在 Java 并发编程中被广泛使用,能提高线程的利用率。但当线程池中的任务存在依赖关系,且结合 synchronized 锁使用时,若依赖关系处理不当,极易造成死锁。
比如,线程池中的任务 A 需要等待任务 B 的结果,而任务 B 又需要等待任务 A 释放的锁,此时就可能形成死锁。
代码示例如下:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class ThreadPoolDeadLockDemo {
private static final Object lock = new Object();
private static ExecutorService executor = Executors.newFixedThreadPool(2);
public static void main(String[] args) {
Future<?> futureA = executor.submit(() -> {
synchronized (lock) {
System.out.println("任务A获取了锁");
// 等待任务B完成
Future<?> futureB = executor.submit(() -> {
synchronized (lock) {
System.out.println("任务B获取了锁");
}
});
try {
futureB.get();
} catch (Exception e) {
e.printStackTrace();
}
}
});
try {
futureA.get();
在这个例子中,任务 A 获取锁后提交了任务 B,而任务 B 需要获取同一个锁才能执行。但此时锁被任务 A 持有,任务 B 等待锁,任务 A 又等待任务 B 完成,从而形成死锁。
解决这类死锁的关键在于合理规划任务之间的依赖关系,避免任务之间出现循环等待锁的情况。可以通过调整任务的执行顺序,或者使用更灵活的同步机制,如 Lock 接口及其相关方法,来更好地控制线程的执行和锁的获取释放。
四、总结归纳
synchronized 锁是 Java 并发编程中实现同步的重要手段,但在使用过程中,若不注意细节,很容易在特定场景下引发死锁。本文介绍的三种必死锁场景分别是:多线程嵌套获取不同顺序的锁、单例模式中同步代码块设计不合理、线程池与锁结合使用时任务依赖关系不当。
对于多线程嵌套获取锁的场景,保证所有线程获取锁的顺序一致是避免死锁的有效方法;在单例模式中,要合理设计同步代码块,避免构造方法中出现可能引发锁竞争的操作;而在线程池与锁结合使用时,需规划好任务间的依赖关系,必要时采用更灵活的同步机制。
开发者在进行 Java 并发编程时,应充分认识这些场景下死锁产生的原理,在代码设计和实现过程中加以规避,从而提高程序的稳定性和可靠性,减少因死锁导致的程序故障。
1958

被折叠的 条评论
为什么被折叠?



