面试官提问:谈谈你对死锁的理解?

xbkong
发布于 2023-10-20 11:44
浏览
0收藏

一、简介

在之前的文章中,我们介绍了​​synchronized​​同步锁关键字的作用以及相关的用法,它能够保证同一时刻最多只有一个线程执行修饰的代码段,以实现线程安全执行的效果

但是如果过度的使用​​synchronized​​等方式进行加锁,程序可能会出现死锁现象。

那什么是死锁呢?它有什么危害?

我们知道被​​synchronized​​修饰的代码,当一个线程持有一个锁,其它线程尝试去获取这个锁未获取到时,那么其它线程会进入阻塞状态,直到线程释放锁才能再次拥有获取锁的条件。假如线程 A 持有锁 L 并且想获取锁 R,线程 B 持有锁 R 并且想获取锁 L,那么这两个线程将会永久等待下去,这种情况就是最简单的死锁现象。

如果程序出现了死锁,会给系统功能带来非常严重的问题,轻则导致程序响应时间变长,系统吞吐量变小;重则导致应用中的某一个功能直接失去响应能力无法提供服务,因此我们应该及时发现并避规这些问题。

当然发生死锁的软件应用,不仅限于 Java 程序,还有数据库等,不同的是:数据库系统中设计了死锁的检测以及从死锁中恢复的机制,数据库如果检测到一组事务中发生了死锁,将选择一个牺牲者并放弃这个事务。

而 Java 虚拟机解决死锁问题并没有数据库那么强大,在 Java 程序中,采用​​synchronized​​加锁的代码如果发生死锁,两个线程就不能再使用了,并且这两个线程所在的同步代码/代码块也无法再运行了,除非杀掉服务,然后重启服务!

在实际的软件项目开发过程中,死锁其实是编程设计上的 bug,问题也比较隐晦,即使通过压力测试也不一定能找到程序上的死锁问题。死锁的出现,往往是在高负载的情况下产生,这种场景下比较难定位。

二、死锁复现

下面我们先来看一个比较经典的产生死锁示例代码。

public class DeadLock {

    private final Object right = new Object();

    private final Object left = new Object();

    /**
     * 加锁顺序从left -> right
     */
    public void leftRight() throws Exception {
        synchronized (left) {
            // 模拟某个业务操作耗时
            Thread.sleep(1000);
            synchronized (right) {
                System.out.println(Thread.currentThread().getName() + " left -> right lock.");
            }
        }
    }

    /**
     * 加锁顺序right -> left
     */
    public void rightLeft() throws Exception {
        synchronized (right) {
            // 模拟某个业务操作耗时
            Thread.sleep(1000);
            synchronized (left) {
                System.out.println(Thread.currentThread().getName() + " right -> left lock.");
            }
        }
    }
}

public class MyThreadA extends Thread {


    private DeadLock deadLock;

    public MyThreadA(DeadLock deadLock){
        this.deadLock = deadLock;
    }

    @Override
    public void run(){
        try {
            deadLock.leftRight();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

public class MyThreadB extends Thread {


    private DeadLock deadLock;

    public MyThreadB(DeadLock deadLock){
        this.deadLock = deadLock;
    }

    @Override
    public void run(){
        try {
            deadLock.rightLeft();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

public class MyThreadTest {

    public static void main(String[] args){
        DeadLock deadLock = new DeadLock();

        MyThreadA threadA = new MyThreadA(deadLock);
        MyThreadB threadB = new MyThreadB(deadLock);
        threadA.start();
        threadB.start();
    }
}

运行测试类观察控制台,你会发现服务一直在运行,什么都没有输出,并且无法关闭,因为程序已经死锁了!

面试官提问:谈谈你对死锁的理解?-鸿蒙开发者社区

发生这个现象的原因,其实也很简单。

  • 1.线程 A 启动之后,先获取了​​left​​​对象锁,然后紧接着尝试获取​​right​​​对象锁,因为​​right​​对象锁被其它线程占有,只能进入阻塞状态
  • 2.线程 B 启动之后,先获取了​​right​​​对象锁,然后紧接着尝试获取​​left​​​对象锁,因为​​left​​对象锁被其它线程占有,只能进入阻塞状态
  • 3.两个线程互相等待对方释放锁,程序进入永久等待状态,因此都无法进入打印方法体

如何定位死锁问题呢?

我们可以通过 Java 自带的 jps 和 jstack 工具,查看 java 进程 id 和相关的线程堆栈信息。

定位过程如下!

2.1、通过 jps 获得当前 Java 虚拟机进程的 pid

面试官提问:谈谈你对死锁的理解?-鸿蒙开发者社区

左边的是当前 Java 虚拟机进程 ID,后边是进程名称,其中​​MyThreadTest​​就是我们当前运行的测试类服务。

2.2、通过 jstack 查看进程中的线程信息

在 jstack 后面输入对应的 java 进程 ID,然后回车即可查询到进程中的线程情况,前面的部分,可以很清晰的看到,两个线程都处于阻塞状态,等待获取对应的锁。

面试官提问:谈谈你对死锁的理解?-鸿蒙开发者社区

因为线程的信息比较多,直接滑倒最底部,可以看到 JVM 给出的死锁报告信息。

面试官提问:谈谈你对死锁的理解?-鸿蒙开发者社区

遇到这种情况,只能强制终止服务才能解除死锁!

三、避免死锁的方式

上面我们复现了死锁的发生,总结下来你会发现死锁的产生,总共有四个共同特点:

  • 1.互斥使用,即当资源被一个线程占用时,别的线程不能使用
  • 2.不可抢占,资源请求者不能强制从资源占有者手中抢夺资源,资源只能由占有者主动释放
  • 3.请求和保持,当资源请求者在请求其他资源的同时保持对原有资源的占有
  • 4.循环等待,多个线程存在环路的锁依赖关系而永远等待下去,例如 T1 占有 T2 的资源,T2 占有 T3 的资源,T3 占有 T1 的资源,这种情况可能会形成一个等待环路

这四个特点是死锁产生的必要条件,只要系统发生死锁,这些条件必然成立,只要能破坏其中一条即可让死锁消失,当然条件一是基础,不能被破坏。

理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免产生死锁和解除死锁。

在软件编程中,我们如何避免死锁呢?

关于死锁的避免,主要有以下几种方式:

  • 1.尽可能使用无锁编程,使用开放调用的编码设计
  • 2.设计时考虑清楚锁的顺序,尽量减少嵌在的加锁交互数量
  • 2.尽可能的缩小锁的范围,防止锁住的资源过多引发阻塞
  • 4.使用定时锁,比如​​Lock​​​类中的​​tryLock​​方法去尝试获取锁,这个方法支持在指定时间内获取锁,如果等待超时会返回一个失败信息,死锁会自动解除。

对于死锁的诊断,主要有以下几种方式:

  • 1.对代码进行全局分析,找出代码中什么地方会出现死锁
  • 2.通过线程转储(Thread Dump)信息来分析死锁,比如 jstack、jvisualvm、jconsole 等工具

至于死锁的解除,主要有以下几种方式:

  • 1.直接强制终止并重启服务,如果代码上的风险没有消除,可能还会再次出现
  • 2.采用定时锁方案,虽然​​synchronized​​​不具备这个功能,但是​​Lock​​​类中的​​tryLock​​​方法具备,实际编程中采用​​Lock​​中的超时机制进行加锁,应用的比较多

四、小结

本文主要围绕多线程编程中常见的死锁问题,从现象复现到方案解决进行了一次知识总结,内容难免有所遗漏,欢迎网友留言指出!

五、参考

1、李子捌 - Java中各种死锁详细讲述及其解决方案

2、五月的仓颉 - 线程死锁


文章转载自公众号:Java极客技术

标签
已于2023-10-20 11:44:00修改
收藏
回复
举报
回复
    相关推荐