警惕 JDK8 UDP 和 Thread.interrupt 的 Bug

Posted 程序员的店小二

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了警惕 JDK8 UDP 和 Thread.interrupt 的 Bug相关的知识,希望对你有一定的参考价值。

背景

线上业务在热点流量大的情况下(业务采用 Java 编程语言实现),单机偶发出现 Hystrix 熔断,接口无法提供服务。如下图所示:

Hystrix circuit short-circuited and is OPEN# 代表Hystrix在一个窗口时间内失败了N次,发生短路。短路时间默认为 5 秒,5秒之内拒绝所有的请求,之后开始运行。

统计了一下 1 个小时内居然有 5 万多条。

最初怀疑依赖下游的某个 RPC 接口出现问题。但从监控来看,异常时间点多个 RPC 接口异常率都有所上升。所以考虑是自身单机存在什么问题,导致调用依赖的 RPC 接口失败,产生了大量的熔断。

这里还有很重要的一点:在单机出现问题第一时间,摘掉了流量,过半小时后恢复流量,依旧熔断无法恢复

若摘掉流量后,静置一段时间放开流量服务恢复,证明可能是由于流量过载导致的问题,如 JVM 内存不够,GC 回收不过来,频繁 GC。这样的话流量掐掉后,GC 一段时间后内存够用,服务恢复正常。

若摘掉流量后,静置一段时间放开流量服务依旧熔断。在单机出现问题的场景下,很可能是 JVM 内部的问题(包括应用自身问题或者 JVM 的一些问题,如死锁)。

排查

通常排查这种自身问题可从几个方面入手:

  • CPU Load、使用率、内存使用率是否过高;

  • 网卡出入速率是否有抖动,包括 TCP 重传;

  • 磁盘 IOPS 是否过高,导致一些 sys call 阻塞;

  • JVM 是否出现 STW 动作,包括不限于 GC、JIT 等;

  • 是否有 OOM、StackOverflow、死锁等现象;

  • 代码出现 Bug;

  • 虚拟化对应用进行 STW 动作。

根据这些逐一分析。

1. 监控CPU和内存如下图所示

CPU 有明显的抖动,内存使用率正常(18:30 断崖式下滑是对服务进行了上线,可忽略不管)。注意:问题发生在 19:00 以后。可以看到 CPU 在出问题时间段内有明显的波动。

2. 查看监控网卡如下图所示

在 19:00 之后出问题的时间段网卡出入速率略有波动,可忽略。

3. 查看磁盘 IOS 如下图所示

磁盘 IOPS 虽然比较高,但 19:00 前后都比较平均。

4. 查看 gclog 如下图所示

虽然 Young GC 比较频繁,但回收的比例还是比较大,耗时也不高。没有明显的其他原因导致 STW 占用时间长的迹象。

5. 打印线程栈如下图所示

jstack 打出的线程栈就很明显了,最下面直接显示了死锁!

6. Review了最近上线的代码,改动很小没有问题。

7. 由于打印线程栈出现死锁,问题很明显,不用排查虚拟化了。

问题很明显了,JVM 出现了死锁,导致 Hystirx 熔断。
从死锁的日志来看,似乎跟 DNS 相关。难道 Java 的 DNS 有啥 Bug?

分析

在死锁线程栈中很明显地标记出了 pool-486-thread-132 在等 0x00000006b77f2098 这把锁,并且这把锁被 hstirx-*** 线程持有着。同时 hstirx-*** 在等待 0x00000006b77f2128 这把锁,并且这把锁被 pool-486-thread-132 线程持有着。

这俩个线程栈中同时出现了 DNS 和 DatagramChannel 相关的类,证明死锁很大一部分原因是由 DNS 在发 UDP 包的情况下产生了死锁。我们知道死锁是俩个线程对相同临界区资源竞争时,加锁不当产生的。复习下死锁的四个必要条件:

  • 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用;

  • 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放;

  • 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放;

  • 环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。

那我们使用的 DNS 为啥会出现这样的问题?难道是 JDK 或者是 dnsjava(我们使用了dnsjava)的 Bug?

排查 Bug 的最好方法就是阅读源码,我们可以通过死锁的线程栈自底向上逐层跟踪代码。

先看 histirx-*** 的死锁线程栈 :

1. 从业务调用点开始,这里我们的业务向 BatchExecutor(公司内部封装的线程池操作)提交了线程。

2. 本质上调用 AbstractExecutorService.invokeAll 方法,该方法在线程池中的线程执行结束时,finally 会触发 FutureTask.cancel 方法。

3. 由于在调用 cancel 方法对 mayInterruptIfRunning 实参传递的是 true,所以会执行 Thread.interrupt 方法。

4. Thread.interrupt 会对 blockerlock 加锁,根据线程栈,我们看到 intterupt 后会执行 DatagramChannelImpl.implCloseSelectableChannel 方法关闭 UDP 链接。

5. 而 implCloseSelectableChannel 方法要拿到 stateLock 的锁,根据线程栈,此时出现锁等待。

再看 pool-486-thread-132 的死锁线程栈:

1. 这个线程栈比较长,直接从 at org.xbill.DNS.UDPClient.connect(UDPClient.java:107) 开始看。可以从包名看出,这是做 dnsjava 向 DNS server 发起 UDP 请求。

2. connect 方法 通过 DatagramChannel.connect 开启 UDP 连接。

3. DatagramChannel.connect中会锁住readLock、writeLock、stateLock,即线程栈中的:

  • readLock locked <0x00000006b77f20f8> (a java.lang.Object)

  • writeLock locked <0x00000006b77f2110> (a java.lang.Object)

  • stateLock locked <0x00000006b77f2128> (a java.lang.Object)

并锁住 bloackingLock 即线程栈中的 locked <0x00000006b77f20e0> (a java.lang.Object),然后分配 Buffer 并执行 receive 方法。

在 receive 方法并根据线程栈逐层往下,recevie 方法 finally 最后会执行 end 方法。end 方法调用到最后实际上调用了 Thread.blockedOn,bockedOn 需要拿到 blockerLock 锁。此时这个锁 <0x00000006b77f2098> 正在被 histirx-*** 线程持有着。

这个链路看起来,似乎是由一个线程建立 UDP 并发包,一个线程对该线程进行 interrupt,就会产生死锁。

这一点,也有人在 OpenJDK 的 Bug 列表中提出, JDK-8228765。

那么线上为啥会出现 hystrix 线程对另外一个 HTTP 请求正在 DNS 解析线程进行 interrupt 的动作?

这里简单说明下:我们的业务中会通过 BatchExecutor 提交一个异步的业务逻辑,并且超时时间为 300ms。若 300ms 以后未正常返回结果,自然会执行 finally。

由于在异步业务逻辑比较复杂,请求多个 RPC 或其他资源时,若一个请求超过 300ms,其他业务逻辑也会被超时终止,即被 interrupt。当有 DNS 时,便会触发这个 Bug。业务流量大的情况下触发概率比较大。

复现

经过上面的分析并参考 JDK-8228765,我们可以构造 2 个线程,一个发 UDP 包,一个对其进行 interrupt 操作,参考如下代码:

import java.io.IOException;import java.net.InetSocketAddress;import java.net.StandardSocketOptions;import java.nio.channels.DatagramChannel;import java.util.concurrent.ThreadLocalRandom;
public class UDPReceiver 
    public static void main(String[] args) throws IOException, InterruptedException         DatagramChannel receiver = DatagramChannel.open();        receiver.setOption(StandardSocketOptions.SO_REUSEADDR, true);        receiver.bind(new InetSocketAddress("localhost",8000));//监听UDP 8000端口
        UDPSender sender = new UDPSender();        new Thread(sender).start(); //启动一个线程开始发UDP包        while (true)             System.out.println("before interrupt");            sender.interruptThis(); //这里会触发死锁            System.out.println("after interrupt");            Thread.sleep(ThreadLocalRandom.current().nextLong(0,100));            ​​​​
import java.net.InetSocketAddress;import java.net.StandardSocketOptions;import java.nio.ByteBuffer;import java.nio.channels.DatagramChannel;import java.util.concurrent.ThreadLocalRandom;
public class UDPSender implements Runnable     private static final InetSocketAddress target = new InetSocketAddress("localhost", 8000);    private Thread thisThread;

    public void interruptThis()         if (null != thisThread)             thisThread.interrupt(); //引发死锁的源头2            
    @Override    public void run()         thisThread = Thread.currentThread(); //保存一下sender的当前线程        while (true)             try                 DatagramChannel sender = DatagramChannel.open();                sender.setOption(StandardSocketOptions.SO_REUSEADDR, true);                sender.connect(target); //连接本地8000端口发包                System.out.println("sending...");                sender.send(ByteBuffer.allocate(100).putInt(1), target);//引发死锁的源头1                System.out.println("sending success...");                Thread.sleep(ThreadLocalRandom.current().nextLong(0, 100));             catch (Exception e)                         
            

如下图所示,在跑几轮后 before interrupt 的时候出现死锁。

打印线程栈,最后可看到死锁信息。

jstack -l <pid>

线程 Thread-0 等待的锁对象 0x000000076b0f9668 由主线程在执行 UDPSender.interruptThis 时锁住,即对 Thread-0 线程进行 interrupt。根据 Thread.interrupt 方法的描述,若当前线程处于 IO 方法阻塞时,通道会被关闭,会抛出 ClosedByInterruptException 异常并设置中断状态为 true。

死锁的整个过程如下:先看 new Thread(sender).start() 即 Thread-0 的执行。
可以看死锁的线程栈调用链路执行情况,参考代码推导:

1. DatagramChannelImp l实现了AbstractInterruptibleChannel。在DatagramChannelImpl.send 方法中会拿到两把锁,注意其中一把为 stateLock,即线程中的 - locked <0x000000076b1e2be8> (a java.lang.Object)。

2. 在 write 方法中,可以看到调用 this.begin 方法,即 AbstracInterruptibleChannel.begin。在 IOUtil.write 真正发包完成后,会执行 finally 中的 this.end 方法。

AbstracInterruptibleChannel.begin 注册了收到 interrupt 后要执行的逻辑,注意这里,下面会用。

3. 在 AbstractInterruptibleChannel.end 方法中通过 blockedOn 方法标记 block 状态。

4. blockedOn 会最终调到 Thread.blockedOn 方法,且实参为当前 Thread-0 线程。注意这里,要拿到 blockerlock,即线程栈中的 - waiting to lock <0x000000076b0f9668> (a java.lang.Object)。

再看 sender.interruptThis() 的执行:可以看死锁的线程栈调用链路执行情况,参考代码推导。

1. 在 Thread.interrupt 方法中会调用 b.interrupt,注意这里会拿到 blokcerLock,即线程栈中的 - locked <0x000000076b0f9668> (a java.lang.Object)。

2. b.interrupt 会触发 AbstracInterruptibleChannel 在 begin 方法中实现的 interrupt 方法。

3. 通过 AbstracInterruptibleChannel.implCloseChannel 关闭通道。

4. 在 DatagramChannelImpl.implCloseChannel 的实现中,要拿到 stateLock 这把锁。注意这里就是线程栈中的 - waiting to lock <0x000000076b1e2be8> (a java.lang.Object)。

从上面的分析可以很明显的看出,主线程触发 Thread-0 的 interrupt 动作时,在执行 interupt 方法持有了 blockerLock 锁。关闭通道要拿 stateLock 锁,而 Thread-0 在发包时先拿到了 stateLock 锁,并且设置 block 状态时要拿 blockerLock 锁,此时出现死锁!

解决方法

根据 OpenJDK 的 Bug 列表中的说明 JDK-8228765 ,这个 Bug 从 JDK-8039509 就开始有了,一直到 JDK13 才修复。所以,要么升级 JDK 到 13,要么死!我选择了死。

我们项目中使用了 dnsjava,在 dnsjava的 Github上 issues#69 里也是有报这个 Bug的。另外在 logstash 中也有类似的问题 issues#117。

当然如果选择了死,那就是遇到了重启服务器了。

总结

JVM 的 Bug 也很多,同时也很诡异,需要 仔细观察运行时状态 + 阅读源码 才能分析出原因,如果你不想分析原因,可以用 Google 搜搜看,很可能已经有了答案。

排查问题思路很重要,由于业务环境通常比较复杂,在大流量、大数据量的情况下会有各种千奇百怪的问题,需要有足够的耐心和较广的知识面。

转自:bd7xzz,

链接:blog.csdn.net/kid_2412/article/details/128125053

以上是关于警惕 JDK8 UDP 和 Thread.interrupt 的 Bug的主要内容,如果未能解决你的问题,请参考以下文章

线程的停止与暂停

警惕javascript变量的全局污染

xise菜刀千万不要随便下载!(警惕!)

警惕 NFT 中可能存在的骗局

张小龙内部演讲:警惕 KPI 和流程

警惕!不要在泡沫破灭前当了“接盘侠”