上一篇中,总结并发的三个特性(原子性、可见性、有序性)发表了一些总结和看法,正常来说,我们都是围绕这三个特性所引发的问题进行处理的。而今天,我们聊聊一个经常说的现象,线程死锁问题。

首先,我们要知道,死锁是一种特定的程序状态。那为什么会有死锁这个状态呢?

因为在几个程序之间,由于循环依赖导致彼此一直处于等待之中,这样等待,没有程序进去,也没有程序出来。那产生死锁要怎么办呢?简单,关机重启!!

好吧,这也是开玩笑的,在我们Java的线程中,资源独占的进程之间同样产生死锁也是很容易的,通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

下面的示例图理解基本的死锁问题:

java 进程 fullgc java 进程锁_java 进程 fullgc

好吧,说完死锁问题,我们来说说如何预防死锁的问题。其实并发程序一旦死锁,一般没有特别好的方法,虽然不用上述的关机重启方式,但是还是只能重启应用的。

因此,解决死锁问题最好的办法还是规避死锁。

那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,只有以下这四个条件都发生时才会出现死锁:

1. 互斥

2. 占有且等待

3. 不可抢占

4. 循环等待

反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。

java 进程 fullgc java 进程锁_浅谈java中的死锁_02

首先是破坏互斥条件,何为互斥呢?互斥就是事件A与事件B在任何一次试验中都不会同时发生,则称事件A与事件B互斥,这个条件我们没有办法破坏,因为我们用锁为的就是互斥。

然后是破坏占有且等待条件,这就比如线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X。所以我们可以一次性申请所有的资源,这样就不存在等待了,这相当于把锁的粒度大点。

接着是不可抢占条件,不可抢占指的是其他线程不能强行抢占线程 T1 占有的资源,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。关于synchronized关键字,以后我也会详细说明,这里我们可以理解成是一把 SDK 层面的锁。

当然,锁也不止这种呢,在 J.U.C(java.util.concurrent) 这个包中,Lock 是可以轻松解决这个问题的。Lock也是一种互斥锁,但是Lock的灵活性更好,关于Lock,以后我也会详细讲到。

最后是循环等待条件,循环等待指的是线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

这种情况可以靠按序申请资源来预防,所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

我们说完如何避免死锁发生后,接着说说,如果避免不了发生死锁了,那我应该怎么排查死锁呢?

大部分死锁本身并不难定位,掌握基本思路和工具使用,理解线程相关的基本概念,比如各种线程状态和同步、锁、 Latch 等并发工具,就已经足够解决大多数问题了。

正常我们定位死锁的工具是jstack,当然还有一些类似 JConsole 等图形化的工具,主要是用来查看JVM中线程栈。在这之前我们可以使用 jps 或者系统的 ps 命令、任务管理器等工具,确定进程 ID 。

接着我们调用 jstack 获取线程栈:

${JAVA_HOME}\bin\jsack your_pid

分析得到的输出,具体片段如下:

java 进程 fullgc java 进程锁_浅谈java中的死锁_03

由上图的线程栈信息可以看出,处于 BLOCKED 状态的线程就是已经造成死锁的前程。

所以,理解线程基本状态和并发相关元素是定位问题的关键,然后配合程序调用栈结构,基本就可以定位到具体的问题代码。

关于死锁,主要也就讲这些吧。死锁在高并发编程中挺重要的,主要是要理解如何避免死锁,这样才可以在并发编程中掉入坑中。