面试官:请手写一段必然死锁的代码

前言

死锁(Deadlock),是并发编程中最需要考虑的问题之一,一般来说死锁发生的概率相对较小,但是危害奇大。本篇主要讲解死锁相关的内容,包括死锁形成的必要条件、危害、如何避免等等。

死锁的定义

死锁(英语:Deadlock),又译为死结,计算机科学名词。当两个以上的运算单元,双方都在等待对方停止运行,以获取系统资源,但是没有一方提前退出时,就称为死锁。在多任务操作系统中,操作系统为了协调不同行程,能否获取系统资源时,为了让系统运作,必须要解决这个问题。

具体到线程死锁,也就是多个(大于等于2个)线程相互持有对方需要的资源,在没有外界干扰的情况下,会永远处于等待状态。

必然死锁的例子

先来看一个必然发生死锁的例子,来直观的感受一下死锁:

/**
 * 必然死锁的例子
 * @author sicimike
 */
public class DeadLock {

    final private static Object lock1 = new Object();
    final private static Object lock2 = new Object();

    public static void main(String[] args) {

        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                // 先获取lock1
                System.out.println("thread-1 get lock1");
                try {
                    // 休眠200毫秒,让thread-2获取lock2
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    // 在lock1同步代码中获取lock2
                    System.out.println("thread-1 get lock2");
                }
            }
            System.out.println("thread-1 finished");
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                // 先获取lock2
                System.out.println("thread-1 get lock2");
                synchronized (lock1) {
                    // 在lock2同步代码中获取lock1
                    System.out.println("thread-2 get lock1");
                }
            }
            System.out.println("thread-2 finished");
        }, "thread-2");

        thread1.start();
        thread2.start();
    }
}

执行结果:

thread-1 get lock1
thread-2 get lock2

例子共有6行输出,但是只输出了2行。不仅如此,用IDE运行该代码时,IDE永远不会执行结束。
对于本次运行结果,thread-1首先获取CPU时间片,开始执行,获取锁lock1,输出thread-1 get lock1,然后休眠200毫秒。在200毫秒内thread-2获取CPU时间片,开始执行,获取lock2,输出thread-2 get lock2
此时tread-1必须获取lock2才能继续执行,执行完成才能释放自己持有的lock1。而thread-2同理,想要继续执行,必须先获取thread-1持有的lock1,执行完成才能释放lock2。就这样,两个线程发生了死锁。导致后续的代码都不会执行,之后的语句并不会输出。

死锁的危害

  • 首先肯定是程序得不到正确的结果,因为处于死锁状态的线程无法处理原先分配的任务。
  • 死锁会降低资源利用率,处于死锁状态的线程所持有的资源是不会释放的,更不能被别的线程利用,所以会导致资源的利用率降低
  • 可能导致新的死锁产生,死锁的线程持有的资源,可能是系统非常宝贵且有限的资源,其他线程获取不到,依然可能会被死锁,产生多米诺骨牌效应

死锁产生的必要条件

死锁的危害非常巨大,是并发编程必须要考虑的问题。不过好在死锁的产生条件比较严苛,需要同时满足四个必要条件:

  • 互斥条件:一个资源同时最多能被一个线程持有
  • 请求与保持条件:一个线程因请求其他资源而被阻塞时,不会释放已持有的资源
  • 不剥夺条件:线程执行完成之前,其他的线程不能抢占该线程持有的资源
  • 循环等待条件:多个线程请求的资源形成一个等待环

只要其中一个不满足就不可能发生死锁。
再回过头来看看上文的实例是不是满足这四个条件,thread-1和thread-2所需的资源是lock1和lock2,都是互斥锁,满足互斥条件;thread-1和thread-2被阻塞后不会释放持有的锁,满足请求与保持条件;thread-1和thread-2都不能直接抢占对方持有的锁,满足不剥夺条件;thread-1需要thread-2持有的lock2,而thread-2需要thread-1持有的lock1,满足循环等待条件
因为只有2个线程,所以循环等待条件不是很明显,可以把实例改成三个线程

/**
 * 三个线程-必然死锁的例子
 * @author sicimike
 */
public class ThreeThreadDeadLock {

    final private static Object lock1 = new Object();
    final private static Object lock2 = new Object();
    final private static Object lock3 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                // 先获取lock1
                System.out.println("thread-1 get lock1");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    // 在lock1中获取lock2
                    System.out.println("thread-1 get lock2");
                }
            }
            System.out.println("thread-1 finished");
        }, "thread-1");

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                // 先获取lock2
                System.out.println("thread-2 get lock2");
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock3) {
                    // 在lock2中获取lock3
                    System.out.println("thread-2 get lock3");
                }
            }
            System.out.println("thread-2 finished");
        }, "thread-2");

        Thread thread3 = new Thread(() -> {
            synchronized (lock3) {
                // 先获取lock3
                System.out.println("thread-3 get lock3");
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    // 在lock3中获取lock1
                    System.out.println("thread-3 get lock1");
                }
            }
            System.out.println("thread-3 finished");
        }, "thread-3");

        thread1.start();
        thread2.start();
        thread3.start();
    }
}

执行结果

thread-1 get lock1
thread-2 get lock2
thread-3 get lock3

同样,程序也不会结束。
thread-1获取lock1后还需要获取lock2,thread-2获取lock2后还需要lock3,thread-3获取lock3后还需要获取lock1,这样就是循环等待条件,三个线程所需要的资源形成了一个环。

定位死锁

主要讲解三种方式来定位死锁:jstack命令、jconsole工具、ThreadMXBean类,以上面两个线程死锁的实例演示。

  • jstack命令
    先查看系统进程,jps(Java Virtual Machine Process Status Tool)是JDK提供的一个显示当前所有java进程pid的命令,位于...\jdk1.8.0_101\bin目录

    C:\Users\Atao>jps
    2272
    10180 Jps
    13956 RemoteMavenServer36
    11032 Launcher
    8488 DeadLock
    

    可以很清楚的看到运行DeadLock.java类的进程pid(8488),再运行jstack命令,jstack是JDK提供的线程堆栈分析工具,使用该命令可以查看Java程序线程堆栈信息,位于...\jdk1.8.0_101\bin目录

    C:\Users\Atao>jstack -F 8488
    Attaching to process ID 8488, please wait...
    Debugger attached successfully.
    Server compiler detected.
    JVM version is 25.101-b13
    Deadlock Detection:
    
    Found one Java-level deadlock:
    =============================
    
    "thread-1":
      waiting to lock Monitor@0x00000000173a0628 (Object@0x00000000d64dc960, a java/lang/Object),
      which is held by "thread-2"
    "thread-2":
      waiting to lock Monitor@0x000000001739f188 (Object@0x00000000d64dc950, a java/lang/Object),
      which is held by "thread-1"
    
    Found a total of 1 deadlock.
    
    Thread 1: (state = BLOCKED)
    ......
    

    很清楚的就能看到哪几个线程发生了死锁(现在应该知道为什么要给每个线程取一个有意义的名字了)

  • jconsole工具,位于...\jdk1.8.0_101\bin目录
    启动jconsole
    打开JConsole
    检测死锁
    检测死锁
    查看检测结果
    查看检测结果

  • ThreadMXBean类
    ThreadMXBean是JDK自带的类,位于java.lang.management包中。是Java虚拟机线程的系统管理接口。以上文举出的必然死锁的例子为例:

    public class DeadLock {
    
        final private static Object lock1 = new Object();
        final private static Object lock2 = new Object();
    
        public static void main(String[] args) {
    
            Thread thread1 = new Thread(() -> {
                synchronized (lock1) {
                    // 先获取lock1
                    System.out.println("thread-1 get lock1");
                    try {
                        // 休眠200毫秒,让thread-2获取lock2
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock2) {
                        // 在lock1同步代码中获取lock2
                        System.out.println("thread-1 get lock2");
                    }
                }
                System.out.println("thread-1 finished");
            }, "thread-1");
    
            Thread thread2 = new Thread(() -> {
                synchronized (lock2) {
                    // 先获取lock2
                    System.out.println("thread-2 get lock2");
                    synchronized (lock1) {
                        // 在lock2同步代码中获取lock1
                        System.out.println("thread-2 get lock1");
                    }
                }
                System.out.println("thread-2 finished");
            }, "thread-2");
    
            // 检测死锁的线程
            Thread monitorThread = new Thread(() -> {
                while (true) {
                    try {
                        // 每隔2秒检测一次
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("死锁线程信息:");
                    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
                    long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
                    for (long thread : deadlockedThreads) {
                        System.out.println(threadMXBean.getThreadInfo(thread));
                    }
                }
            }, "monitor-thread");
    
            thread1.start();
            thread2.start();
            monitorThread.start();
        }
    }
    

    执行结果:

    thread-1 get lock1
    thread-2 get lock2
    死锁线程信息:
    "thread-2" Id=12 BLOCKED on java.lang.Object@793190d2 owned by "thread-1" Id=11
    "thread-1" Id=11 BLOCKED on java.lang.Object@77f67184 owned by "thread-2" Id=12
    死锁线程信息:
    "thread-2" Id=12 BLOCKED on java.lang.Object@793190d2 owned by "thread-1" Id=11
    "thread-1" Id=11 BLOCKED on java.lang.Object@77f67184 owned by "thread-2" Id=12
    ......
    

死锁的处理

既然知道了死锁产生的四个必要条件,所以只需要破坏其中一个或者多个即可。

  • 对于互斥条件,想要破坏它,就是能加共享锁的就不要加独占锁
  • 对于请求与保持条件,可以设置超时时间,阻塞一段时间后,如果还未获取到锁,就释放自己持有的锁;或者死锁发生时,调度者强行中断某个死锁的状态,并释放持有的资源
  • 对于不剥夺条件,请求资源(锁)时,使用可以响应中断的锁,例如Lock.lockInterruptibly()
  • 对于循环等待条件,这个条件相对来说是最好破坏的。只需要打破等待环即可。线程请求多把锁的时候,做到按顺序请求锁(每个线程),这样就不会形成等待环。

可以动手改造下上文中三个线程死锁的例子,使三个线程均按照lock1->lock2->lock3的顺序请求锁。

最佳实践

死锁的处理策略总的来说有三种方式:

  • 避免策略
  • 检测与修复
  • 不处理(鸵鸟策略)

对于生产环境而言,死锁的大于,也就是说应该将重点放在死锁的避免上。在实际工作中养成良好的习惯,可以大大减少死锁发生的概率,好的习惯总结如下:

  • 争抢锁时设置超时,比如Lock的tryLock(long, unit)方法
  • 多使用并发工具类,而不是自己设计锁
  • 尽量降低锁的粒度
  • 如果能使用同步代码块,就不使用同步方法:锁的粒度更小;还可以自己指定锁对象
  • 给线程起有意义的名字:方便debug、日志记录
  • 避免锁的嵌套
  • 尽量不要多个功能用同一把锁:专锁专用
  • 分配资源前先看能不能收回来:银行家算法

总结

死锁像火灾一样:不可预测、蔓延迅速、危害大。编写并发程序的时候,一定要特别关注。

扩展阅读

  • https://zh.wikipedia.org/zh-hans/银行家算法
  • https://zh.wikipedia.org/wiki/哲学家就餐问题
发布了52 篇原创文章 · 获赞 107 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/Baisitao_/article/details/103358978
今日推荐