Java核心技术【二十五】Java多线程死锁问题详解

引言

在Java开发中,多线程是提升程序性能的重要手段之一,它允许程序同时执行多个任务。然而,多线程编程也带来了许多挑战,其中死锁(Deadlock)是一个尤为棘手的问题。死锁不仅会导致程序执行效率低下,严重时甚至会使程序完全停止响应。本文将结合理论与实例代码,深入探讨Java多线程中的死锁问题,包括死锁的定义、产生原因、分类以及预防和解决策略。

一、死锁的定义

1.1 什么是死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,这些线程都将无法继续执行。简而言之,死锁是一种僵局状态,线程之间相互等待对方释放资源,导致所有相关线程都无法继续向前推进。

1.2 死锁的特征

  • 互斥条件:资源不能被多个线程共享,即一个资源每次只能被一个线程使用。
  • 请求与保持条件:线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有,此时请求线程被阻塞,但对自己已获得的资源保持不放。
  • 不剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺。
  • 循环等待条件:存在一个线程资源的循环等待链,链中每一个线程已获得的资源同时被链中下一个线程所请求。

二、死锁的产生原因

2.1 资源竞争

在多线程环境中,多个线程可能竞争有限数量的资源。当一个线程申请资源时,如果这时没有可用资源,该线程将进入等待状态。如果所申请的资源被其他等待线程占有,那么该等待线程有可能再也无法改变状态,从而导致死锁。

2.2 锁的顺序不一致

线程在获取多个锁时,如果不同的线程以不同的顺序获取这些锁,就可能导致死锁。例如,线程A 先获取 锁1 再获取 锁2,而 线程B 先获取 锁2 再获取 锁1,如果 线程A 持有 锁1 并等待 锁2,同时 线程B 持有 锁2 并等待 锁1,就会发生死锁。

2.3 不可抢占资源

线程在持有资源期间,其他线程无法强行抢占这些资源,必须等待持有线程主动释放。如果持有资源的线程因为等待其他资源而无法继续执行,就可能导致死锁。

三、死锁的分类

3.1 静态顺序型死锁

静态顺序型死锁是指线程之间以固定的顺序获取锁时,若某些线程尝试以不同的顺序获取锁,就可能导致死锁。例如,在银行账户转账的场景中,如果两个线程分别尝试从两个不同的账户向对方转账,且获取锁的顺序不一致,就可能发生死锁。

3.2 动态顺序型死锁

动态顺序型死锁是指线程获取锁的顺序并非固定,而是由外部参数或条件决定。这种死锁更加难以预测和避免,因为它依赖于运行时的情况。

四、实例代码分析

4.1 静态顺序型死锁示例

package java_core_25;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 
 * @Description: Java核心技术【二十五】Java多线程死锁问题详解
 *
 * @author: C
 *
 * @date: 2024年8月2日
 *
 */
public class LeftRightDeadLock {  
    private static Object left = new Object();  
    private static Object right = new Object();  
  
    public static void leftRight() {  
        synchronized (left) {  
            System.out.println("leftRight: left lock, threadId: " + Thread.currentThread().getId());  
            try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }  
            synchronized (right) {  
                System.out.println("leftRight: right lock, threadId: " + Thread.currentThread().getId());  
            }  
        }  
    }  
  
    public static void rightLeft() {  
        synchronized (right) {  
            System.out.println("rightLeft: right lock, threadId: " + Thread.currentThread().getId());  
            try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }  
            synchronized (left) {  
                System.out.println("rightLeft: left lock, threadId: " + Thread.currentThread().getId());  
            }  
        }  
    }  
  
    public static void main(String[] args) {  
        ExecutorService executorService = Executors.newFixedThreadPool(2);  
        executorService.execute(() -> leftRight());  
        executorService.execute(() -> rightLeft());  
        executorService.shutdown();  
    }  
}

4.1.1 代码说明:

  • 首先,定义了两个对象 leftright,这两个对象将用作同步锁。
  • leftRight 方法首先获取 left 锁,然后输出当前线程的信息,并让线程暂停 100 毫秒。之后,该方法试图获取 right 锁,并再次输出当前线程的信息。
  • rightLeft 方法与 leftRight 类似,只是它首先获取 right 锁,然后尝试获取 left 锁。
  • main 方法创建了一个固定大小的线程池,包含两个工作线程。它启动了一个线程来执行 leftRight 方法,另一个线程执行 rightLeft 方法。最后调用 shutdown 方法来关闭线程池。

4.1.2 死锁分析:

leftRight 方法执行时,它首先获取 left 锁,然后在短暂延迟后尝试获取 right 锁。与此同时,rightLeft 方法正在运行,它已经获取了 right 锁,并在短暂延迟后尝试获取 left 锁。如果两个方法几乎同时启动,那么 leftRight 方法会在获取 left 锁后暂停,此时 rightLeft 方法有机会获取 right 锁。当 leftRight 方法恢复执行并尝试获取 right 锁时,rightLeft 方法也正在尝试获取 left 锁。结果,两个线程都持有其中一个锁,并且都在等待另一个锁。这就形成了一个死锁情况,两个线程都将无限期地等待下去。

4.1.3 输出结果及分析

leftRight: left lock, threadId: 15
rightLeft: right lock, threadId: 16

1)线程执行顺序:

  • leftRight 方法由线程 ID 15 执行,并成功获取了 left 锁.
  • rightLeft 方法由线程 ID 16执行,并成功获取了 right 锁。

2)死锁可能性:

  • 如果两个线程几乎同时启动,那么 leftRight 方法在获取 left 锁后会进入睡眠状态 100 毫秒。在此期间,rightLeft 方法有机会获取 right 锁。
  • leftRight 方法从睡眠中恢复并试图获取 right 锁时,rightLeft 方法仍然持有 right 锁。
  • 同时,rightLeft 方法试图获取 left 锁,而 leftRight 方法仍然持有 left 锁。
  • 这种情况下,两个线程都将陷入无限等待的状态,形成死锁。

3)输出中断:

输出结果表明两个线程分别获取了 leftright 锁,并且很可能已经陷入了死锁状态,因为它们都在等待对方释放所持有的锁。如果程序正常执行,我们期望看到两行额外的输出,表示两个线程都已经成功获取了第二个锁,但是这些输出并没有出现,这表明程序在此处停滞了。

4.2 动态顺序型死锁示例

package java_core_25;

public class TransferMoneyDeadlock {  
    public static void transfer(Account from, Account to, int amount) {  
        synchronized (from) {  
            System.out.println("线程[" + Thread.currentThread().getId() + "]获取[" + from.name + "]账户锁成功");  
            try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }  
            synchronized (to) {  
                System.out.println("线程[" + Thread.currentThread().getId() + "]获取[" + to.name + "]账户锁成功");  
                // 转账逻辑  
            }  
        }  
    }  
  
    private static class Account {  
        public String name;  
        // 其他属性和方法  
    }  
  
    public static void main(String[] args) {  
        Account account1 = new Account();  
        account1.name = "A";  
        Account account2 = new Account();  
        account2.name = "B";  
  
        Thread thread1 = new Thread(() -> transfer(account1, account2, 100));  
        Thread thread2 = new Thread(() -> transfer(account2, account1, 50));  
  
        thread1.start();  
        thread2.start();  
    }  
}

4.2.1 代码说明:

这段Java代码展示了如何模拟转账操作时可能出现的死锁情况。下面是代码的逐行解析:

  • transfer 方法接收三个参数:转账的源头账户 from、目标账户 to 以及转账金额 amount
  • 首先,方法使用 synchronized 关键字获取 from 账户的锁,并输出当前线程已成功获取 from 账户锁的信息。
  • 接着,线程暂停 100 毫秒以模拟某种操作或等待时间。
  • 最后,方法试图获取 to 账户的锁,并输出当前线程已成功获取 to 账户锁的信息。
  • to 账户锁的范围内,可以实现转账逻辑,但这里并未给出具体的转账实现。
  • Account 是一个简单的类,包含一个公共字符串属性 name。这个类可以扩展更多属性和方法以支持实际的转账操作,这里我们只为演示死锁问题。
  • main 方法创建了两个 Account 对象:account1account2,并将它们的名字分别设置为 “A” 和 “B”。
  • 创建了两个线程 thread1thread2,分别用于执行从 account1account2 转账 100 单位金额的操作,以及从 account2account1 转账 50 单位金额的操作。
  • 启动这两个线程。

4.2.2 死锁分析:

thread1thread2 几乎同时启动时,可能会发生以下情况:

1)线程 thread1:

  • 成功获取 account1 的锁,并输出相关信息。
  • 暂停 100 毫秒。
  • 尝试获取 account2 的锁。

2)线程 thread2:

  • 成功获取 account2 的锁,并输出相关信息。
  • 暂停 100 毫秒。
  • 尝试获取 account1 的锁。

如果两个线程几乎同时开始,thread1 在获取 account1 的锁之后暂停,此时 thread2 可能已经获取了 account2 的锁。当 thread1 试图获取 account2 的锁时,thread2 正在持有 account2 的锁,并试图获取 account1 的锁,这样就形成了一个死锁。

4.2.3 输出结果:

线程[16]获取[B]账户锁成功
线程[15]获取[A]账户锁成功

输出结果表明两个线程分别获取了 AB 账户的锁,并且很可能已经陷入了死锁状态,因为它们都在等待对方释放所持有的锁。如果程序正常执行,我们期望看到两行额外的输出,表示两个线程都已经成功获取了第二个账户的锁,但是这些输出并没有出现,这表明程序在此处停滞了。

五、死锁的预防和解决策略

5.1 破坏死锁的必要条件

为了避免死锁,可以破坏死锁产生的四个必要条件中的一个或多个。

  • 破坏互斥条件:由于互斥是资源的基本特性,通常无法破坏。
  • 破坏请求与保持条件:一次性申请所有需要的资源,如果某个资源申请失败,则释放已经申请到的所有资源。
  • 破坏不剥夺条件:当一个线程持有某些资源并等待其他资源时,可以强制剥夺它所持有的资源,并将其分配给其他线程。但这种方法通常难以实现且代价高昂。
  • 破坏循环等待条件:对所有资源进行排序,线程在申请资源时必须按照资源的顺序进行。

5.2 使用超时锁

在Java中,可以使用 Lock 接口及其实现类(如 ReentrantLock)来替代 synchronized 关键字,并设置锁的获取超时时间。如果线程在指定时间内未能获取到锁,则可以释放已经持有的锁,从而避免死锁。

5.3 死锁检测与恢复

可以通过工具(如 jstack)来检测死锁。当检测到死锁时,可以采取以下措施之一来恢复:

  • 终止一个或多个死锁线程:释放它们持有的资源,从而打破死锁。
  • 重新启动程序:在某些情况下,直接重启程序可能是最简单有效的解决方案。

5.4 使用“银行家算法”

“银行家算法”是一种避免死锁的著名算法,它通过预先分配资源的方式来防止系统进入不安全状态。然而,这种算法实现复杂且开销较大,通常用于操作系统级别的资源管理中。

六、结论

死锁是多线程编程中常见且棘手的问题之一。通过深入理解死锁的定义、产生原因、分类以及预防和解决策略,我们可以有效地避免和解决死锁问题。在实际开发中,应根据具体场景选择合适的预防和解决策略,以确保程序的稳定性和高效性。同时,也应注意代码的可读性和可维护性,避免因为复杂的锁机制而引入新的错误。

遇见即是缘分,关注🌟、点赞👍、收藏📚,让这份美好延续下去!

🌟 获取更多实用技巧和深入见解 请关注 ⬇ 技术管理修行

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值