JDK21虚拟线程死锁问题
JDK21虚拟线程死锁问题
一.背景
1.1 业务背景
某天早上收到推单job不推单短信告警,此项目使用的JDK版本是jdk21,项目中有IO操作的流程使用了JDK21的新特虚拟线程。
1.2 知识背景
线程术语定义
操作系统线程(OS Thread):由操作系统管理,是操作系统调度的基本单位。
平台线程(Platform Thread):Java.Lang.Thread 类的每个实例,都是一个平台线程,是 Java 对操作系统线程的包装,与操作系统是 1:1 映射。
虚拟线程(Virtual Thread):一种轻量级,由 JVM 管理的线程。对应的实例 java.lang.VirtualThread 这个类。
载体线程(Carrier Thread):指真正负责执行虚拟线程中任务的平台线程。一个虚拟线程装载到一个平台线程之后,那么这个平台线程就被称为虚拟线程的载体线程。
虚拟线程定义
JDK 中 java.lang.Thread 的每个实例都是一个平台线程。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内独占操作系统线程,平台线程实例本质是由系统内核的线程调度程序进行调度,并且平台线程的数量受限于操作系统线程的数量。
而虚拟线程(Virtual Thread)它不与特定的操作系统线程相绑定。它在平台线程上运行 Java 代码,但在代码的整个生命周期内不独占平台线程。**这意味着许多虚拟线程可以在同一个平台线程上运行他们的 Java 代码,共享同一个平台线程。**同时虚拟线程的成本很低,虚拟线程的数量可以比平台线程的数量大得多。
虚拟线程原理
虚拟线程是由 Java 虚拟机调度,而不是操作系统。虚拟线程占用空间小,同时使用轻量级的任务队列来调度虚拟线程,避免了线程间基于内核的上下文切换开销,因此可以极大量地创建和使用。
简单来看,虚拟线程实现如下:virtual thread =continuation+scheduler+runnable
虚拟线程会把任务(java.lang.Runnable实例)包装到一个 Continuation 实例中:
-
当任务需要阻塞挂起的时候,会调用 Continuation 的 yield 操作进行阻塞,虚拟线程会从平台线程卸载。
-
当任务解除阻塞继续执行的时候,调用 Continuation.run 会从阻塞点继续执行。
Scheduler 也就是执行器,由它将任务提交到具体的载体线程池中执行。
-
它是 java.util.concurrent.Executor 的子类。
-
虚拟线程框架提供了一个默认的 FIFO 的 ForkJoinPool 用于执行虚拟线程任务。
Runnable 则是真正的任务包装器,由 Scheduler 负责提交到载体线程池中执行。
JVM 把虚拟线程分配给平台线程的操作称为 mount(挂载),取消分配平台线程的操作称为 unmount(卸载):
mount 操作:虚拟线程挂载到平台线程,虚拟线程中包装的 Continuation 堆栈帧数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程。
unmount 操作:虚拟线程从平台线程卸载,此时虚拟线程的任务还没有执行完成,所以虚拟线程中包装的 Continuation 栈数据帧会会留在堆内存中。
从 Java 代码的角度来看,其实是看不到虚拟线程及载体线程共享操作系统线程的,会认为虚拟线程及其载体都在同一个线程上运行,因此,在同一虚拟线程上多次调用的代码可能会在每次调用时挂载的载体线程都不一样。JDK 中使用了 FIFO 模式的 ForkJoinPool 作为虚拟线程的调度器。
jdk21虚拟线程缺陷(绑定平台线程)
1.执行本地方法时会Pinned住载体线程
2.在synchroized方法块时、或者类加载等隐式持有锁场景
二.表象
由Qshedule调度到该机器的任务全部执行失败,由虚拟线程调度的所有任务全部卡死不执行,系统CPU利用率低,物理线程数相较于前一天减少一半,且没有上升趋势。
三.发现
3.1 提交的协程任务数过多?
- 结论:由上图虚拟线程数可知,我们发现真实才创建了314个虚拟线程,远远没有达到系統调度的瓶颈。
3.2 存在线程死锁?
- 结论: 由图可知不存在物理线程的死锁,所以结论不成立。
3.3 存在虚拟线程死锁?
-
猜想:是否存在虚拟线程的死锁,由于虚拟线程的死锁,公司的BAT上是不会展示的,所以需要自己一步一步分析
-
当前时间ForkJoinPool线程池统计信息
-
上一时间段ForkJoinPool线程池统计信息
由上图可知,发生问题的时刻,ForkJoinPool线程池竟然只开了5个线程(夜间指定时间段任务不执行,ForkJoinPool会动态缩容到最小线程数),但是随着虚拟线程任务丢进来并没有重新开新的物理线程来处理这一部分任务,这明显有问题。正常情况下,随着任务数的提交,ForkJoinPool线程池发现没有可用线程会尝试扩容。
- ForkJoinPool线程详细信息
由上图可知ForkJoinPool中所有的线程都阻塞在了虚拟线程上,由上面的知识背景我们知道,此时物理线程可能由于某种原因导致被Pinned住了,虚拟线程无法从载体线程上卸载下来,一直占用着这个载体线程。
- 查看对应虚拟线程栈信息
通过栈信息我们找到相应的代码,循环通过Id变更任务信息,首先看到这个代码的第一眼我就明显感觉不对劲,为什么不批量更新,竟然要一条一条for循环更新?????
通过查看框架源码我们发现在获取Db连接的时候,有通过synchronized加锁操作,相应代码如下
例:两个虚拟线程同时执行lock操作
- 假设虚拟线程1在synchronized块中执行lock操作
public void tryLockInSync() {synchronized (DeadLockTest.class) {lock.lock();try {// do someThing} finally {lock.unlock();}}}
- 假设虚拟线程2正常lock操作
public void tryLock() {lock.lock();try {// do someThing} finally {lock.unlock();}}
}
为了更好的理解这种场景,我们假设底层ForkJoinPool只有一个物理线程,虚拟线程2执行加锁操作,碰到阻塞正常释放载体线程,虚拟线程1挂载到物理线程上执行,进入synchronized块中执行加锁操作,由于虚拟线程2的锁未释放,此时虚拟线程1进入等待且不释放载体线程(由于在synchronized块中执行不会释放载体线程)。待虚拟线程2执行完业务需要释放锁的时候发现没有可用的载体线程就会一直等待,而虚拟线程1又在等待锁的释放。此时虚拟线程1和虚拟线程2相互竞争一个锁资源,且都在等待对方释放,就形成了死锁,导致业务不执行。
结论:通过上面层层证据分析,原来是开启的虚拟线程在获取DB连接的时候遇到了synchronized块,而在获取连接的底层有使用ReentrantLock加锁的阻塞操作,JDK21官方文档有说,如果在中synchronized块遇到了阻塞操作,此时有可能导致物理线程被Pinned住,无法将虚拟线程卸载下来。
四.解决方案
-
升级Pom版本到1.0.8-Java21,框架修复了这个问题
-
添加监控虚拟线程任务延迟监控发送告警
-
尝试为ForkJoinPool主动扩容载体线程
五.总结
通过对虚拟线程和载体线程的深入分析,发现虚拟线程在synchronized块中遇到阻塞操作可能导致物理线程被Pinned住的问题。通过升级框架和增加监控,解决了该问题并提高了系统的稳定性。
参考文档
https://tech.dewu.com/article?id=89