Java面试必问-死锁终极篇

24,537 阅读10分钟

掘金江溢Jonny,转载请注明原创出处,谢谢!

关注我的公众号,获得更多干货~

背景

这个话题是源自笔者以前跟人的一次技术讨论,“你是怎么发现死锁的并且是如何预防、如何解决的?”以前听到的这个问题的时候,虽然脑海里也有一些思路,但是都是不够系统化的东西。直到最近亲身经历一次死锁,才做了这么一次集中的思路整理,撰录以下文字。希望对同样问题的同学有所帮助。

死锁定义

首先我们先来看看死锁的定义:“死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。”那么我们换一个更加规范的定义:“集合中的每一个进程都在等待只能由本集合中的其他进程才能引发的事件,那么该组进程是死锁的。”

竞争的资源可以是:锁、网络连接、通知事件,磁盘、带宽,以及一切可以被称作“资源”的东西。

举个栗子

上面的内容可能有些抽象,因此我们举个例子来描述,如果此时有一个线程A,按照先锁a再获得锁b的的顺序获得锁,而在此同时又有另外一个线程B,按照先锁b再锁a的顺序获得锁。如下图所示:

死锁

我们用一段代码来模拟上述过程:

public static void main(String[] args) {
    final Object a = new Object();
    final Object b = new Object();
    Thread threadA = new Thread(new Runnable() {
        public void run() {
            synchronized (a) {
                try {
                    System.out.println("now i in threadA-locka");
                    Thread.sleep(1000l);
                    synchronized (b) {
                        System.out.println("now i in threadA-lockb");
                    }
                } catch (Exception e) {
                    // ignore
                }
            }
        }
    });

    Thread threadB = new Thread(new Runnable() {
        public void run() {
            synchronized (b) {
                try {
                    System.out.println("now i in threadB-lockb");
                    Thread.sleep(1000l);
                    synchronized (a) {
                        System.out.println("now i in threadB-locka");
                    }
                } catch (Exception e) {
                    // ignore
                }
            }
        }
    });

    threadA.start();
    threadB.start();
}

程序执行结果如下:

程序执行结果
很明显,程序执行停滞了。

死锁检测

在这里,我将介绍两种死锁检测工具

1、Jstack命令

jstack是java虚拟机自带的一种堆栈跟踪工具。jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息。 Jstack工具可以用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁死循环请求外部资源导致的长时间等待等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。

首先,我们通过jps确定当前执行任务的进程号:

jonny@~$ jps
597
1370 JConsole
1362 AppMain
1421 Jps
1361 Launcher

可以确定任务进程号是1362,然后执行jstack命令查看当前进程堆栈信息:

jonny@~$ jstack -F 1362
Attaching to process ID 1362, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 23.21-b01
Deadlock Detection:

Found one Java-level deadlock:
=============================

"Thread-1":
  waiting to lock Monitor@0x00007fea1900f6b8 (Object@0x00000007efa684c8, a java/lang/Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock Monitor@0x00007fea1900ceb0 (Object@0x00000007efa684d8, a java/lang/Object),
  which is held by "Thread-1"

Found a total of 1 deadlock.

可以看到,进程的确存在死锁,两个线程分别在等待对方持有的Object对象

2、JConsole工具

Jconsole是JDK自带的监控工具,在JDK/bin目录下可以找到。它用于连接正在运行的本地或者远程的JVM,对运行在Java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。而且本身占用的服务器内存很小,甚至可以说几乎不消耗。

我们在命令行中敲入jconsole命令,会自动弹出以下对话框,选择进程1362,并点击“链接

新建连接

进入所检测的进程后,选择“线程”选项卡,并点击“检测死锁”

检测死锁
可以看到以下画面:
死锁检测结果
可以看到进程中存在死锁。

以上例子我都是用synchronized关键词实现的死锁,如果读者用ReentrantLock制造一次死锁,再次使用死锁检测工具,也同样能检测到死锁,不过显示的信息将会更加丰富,有兴趣的读者可以自己尝试一下。

死锁预防

如果一个线程每次只能获得一个锁,那么就不会产生锁顺序的死锁。虽然不算非常现实,但是也非常正确(一个问题的最好解决办法就是,这个问题恰好不会出现)。不过关于死锁的预防,这里有以下几种方案:

1、以确定的顺序获得锁

如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:

时序图

如果此时把获得锁的时序改成:

新时序图
那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。
哲学家进餐

问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“环路等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生,该算法在这里就不再赘述了,有兴趣的可以自行了解一下。

2、超时放弃

当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,时序图如下:

时序图

其他形式的死锁

我们再来回顾一下死锁的定义,“死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。” 死锁条件里面的竞争资源,可以是线程池里的线程、网络连接池的连接,数据库中数据引擎提供的锁,等等一切可以被称作竞争资源的东西。

1、线程池死锁

用个例子来看看这个死锁的特征:

final ExecutorService executorService = 
        Executors.newSingleThreadExecutor();
Future<Long> f1 = executorService.submit(new Callable<Long>() {

    public Long call() throws Exception {
        System.out.println("start f1");
        Thread.sleep(1000);//延时
        Future<Long> f2 = 
           executorService.submit(new Callable<Long>() {

            public Long call() throws Exception {
                System.out.println("start f2");
                return -1L;
            }
        });
        System.out.println("result" + f2.get());
        System.out.println("end f1");
        return -1L;
    }
});

在这个例子中,线程池的任务1依赖任务2的执行结果,但是线程池是单线程的,也就是说任务1不执行完,任务2永远得不到执行,那么因此造成了死锁。原因图解如下:

线程池死锁

执行jstack命令,可以看到如下内容:

"pool-1-thread-1" prio=5 tid=0x00007ff4c10bf800 nid=0x3b03 waiting on condition [0x000000011628c000]
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x00000007ea51cf40> (a java.util.concurrent.FutureTask$Sync)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:186)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:834)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedInterruptibly(AbstractQueuedSynchronizer.java:994)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(AbstractQueuedSynchronizer.java:1303)
	at java.util.concurrent.FutureTask$Sync.innerGet(FutureTask.java:248)
	at java.util.concurrent.FutureTask.get(FutureTask.java:111)
	at com.test.TestMain$1.call(TestMain.java:49)
	at com.test.TestMain$1.call(TestMain.java:37)
	at java.util.concurrent.FutureTask$Sync.innerRun(FutureTask.java:334)
	at java.util.concurrent.FutureTask.run(FutureTask.java:166)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
	at java.lang.Thread.run(Thread.java:722)

可以看到当前线程wait在java.util.concurrent.FutureTask对象上。

解决办法:扩大线程池线程数 or 任务结果之间不再互相依赖。

2、网络连接池死锁

同样的,在网络连接池也会发生死锁,假设此时有两个线程A和B,两个数据库连接池N1和N2,连接池大小都只有1,如果线程A按照先N1后N2的顺序获得网络连接,而线程B按照先N2后N1的顺序获得网络连接,并且两个线程在完成执行之前都不释放自己已经持有的链接,因此也造成了死锁。

// 连接1
final MultiThreadedHttpConnectionManager connectionManager1 = new MultiThreadedHttpConnectionManager();
final HttpClient httpClient1 = new HttpClient(connectionManager1);
httpClient1.getHttpConnectionManager().getParams().setMaxTotalConnections(1);  //设置整个连接池最大连接数

// 连接2
final MultiThreadedHttpConnectionManager connectionManager2 = new MultiThreadedHttpConnectionManager();
final HttpClient httpClient2 = new HttpClient(connectionManager2);
httpClient2.getHttpConnectionManager().getParams().setMaxTotalConnections(1);  //设置整个连接池最大连接数

ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.submit(new Runnable() {
    public void run() {
        try {
            PostMethod httpost = new PostMethod("http://www.baidu.com");
            System.out.println(">>>> Thread A execute 1 >>>>");
            httpClient1.executeMethod(httpost);
            Thread.sleep(5000l);

            System.out.println(">>>> Thread A execute 2 >>>>");
            httpClient2.executeMethod(httpost);
            System.out.println(">>>> End Thread A>>>>");
        } catch (Exception e) {
            // ignore
        }
    }
});

executorService.submit(new Runnable() {
    public void run() {
        try {
            PostMethod httpost = new PostMethod("http://www.baidu.com");
            System.out.println(">>>> Thread B execute 2 >>>>");
            httpClient2.executeMethod(httpost);
            Thread.sleep(5000l);

            System.out.println(">>>> Thread B execute 1 >>>>");
            httpClient1.executeMethod(httpost);
            System.out.println(">>>> End Thread B>>>>");

        } catch (Exception e) {
            // ignore
        }
    }
});

整个过程图解如下:

连接池死锁

在死锁产生后,我们用jstack工具查看一下当前线程堆栈信息,可以看到如下内容:

"pool-1-thread-2" prio=5 tid=0x00007faa7909e800 nid=0x3b03 in Object.wait() [0x0000000111e5d000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000007ea73f498> (a org.apache.commons.httpclient.MultiThreadedHttpConnectionManager$ConnectionPool)
	at org.apache.commons.httpclient.MultiThreadedHttpConnectionManager.doGetConnection(MultiThreadedHttpConnectionManager.java:518)
	- locked <0x00000007ea73f498> (a org.apache.commons.httpclient.MultiThreadedHttpConnectionManager$ConnectionPool)
	at org.apache.commons.httpclient.MultiThreadedHttpConnectionManager.getConnectionWithTimeout(MultiThreadedHttpConnectionManager.java:416)
	at org.apache.commons.httpclient.HttpMethodDirector.executeMethod(HttpMethodDirector.java:153)
	at org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:397)
	at org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:323)
	at com.test.TestMain$2.run(TestMain.java:79)
	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:471)
	at java.util.concurrent.FutureTask$Sync.innerRun(FutureTask.java:334)
	at java.util.concurrent.FutureTask.run(FutureTask.java:166)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
	at java.lang.Thread.run(Thread.java:722)

"pool-1-thread-1" prio=5 tid=0x00007faa7a039800 nid=0x3a03 in Object.wait() [0x0000000111d5a000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000007ea73e0d0> (a org.apache.commons.httpclient.MultiThreadedHttpConnectionManager$ConnectionPool)
	at org.apache.commons.httpclient.MultiThreadedHttpConnectionManager.doGetConnection(MultiThreadedHttpConnectionManager.java:518)
	- locked <0x00000007ea73e0d0> (a org.apache.commons.httpclient.MultiThreadedHttpConnectionManager$ConnectionPool)
	at org.apache.commons.httpclient.MultiThreadedHttpConnectionManager.getConnectionWithTimeout(MultiThreadedHttpConnectionManager.java:416)
	at org.apache.commons.httpclient.HttpMethodDirector.executeMethod(HttpMethodDirector.java:153)
	at org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:397)
	at org.apache.commons.httpclient.HttpClient.executeMethod(HttpClient.java:323)
	at com.test.TestMain$1.run(TestMain.java:61)
	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:471)
	at java.util.concurrent.FutureTask$Sync.innerRun(FutureTask.java:334)
	at java.util.concurrent.FutureTask.run(FutureTask.java:166)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
	at java.lang.Thread.run(Thread.java:722)

当然,我们在这里只是一些极端情况的假定,假如线程在使用完连接池之后很快就归还,在归还连接数后才占用下一个连接池,那么死锁也就不会发生。

总结

在我的理解当中,死锁就是“两个任务以不合理的顺序互相争夺资源”造成,因此为了规避死锁,应用程序需要妥善处理资源获取的顺序。 另外有些时候,死锁并不会马上在应用程序中体现出来,在通常情况下,都是应用在生产环境运行了一段时间后,才开始慢慢显现出来,在实际测试过程中,由于死锁的隐蔽性,很难在测试过程中及时发现死锁的存在,而且在生产环境中,应用出现了死锁,往往都是在应用状况最糟糕的时候——在高负载情况下。因此,开发者在开发过程中要谨慎分析每个系统资源的使用情况,合理规避死锁,另外一旦出现了死锁,也可以尝试使用本文中提到的一些工具,仔细分析,总是能找到问题所在的。

以上就是本次写作全部内容了,如果你喜欢,欢迎关注我的公众号~ 这是给我不断写作的最大鼓励,谢谢~