JDK21的 虚拟线程 目前存在许多严重bug,尽量不要在可靠性要求较高的服务中使用!
简单介绍
虚拟线程使用一个专用的,FIFO模式下的ForkJoin线程池(parallelStream使用的是LIFO模式)。
它的并行度就是承载虚拟线程的平台线程数,默认与可用核心数相等,也可以使用参数 jdk.virtualThreadScheduler.parallelism 来调整。
部分实现细节
[JVMLS][精翻+个人补充说明]虚拟线程Continuation实现原理 - 原视频 https://inside.java/2023/08/26/continuations-under-the-covers/
推荐资料
以下资料主要是对java虚拟线程的设计和思考,具体实现以jdk为准
https://cr.openjdk.org/~rpressler/loom/Loom-Proposal.html
https://cr.openjdk.org/~rpressler/loom/loom/sol1_part1.html
https://cr.openjdk.org/~rpressler/loom/loom/sol1_part2.html
https://inside.java/2021/05/10/networking-io-with-virtual-threads/
https://inside.java/2020/08/07/loom-performance/
https://blogs.oracle.com/javamagazine/post/going-inside-javas-project-loom-and-virtual-threads
如果对异步发展史感兴趣,可以观看 【[Devoxx2023]深入理解Java异步编程发展历史-从线程到响应式再到虚拟线程】 - 原视频 https://youtu.be/1zSF1259s6w?si=tJGrNK-Uh6Hgw4B9
风险
虚拟线程支持ThreadLocal和synchronized,但是存在一些缺陷,有一些问题需要我们额外注意
1.绑定问题
虚拟线程执行时 在
synchronized块或方法中执行代码时,会 与平台线程绑定(同步块内,以及等待同步锁都会pin),因此需要使用 ReentrantLock替代(未来有望从底层解决-目前已经有相关JEP,会在JDK24上 https://openjdk.org/jeps/491 )虚拟线程执行native代码或外来函数( JEP424 )时是无法被中止的(无法从底层解决,目前典型的是类初始化)。
故障实例
1[P1].死锁(这块风险在JEP中实际上被低估了)
1.最经典场景(大部分死锁本质都是这类问题)
这个问题触发概率高,而且影响大,目前已经有许多第三方开源组件都出现了这个问题
https://www.reddit.com/r/java/comments/1512xuo/virtual_threads_interesting_deadlock/ (这里的分析实际上有点问题,但是无伤大雅,下面会说到)
https://mail.openjdk.org/pipermail/loom-dev/2023-July/005993.html
复现代码
1 | for (int i = 0; i < Runtime.getRuntime().availableProcessors() + 10; i++) { |
原理分析
大体原理可以参考上面的这个讨论内容
https://www.reddit.com/r/java/comments/1512xuo/virtual_threads_interesting_deadlock/
代码可以简化为
1 | for (int i = 0; i < Runtime.getRuntime().availableProcessors() + 1; i++) { |
但是帖子里说到:
这里的分析略有偏差。如果 [#30]能得到锁的话,说明已经开始运行了,如果它没有yield让出平台线程的话应该是会执行到释放锁逻辑的,所以这个说法应该是不太准确的。
这里我们取这两个阻塞在System.out.println方法上的线程进行分析,即[#26](阻塞在同步代码块内部),[#30](阻塞在同步代码块外部)。
Lock内部实现的AQS使用 FIFO 队列来进行线程的排队进行资源抢占(对于已经在队列中的线程来说无论是公平锁还是非公平锁都是遵循FIFO的)。
这个死锁的场景实际上是FIFO队列中, [#30]排在[#26]前面,而当有一个线程执行完同步块外部的方法释放锁时,会将队列前面的Node,即[#30]进行unpark,然后自身马上停靠在synchronized同步块上被绑定
此时其他虚拟线程都在pinned状态,占用了所有平台线程(假设8核,即7个线程阻塞在同步块的锁上,1个线程阻塞在同步块内部的锁上)
因此这个unpark是无效的,[#30]此时没有平台线程可以拿来绑定,因此也就无法上锁,而[#26]又由于排在[#30]后面而无法得到unpark通知,因此死锁。
对于以上分析,我们可以通过debug来佐证
1.首先验证锁的情况,可以看到锁的确是并没有被抢占到的
2.当前AQS队列中#32排在#25前面,而#32阻塞在同步块外部的lock上,而#25阻塞在同步块内部的lock上,其他虚拟线程都在同步块上被绑定着
vt.json
如果摒弃ReentrantLock的影响,实际状态可以简化为类似以下代码情况
下面情况需要只有一个载体线程,即 -Djdk.virtualThreadScheduler.parallelism=1,另外需要模块化参数–add-opens java.base/jdk.internal.misc=ALL-UNNAMED
1 | import jdk.internal.misc.VirtualThreads; |
运行后输出应该如下,vt1被unpark也无法执行到vt1state++的代码
相关问题
https://bugs.openjdk.org/browse/JDK-8320211
https://www.javaspecialists.eu/archive/Issue302-Virtual-Thread-Deadlocks.html
https://www.reddit.com/r/java/comments/17xkkoc/java_virtual_threads_pitfalls_to_look_out_for/
conf相关问题(conf上面这几个问题实际上也类似上面提到的)
2.单线程的死锁(更简单)
一个简化场景是单线程调度器 vt1拿到了lock A然后遇到io让出 vt2拿到sync然后去拿lock A pin住
3.Native或FFI导致的死锁
这个可能比较难发生,但是与同步块的死锁是同理的
例如在类加载内与类加载外存在ReentrantLock,或者其他资源的抢占,就可能发生。
2[P2].平台线程资源耗尽
How we switched to Java 21 virtual threads and got a deadlock in TPC-C for PostgreSQL
https://news.ycombinator.com/item?id=39008026
这个问题源于线程的pinning,需要注意。
2.ThreadLocal
ThreadLocal的问题主要在于线程模型的变化
没有虚拟线程前我们可以假定线程数不会太多,但是虚拟线程打破了这个限制,一个系统可能同时存在非常多虚拟线程
因此ThreadLocal的数据量就可能非常非常多,这也就是ThreadLocal的风险所在了。用的时候注意一下就好。
这里cue一下ScopedValue,有些人可能期望使用ScopedValue完全替代ThreadLocal,其实这是不现实的。
最典型的就是ThreadLocal用于资源池化的场景,例如jackson中和log4j2中都有这种用法。例如 log4j2的ReusableMessage,现在老实换成普通资源池了 https://github.com/apache/logging-log4j2/pull/1401 。
故障实例
1.[P2].线程变量使用不当导致OOM
但是这个其实跟 ThreadLocal关系不大。。。。
监控
可以使用JFR来观察虚拟线程的 在绑定时的挂起情况
jdk.VirtualThreadStart 和 jdk.VirtualThreadEnd
记录了虚拟线程的启动和结束,默认关闭
jdk.VirtualThreadPinned
表示虚拟线程绑定在平台线程上时,如果阻塞那么会产生这个事件(即没有释放其平台线程),阈值为20ms,默认开启
jdk.VirtualThreadSubmitFailed
启动或取消unparking虚拟线程失败,默认开启
https://openjdk.org/jeps/425#JDK-Flight-Recorder-JFR
2.【推荐】JFR EventStream
由于 Djdk.tracePinnedThreads本身会导致pin线程(下面会讲到),因此JFR是我们监控pin线程的首选方案
但是如果使用通过命令行的方式使用JFR会存在以下问题
1. dump文件较大,获取监控数据不方便,难以一次获取长期分析数据
2. dump文件需要上机器控制台dump文件,比较麻烦
3. 一次只能查看单机数据
而如果我们自己去解析JFR文件实际上也能做到持续监控,但是会很麻烦
因此使用JFR event stream来解决这个问题
JFR的事件流是jdk14引入的特性 JEP 349: JFR Event Streaming
这边用的是封装过的监控埋点组件,替换成自己的就好
JFR event stream
1 | import com.google.common.collect.Maps; |
需要加上以下参数
1.maven-surefire-plugin 中加上
--add-opens jdk.jfr/jdk.jfr.internal.tool=ALL-UNNAMED
2.maven-compiler-plugin 中加上
<compilerArgs> <arg>–add-exports</arg> <arg>jdk.jfr/jdk.jfr.internal.tool=ALL-UNNAMED</arg> </compilerArgs>
--add-exports jdk.jfr/jdk.jfr.internal.tool=ALL-UNNAMED
这个事件的触发条件是:线程在pinned情况下出现park操作的耗时超过duration(默认20ms)
当然,这样依然会有问题存在
1.在JFR启动之前,可能有一些其他的pin事件发生了,例如一些类加载,会被忽略掉
2.JFR的启动在premain之后,也就是说对于启动期间静态agent premain操作中的pin事件我们也是不知道的
对于问题1,我们可以使用命令行启动JFR的方式解决,随后去机器上dump启动期间的jfr记录就好。或者我们可以自定义一个agent,确保jfr监控在其他类加载之前启动。
问题2目前是无解的,这是在JVM里写死的
当然,如果启动期间有严重问题会直接导致机器启动不起来,并不会造成太大的影响。
3. jdk.tracePinnedThreads ( 只能在非生产使用! )
当线程固定在平台线程上阻塞时
-Djdk.tracePinnedThreads=full 打印完整的堆栈跟踪,并突出显示本机帧和持有锁的帧。
-Djdk.tracePinnedThreads=short 输出限制为仅有问题的帧。
目前存在以下两个严重bug
bug1(deadlock): https://bugs.openjdk.org/browse/JDK-8322846
这个问题实质上就是lock和sync的嵌套导致的死锁问题, bug1已经在jdk21.0.4修复
解决
可以尝试使用- Djdk.io .useMonitors=true来将内部的ReentrantLock替换成使用synchronized,但是这样有饮鸩止渴的意味,不推荐
bug2(假死问题,hang住虚拟机): https://bugs.openjdk.org/browse/JDK-8325521
这里对bug2展开说说
- 根因
JVM里的JVMTI存在bug,jdk20时就有相关pr( https://github.com/openjdk/jdk/pull/10321 ) ,应该是漏了部分逻辑,将会在23以后完成修复,原因如下
触发条件
1.开启了参数-Djdk.tracePinnedThreads;
2.使用某个内部 agent增强了Runnable;
3.在某个内部 agent attach之前没有触发过java.lang.PinnedThreadPrinter类里lambda表达式的加载
- 解决(如果不想关闭 -Djdk.tracePinnedThreads )
1.关闭JVM里对JVMTI事件的处理 -XX:-DoJVMTIVirtualThreadTransitions
2.在某个内部 agent attach之前自己手动触发一次pin
- 复现代码(参数 -Djdk.attach.allowAttachSelf=true -Djdk.tracePinnedThreads=full )
hang JVM复现代码
1 | package org.example; |
解决措施
最好的解决方案是官方提供synchronized对虚拟线程的支持(在jdk24中已经实现了适配 JEP 491 )
尽量从synchronized迁移到ReentrantLock
自动档
1 | <dependency> |
以及以下依赖:
具体如何更好地适配
1.可以参考 https://dzone.com/articles/relearning-java-thread-primitives ,更优雅地使用Lock进行替换
2.对于ConcurrentHashMap这类使用ReentrantLock替换需要付出代价(需要在Node上加lock属性,会导致内存膨胀)的场景,可以考虑使用ConcurrentSkipListMap替换(性能略差,但是是无锁的线程安全HashMap实现)
3.由于JFR的event并不包含持有Monitor的帧( https://bugs.openjdk.org/browse/JDK-8314591 ),因此上线前的排查阶段使用 -Djdk.tracePinnedThreads排查比较方便
排查时需要关注的场景
1.sync块内外出现了同一资源的竞争,典型的例如上面的sync块内外出现了对同一lock的抢占,这种可能造成死锁
2.sync里面有park但是无内外资源竞争的,这种只会影响性能
3.sync里面没有park操作,这种情况无竞争的情况下无影响,有竞争的情况下会导致性能变差(通过JFR事件jdk.JavaMonitorEnter来观察)
4.其他绑定问题(Native或FFI代码,例如类加载场景)
TIPS
https://www.javaperformancetuning.com/news/newtips274.shtml
虚拟线程不再需要池化,虚拟线程是非常轻量的,随用随建就行。
但是线程池 部分思想依然有效,例如使用线程池的思想控 制访问资源的并发量。
这这种场景线程池内部可能用Semaphore简单地控制并发量,而不需要有复杂的线程管理逻辑
也就是说虚拟线程池的目的将不再包含资源的复用,而应该只包含一些更轻量的附加控制
其他
线程转储
在jdk21中使用jstack -l [pid] > [file]是拿不到虚拟线程的
使用idea中的线程转储也获取不到虚拟线程
原因:虚拟线程数量非常多,原本扁平化的数据结构无法很好地组织线程之间的结构关系,引入结构化并发后更是如此
方案:
提供json结构来输出包含虚拟线程的堆栈转储,而不会在原来的线程转储输出中包含虚拟线程
1 | jcmd <pid> Thread.dump_to_file -format=json <file> |
参考