SRE重案调查组 第六集 | 剖析Java的非常规线程死锁问题

这是本人发表在 eBay 微信公众号 eBay技术荟 上的 一系列文章, 原文地址如下. 编辑非常给力, 请查看原文, 这里只是供搜索引擎访问.
https://mp.weixin.qq.com/s/r__X4sYj6PLjPDWDulJAAw

如果原文由于某种原因不存在了, 请查看这个pdf 版本: SRE重案调查组 第六集 | 剖析Java的非常规线程死锁问题

导读

本文将分享eBay SRE部门遇到的某个非常规Java应用程序死锁问题。SRE侦探们将从最表象的问题入手,逐步分析,并重现代码,提出该类问题的解决方案,最后总结Java常规及非常规死锁问题的分析及定位,希望能对同业人员有所启发。

在多线程并发环境下,为了保证某些共享资源的状态一致性,通常我们会对这些资源的访问进行加锁,只有获得锁的线程可以使用该资源,不能获得锁的线程要排队等待或者放弃访问。对共享资源加锁,不仅会带来锁获取,释放及排队的开销,同时还有可能导致多线程死锁,致使线程阻塞,不能继续提供服务。
对于Java应用程序常见的死锁问题,通过对thread dump分析很容易发现症结所在。然而,有一种死锁问题,在thread dump里却不那么容易被找出来。本文将分享一个此类的死锁案例,希望对读者解决该类问题有所帮助。

一、问题描述

有一天,监控平台上显示某个应用部分服务器的Tomcat服务线程在很短的时间内突然用光了,从原来平均6个忙碌的Tomcat服务线程突然上升到设置的最大值40,一旦上升到最大值之后,就不会再减少,如图1所示。这就导致应用里能正常服务的服务器越来越少, 还能正常提供功能的服务器的并发请求数明显增加。同时,从客户端来看,很多发往这些出问题服务器上的请求都出现了请求超时,有些甚至出现了服务熔断。
出问题应用的忙碌线程数和CPU使用率,每条曲线代表一台服务器:

图1(点击查看大图)

二、初步分析

首先,我们观察到这些应用服务器的忙碌线程数在短短的几分钟内,就到达了设置的最大值。那么,是什么原因让这么多线程突然忙碌起来呢?

我们先是检查了进来的流量,没有发现大规模增加,另外在应用前端的负载均衡服务器上并没有任何改动,还是默认的Round Robin模式,排除了流量剧增的可能。

另外,我们可以看到,仅仅是部分机器的忙碌线程数突然增加,并非所有的都在增加。而且,只要服务器的忙碌线程数开始增加,它就很快达到上限,并且维持在最大值,不会减小。

既然是Tomcat服务线程全部忙碌,我们首先想到的就是:这些线程在干什么?为什么这么忙碌?

于是选定一台忙碌线程数已经达到最大值的应用服务器,连续做了3个thread dump文件。从这3个dump文件里,我们发现了一些很明显的特征。

这些Tomcat服务线程全部都卡在了两个不同的地方: 一个名为TrackingComponent.java类的getTrackingInfo方法里面第115行和第118行。从下面的图2和图3可以看到,有7个线程停在第115行,33个线程停在第118行。

共有7个Tomcat线程都停在了TrackingComponent.java的第115行:

图2(点击查看大图)

共有33个Tomcat线程都停在了TrackingComponent.java的第118行:

图3(点击查看大图)

一般来说,对于不同时间点上获取的thread dump,如果某些线程一直卡在某个方法上,除了巧合之外,很有可能是等待锁造成的。可是从上面的截图可以看到,这些线程并没有表明它们在等待某些锁(若是等待锁,thread dump会以waiting for lock 标明)。

三、深入代码

那么为什么所有Tomcat服务线程都会停在这两行呢?这两行代码有什么特别之处吗?
于是我们查看了这个类的具体代码,图4就是这两行代码所在的getTrackingInfo方法:

TrackingComponent.java类相关代码的片段:

图4(点击查看大图)

乍一看,这两行代码貌似并没有什么可疑之处。但是SRE侦探可不会就此罢休,我们继续深入研究,从这两行代码的一些蛛丝马迹中,我们终于发现了它们之间的一些关联。
第115行,ShippingService_DUMMY是一个类,它的父类正是118行里面用到的ShippingService。这个ShippingService是一个抽象类,ShippingService_DUMMY是它的一个具体实现类。而STANDARD是ShippingService_DUMMY的一个static字段,这个字段也是ShippingService_DUMMY的一个实例。
所以第115行的目的其实很简单,就是把ShippingService_DUMMY的一个静态字段STANDARD的值赋值给service,如图5所示。
ShippingService_DUMMY类片段:

图5(点击查看大图)

第118行,ShippingService是一个抽象类,不能被实例化,但是它有一个static的getInstance()方法,可以根据传入参数的不同,返回它的具体某个实现子类,然后赋值给service。它还包含一个static块,这个静态块里面会加载其所有子类的类型到内存,如图6所示。

ShippingService类片段:

图6(点击查看大图)

虽然它们之间是抽象类和具体子类关系,可是这和Tomcat服务线程都卡在此处有什么关系呢?

为了让问题描述起来更简单,我们假设现在有2个线程分别是:Thread-A和Thread-B,两者同时在运行。

我们假设Thread-A开始执行第115行代码,发现ShippingService_DUMMY类还没有被加载到内存,于是Thread-A通过Java的ClassLoader的类加载机制去加载ShippingService_DUMMY。

同时Thread-B 开始执行第118行代码,发现ShippingService类还没有被加载到内存,于是Thread-B通过Java的ClassLoader的类加载机制去加载ShippingService。

在Thread-A把ShippingService_DUMMY加载到内存之后,发现它的父类是ShippingService,可是ShippingService还没有完全被加载成功,而Thread-B已经开始加载ShippingService类了,所以Thread-A就停在这里等待ShippingService加载完成。

同时Thread-B把ShippingService的字节码文件加载到内存,接着验证,做ShippingService类的static初始化代码,可是它的static代码块里面需要ShippingService_DUMMY类,而ShippingService_DUMMY类正在被Thread-A加载和做类的初始化,导致线程Thread-B只能停在这里等待Thread-A。

于是这两个线程最终因为彼此等待,导致死锁。

类加载、连接和初始化流程

在使用例子说明问题之前,我们可以先了解一下类是如何被加载到内存,然后才能被使用的。一个类被使用之前,会顺序发生3个阶段: 分别是Load,Link,Initialize。

Load:当Java虚拟机想要使用一个类,却发现还没有这个类的二进制表示的时候,它就会使用Class Loader去找到这个类的二进制表示,然后载入内存。

Link:细分为Verify,Prepare和可选的Resolve三个子过程。

  Verify阶段会去验证Load阶段得到的二进制表示是不是符合Java语言语法规范和Java虚拟机的类文件规范。
  Prepare阶段会对该类用到静态字段,方法表,和JVM初始化类用到的其它数据结构所用的空间进行分配。
  Resolve阶段会检查从当前类到其它类和接口的符号表是否正确。

Initialize:初始化当前类的静态变量和执行静态块。但前提是它的直接父类已经被初始化,否则将先对自己的直接父类做整个加载,连接和初始化的过程。对于父类的父类,则依此类推。

在OpenJDK和HotSpot的JDK实现中,Initialize阶段之前,会在该类的内存结构中添加一个静态init_lock字段,该字段是一个长度为0的int数组(init[

四、代码重现

由于上面实际问题的代码涉及到大量的业务逻辑,为了更简单直观地说明问题,我们用三个简短的实例代码来模拟上面一样的过程。共涉及到三个类:一个抽象的父类:AbstractClass,一个具体的子类SubClass和一个包含main函数用来运行示例代码的辅助类Main,分别如图7,8,9所示。
抽象类只有一个静态的getInstance方法和一个静态块:

图7(点击查看大图)

子类包含一个静态块和一个构造函数:

图8(点击查看大图)

Main方法类使用2个线程同时去执行抽象类的getInstance方法和初始化子类的一个实例对象:

图9(点击查看大图)

运行main函数,大多数情况下我们会发现程序并不能正常运行结束并退出,如图10所示。抽象类的静态块没有执行完,子类的静态块和初始化方法都没有被执行。

运行结果:

图10(点击查看大图)

从图11的thread dump中我们可以看到两个线程都是在RUNNABLE状态, 并且分别停在了哪两行代码:

Thread dump片段:

图11(点击查看大图)

同时,通过对采集的heap dump分析,我们可以看到,虽然内存中已经有这些类,但这些类还没有被初始化完成,也就是还不能正常使用。

AbstractClass类已经出现在内存中,可以看到它的init_lock字段,所以它还在被初始化过程中:

图12(点击查看大图)

SubClass出现在内存中,可以看到init_lock:

图13(点击查看大图)

通过上面图12和图13可以看到,这两个类都已经在内存中,并且都在初始化过程中。进一步查看这些init_lock字段,只能看到它们是Busy Monitor,不能看到更多信息,不过Busy Monitor已经说明它们都已在获取锁后的执行过程当中了。

JVM对类进行初始化的过程中会保证只有唯一的一个线程会对一个类进行初始化,这个唯一性是通过锁机制去保证的,初始化之前会先获得锁,初始化之后释放锁。

如果类初始化过程中发现父类尚未加载和初始化,就先去加载和初始化父类。如果在类的静态块初始化过程中有其他的类尚未加载初始化,就先要加载和初始化这些类。在加载和初始化父类或其他类的过程中,会继续持有当前类的初始化锁。

若两个线程分别在初始化两个不同的类,同时又等待对方正在初始化的类完成,就会发生死锁。由于这两个线程都是等待对方类初始化完成,并不是等待锁,所以不能像一般的死锁问题一样,可以打印锁的ID,让我们在thread dump中很容易发现死锁问题。

五、解决方案

对于这个案例中的问题,在父类的静态块中加载子类的具体实现类,一般不被推荐。通过延迟加载,在父类的具体实例中去加载子类的具体类,就可以有效地避免此类问题。
从更广泛的层面来说,在类初始化的过程中,先初始化父类,再初始化子类不可避免,所以尝试去避免因为静态字段初始化和静态块初始化运行产生的相互依赖,是一种有效的手段。

常规的死锁问题及定位

一般的线程死锁问题,通过thread dump很容易定位。通过jstack命令或者通过jcmd命令生成thread dump,在thread dump的最后部分,一般都会有说明是不是有死锁的线程。同时,在具体线程的stack里面,都有描述每个线程现在正在等待某个特定的锁,可以通过查找这个锁的ID来确定哪个线程拥有这把锁。

常规死锁线程栈的部分截图:

图14(点击查看大图)

上面的截图(图14)里,除去了其它不相关的线程信息。可以看到 DefaultThreadPool-24拥有ID为0x000000070676f428的锁,同时它在试图获取ID为0x000000070676f938的锁。DefaultThreadPool-25则相反,所以导致死锁。

这里的lock ID就是某个特定类或者类实例的锁。基本上通过Synchronize或者Lock接口获得的锁,都有这种lock ID。通过锁的ID很容易确认是哪些代码引起的死锁。在早期的LockSupport版本中,在park相关的API中并没有要求提供阻塞对象,导致在thread dump中找到相对应的锁对象不是那么容易。后面添加的带阻塞对象的API,能帮助开发人员像查找Synchronize对象的锁一样,很容易就找到对应的锁对象。

六、总结

常规的死锁问题,很容易通过thread dump来分析和定位,因为里面包含锁ID。对于由于类初始化引起的死锁问题,可以通过分析类之间的相互依赖关系的方法,来基本确定问题,然后通过对内存对象的初始化状态来验证这一问题。

参考文献:

1http://mail.openjdk.java.net/pipermail/hotspot-runtime-dev/2014-September/012431.html
2http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/share/native/java/lang/Class.c
[3]https://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html ->12.4.2 Detailed Initialization Procedure
[4]https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html
[5]https://hunterzhao.io/post/2018/05/15/hotspot-explore-java-lang-class-forname/
[6]http://cr.openjdk.java.net/~mr/jigsaw/spec/api/java/lang/Class.html#forName-java.lang.String-boolean-java.lang.ClassLoader-

标签: none

添加新评论