深入理解Java多线程死锁问题

在多线程编程中,死锁是一个经典且棘手的问题。当两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法向前推进,这种情况就被称为死锁。

死锁产生的必要条件

死锁的发生必须同时满足以下四个必要条件,缺一不可:

互斥条件:一个资源每次只能被一个线程使用。例如,一个锁在某个时刻只能被一个线程持有。

请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。即线程在等待新资源的同时,不会释放已占有的资源。

不剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能由自己释放。

循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。即线程集合{T0, T1, ..., Tn}中,T0等待T1占用的资源,T1等待T2占用的资源,……,Tn等待T0占用的资源。

一个典型的死锁代码示例

以下代码展示了一个简单的死锁场景,其中两个线程分别以不同的顺序尝试获取两把锁。

```javapublic class DeadlockDemo { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) { Thread thread1 = new Thread(() -> { synchronized (lockA) { System.out.println(Thread1 holds lockA); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(Thread1 holds lockA and lockB); } } }); Thread thread2 = new Thread(() -> { synchronized (lockB) { System.out.println(Thread2 holds lockB); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(Thread2 holds lockB and lockA); } } }); thread1.start(); thread2.start(); }}```

在这个例子中,Thread1先获取lockA再尝试获取lockB,而Thread2先获取lockB再尝试获取lockA。极有可能发生Thread1持有lockA等待lockB,同时Thread2持有lockB等待lockA,双方互相等待,导致死锁。

诊断与检测死锁

当程序发生死锁时,通常表现为程序挂起,无响应。我们可以使用一些工具来诊断死锁:

jstack工具:这是JDK自带的一个命令行工具。我们可以先使用`jps`命令找到Java进程的PID,然后运行`jstack -l `来获取线程的堆栈信息。jstack会自动检测死锁并在输出中明确指出的“Found one Java-level deadlock”,并详细列出哪些线程在等待哪些锁。

JConsole 或 VisualVM:这些图形化监控工具也提供了检测死锁的功能。在JConsole的“线程”选项卡中,有一个“检测死锁”的按钮,点击后可以直观地看到发生死锁的线程和相关的锁信息。

解决与避免死锁的策略

理解了死锁产生的条件,我们就可以通过打破其中一个或多个条件来避免死锁。

1. 避免嵌套加锁与锁排序

最常用的方法是定义全局的锁获取顺序,并强制所有线程都遵循这个顺序来获取锁。这样就破坏了“循环等待”条件。

修改上面的示例,让两个线程都以相同的顺序(先lockA后lockB)获取锁,即可避免死锁。

```javaThread thread2 = new Thread(() -> { synchronized (lockA) { // 改为先获取lockA System.out.println(Thread2 holds lockA); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { // 再获取lockB System.out.println(Thread2 holds lockA and lockB); } }});```
2. 使用定时锁

使用`ReentrantLock`类提供的`tryLock()`方法,可以尝试获取锁,如果获取失败(超时),则释放已获得的所有资源并进行重试或回退操作。这打破了“请求与保持”条件。

```javaimport java.util.concurrent.locks.ReentrantLock;import java.util.concurrent.TimeUnit;public class TryLockDemo { private static final ReentrantLock lockA = new ReentrantLock(); private static final ReentrantLock lockB = new ReentrantLock(); public static void main(String[] args) { Thread thread1 = new Thread(() -> { try { while (true) { if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) { try { System.out.println(Thread1 holds lockA); if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) { try { System.out.println(Thread1 holds both locks); break; // 成功获取两把锁,退出循环 } finally { lockB.unlock(); } } } finally { lockA.unlock(); // 获取lockB失败,释放lockA } } // 短暂休眠,避免活锁 Thread.sleep(100); } } catch (InterruptedException e) { e.printStackTrace(); } }); // Thread2 采用类似的逻辑 Thread thread2 = ... // 代码逻辑与thread1类似 thread1.start(); thread2.start(); }}```
3. 缩小锁的范围(细粒度锁)

尽可能只在对共享资源操作的临界区加锁,减少线程持有锁的时间,从而降低发生死锁的概率。这虽然不能直接避免死锁,但是一种良好的编程实践。

4. 使用更高级的并发工具

Java并发包(java.util.concurrent)提供了许多高级同步机制,如并发集合(ConcurrentHashMap)、CountDownLatch、CyclicBarrier、Semaphore等。在很多时候,使用这些设计良好的线程安全工具比直接使用`synchronized`更能避免复杂的锁问题。

总结

死锁是多线程编程中一个需要严肃对待的问题。解决死锁的关键在于预防,通过分析代码、制定统一的锁获取顺序、使用带超时的尝试机制以及利用JDK提供的并发工具,可以有效地避免大多数死锁情况的发生。在开发过程中,养成良好的并发编程习惯,并结合工具进行定期检查和测试,是构建健壮、高效多线程应用的基石。

更多推荐