欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2024-03-24 21:26:16
...

前言

死锁(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 aaa@qq.com (aaa@qq.com, a java/lang/Object),
      which is held by "thread-2"
    "thread-2":
      waiting to lock aaa@qq.com (aaa@qq.com, 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
    面试官:请手写一段必然死锁的代码
    检测死锁
    面试官:请手写一段必然死锁的代码
    查看检测结果
    面试官:请手写一段必然死锁的代码

  • 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 aaa@qq.com owned by "thread-1" Id=11
    "thread-1" Id=11 BLOCKED on aaa@qq.com owned by "thread-2" Id=12
    死锁线程信息:
    "thread-2" Id=12 BLOCKED on aaa@qq.com owned by "thread-1" Id=11
    "thread-1" Id=11 BLOCKED on aaa@qq.com 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/哲学家就餐问题