诊断 Java 由 Synchronizer 和 AQS 混合组成的死锁

死锁问题在 SRE 的日常的应用诊断中, 经常遇到. 遇到的大多数, 都是通过 thread dump 里面明显看到的, 因为 thread dump 默认会在最后打印出 JVM 识别出来的死锁. 对于使用 AQS 锁的情况, 获得的锁就不会打印在 thread dump 里面. 这时候, 就需要分析具体的堆栈的情况去具体识别. 这里就展示一个这样的案例.

症状:

首先, 报警显示有个机器的 Tomcat 服务线程被用光了, 已经无法再接受新的请求. 该应用其它server还是正常工作, 所以判断只是这个 server 走火入魔, 陷入了困境.
tomcatThreadsMax.png

初步诊断:

一般 Tomcat 服务线程用光(这个应用上默认设置最大40个 Tomcat 服务线程), 第一步就是捕获 thread dump, 查看这些 Tomcat 服务线程都在干嘛.
从 thread dump 来看, 这40个线程有33个在等待从一个数据库连接池获得连接, 可以猜测这些连接都被在正在使用, 所以这33个线程只能等待. 线程栈如下所示:
getConn.png

另外有7个线程都在等待获得一把对象锁. 根据这里的栈推断, 他们为了获得一个数据库表 sequence 的 ID, 所以需要这个锁来维护一致性. 线程栈如下:
getId.png

这里分别针对上面的2类不同的线程线索去查. 从 thread dump 看对象锁比较容易, 因为这种对象锁, 一旦有线程能获得, 对象锁 ID 必然打印在这个 thread dump 里面. 所以, 分2个方向去查.

谁获得了那个 getNextId()的对象锁, 它在干嘛?

根据锁对象的 ID, 我们很容易看到, 有个线程DefaultThreadPool-25, 它得到了这个锁, 然后又尝试去获得数据库连接池的连接. 因为连接池的连接用完了, 它只能等. 线程栈如下:
thread25.png

是谁获得的连接池的连接? 获得连接之后又在干嘛?

为了查清连接被谁正在使用, 我们只能分析 heap dump. 于是, 我们捕获了 heap dump, 分析连接池的使用情况.
从任何一个想要获得连接池连接的线程入手, 找到连接池对象, 我们大致可以看到这个连接池的一些基本属性:

  1. 这个连接池历史上共创建了27个连接, 到现在为止销毁了19个(连接长时间 idle, 就会被销毁);
  2. 现存的活得连接共有8个;
  3. 这个连接池活连接的最大值就是8个;
    heapConnPool.png
是那些线程在使用这8个连接?

由于共有8个连接, 在不是很多的情况下, 考虑先不使用 OQL, 我们可以手工查找是那些线程在使用这些连接. 我们先找到这个连接池的 allObjects 字段, 它是一个 ConcurrentHashMap, 找到里面的每个 PoolableConnection 对象. 然后右键选择 Path To GC Roots -> with all references. 步骤如下:
heapConnToGCRoot.png

然后从这里我们基本能看到2个路径: 1) 当前正在使用连接的线程到该连接的路径; 2) 从线程池到该连接的路径; 例如下面截图的连接, 从路径1可以可以看到这个连接正在被 DefaultThreadPool-25 使用. 如下:
heap_using.png

合并2个线索

然后我们挨个查看这8个连接, 发现其中7个就是上面提到的正在等待 getNextId() 对象锁的7个线程. 也就是说, 这7个线程先获得了连接池的连接, 然后又遇到了要获得 getNextId() 的对象锁. 然而这个对象锁, 已经被其它线程获得, 所以只能等待在这. 还剩下1个连接, 这个连接正在被线程 DefaultThreadPool-25 使用. 然而根据我们上面看到的, 这个线程现在的情况是: 它就是那个已经获得 getNextId() 对象锁的唯一线程, 而它现在也在等待获得连接池里的连接. 然而根据 heap dump 分析, 它已经获得过一次连接池里的连接了. 到底什么情况呢?

如果我们仔细观察上面的引用路径1, 我们会注意到, 这里持有那个连接的类名叫做:
org.springframework.transaction.support.AbstractPlatformTransactionManager$SuspendedResourcesHolder. 根据类名中的关键字: Suspend, 我们有理由怀疑这个连接被暂时挂起了. 查看线程 DefaultThreadPool-25 的栈, 我们进一步可以可以看到下面的情况: 该线程在开始一个新事务之前, 把原来的连接挂起了.
heap_suspend.png

从上面的栈可以看到, 这个线程正在尝试开始一个新的事务. 然而之前它已经获得了一个连接, 很有可能这是一个嵌套事务, 并且这两个事务需要不同的数据库连接, 这样的话, 嵌套的内层事务和外层事务之间不需要共同进退, 可以做到相互独立.

事务传导及数据库连接

这里就涉及到 Spring 的事务传导 (Transaction Propagation). 共有三种传导方式: 1) PROPAGATION_REQUIRED, 2) PROPAGATION_REQUIRES_NEW, 3) PROPAGATION_NESTED. 其中第二种传导方式: PROPAGATION_REQUIRES_NEW, 就需要每个嵌套的事务需要一个自己独立的物理连接. 如下图(从 Spring 官方文档复制):
tx_prop_requires_new.png

所以这里 DefaultThreadPool-25 这个线程的情况是这样的: 某个时候, 开始了外层事务, 先是获得了连接池的一个连接, 接着获得了 getNextId() 的对象锁, 然后又开始一个新的事务, 然而这个事务使用了 PROPAGATION_REQUIRES_NEW 的方式, 所以要获得一个新的物理连接. 可是现在再去申请新的连接的时候, 默认的8个连接全部都在使用, 所以只能卡着这里不动了.

情景梳理

那么梳理一下整个情况: 有40个线程, 他们在执行某些业务, 都要去某个最多有8个连接的数据库连接池申请连接, 其中8个申请到了, 剩下32个等待, 然后这8个线程下一站又都遇到了一个对象锁, 只能有一个线程同时拥有这个对象锁, 其中一个幸运儿( DefaultThreadPool-25)获得了这个对象锁, 其它7个只能在这里等待. 然后这个幸运儿不料又需要一个新的数据库连接, 再去刚才那个连接池申请数据库连接, 可是没有空闲可用的了. 只能卡死在这.

建议方案

  1. 对于任何想要获得的连接, 锁等关键资源, 只要有可能都要设置一个 timeout 时间, 防止死等;
  2. 如果能根据经验预测系统能承受的最大并发值, 那么在系统入口处设置这个值, 系统内部任何一个关键资源的量最好都大于等于这个值, 这个例子里面就是连接池的连接数;
  3. 这个例子中 getNextId() 的锁使用有问题, 它属于外层事务, 外层事务获得连接之后, 执行 getNextId() 应该是瞬时的操作, 执行完, 立马释放. 这个锁的范围应该尽量小, 然而本例中被扩大了.

标签: none

添加新评论