JDK21虚拟线程僵死、调查及展望

01

虚拟线程诞生的背景

1.1 背景

说起虚拟线程,不得不提一下Java现有的线程模型,Java的线程模型基于操作系统线程(即“平台线程”)。每个线程与操作系统的调度器直接挂钩,线程的创建和销毁往往需要较大的开销。随着并发处理需求的激增,基于线程池和事件驱动的异步编程模型逐渐成为解决高并发问题的主流方式。

1.2 线程池的局限性

线程池是通过维护一组线程来处理任务,减少了频繁创建和销毁线程的性能开销,但在高并发场景下仍可能存在瓶颈:

  • 线程数量的限制:线程池中的线程是有限的,当请求数过多时,线程池可能无法满足所有请求,造成任务的阻塞;

  • 线程的上下文切换:线程池中的线程需要频繁地进行上下文切换,在高并发的情况下,操作系统需要花费大量的时间来管理线程的调度,导致性能的下降。

1.3 高并发的需求

随着微服务架构的普及以及对高并发处理能力的需求,很多业务开始采用Reactive编程模型,例如RxJava、Project Reactor等。这些编程框架强调通过事件驱动、非阻塞I/O来处理大量并发请求,极大提高了处理效率,但这依然存在以下问题:

  • 回调地狱和复杂性:大量的异步回调容易导致代码难以理解和维护,尤其是在处理复杂业务逻辑时;

  • 学习曲线:Reactive编程需要掌握新的概念(如背压、调度器等),对于传统同步编程的开发者来说,学习和理解的难度较大;

  • 调试困难和性能开销:由于涉及异步操作和多线程调度,调试变得更加复杂,额外的抽象层也可能带来性能损失,影响响应速度。

为了克服上述问题,JDK引入了虚拟线程,它通过轻量级线程模型来有效解决高并发问题。

02

虚拟线程的特点

Java的虚拟线程项目-Loom早在2017年即作为实验性项目进行开发,在历经JDK 19和20的预览版后,终于于2023年09月19日在JDK 21 LTS版本中正式发布了。

虚拟线程是一种新型线程,它是为了简化并发编程而设计的。与传统的线程不同,虚拟线程并不是直接由操作系统调度的,而是由JVM调度和管理,通过更高效的调度机制,使得数百万的线程也能在单台机器上高效运行。它具有如下特性:

  • 轻量级:每个虚拟线程的内存消耗非常小,相比传统线程,它们能够在短时间内被快速创建和销毁。官方建议虚拟线程不应池化,每次执行任务都可以创建一个新的虚拟线程,例如仅执行一次HTTP调用或JDBC查询;

  • 非阻塞I/O:虚拟线程适合用于处理大量的并发任务,尤其是在需要非阻塞I/O操作时,它们能够在等待I/O时挂起,并允许JVM将CPU时间分配给其他任务,从而提高了吞吐量;

  • JVM调度:JVM通过ForkJoin线程池来调度虚拟线程,并且会根据需要将虚拟线程挂起或恢复,从而避免了平台线程切换带来的开销;

  • 简化并发编程:虚拟线程本质上是同步代码的扩展,使用虚拟线程可以像编写串行代码一样编写并发代码,避免了线程池和Reactive编程的复杂性。

虚拟线程有助于提高应用程序的吞吐量,同时降低代码复杂度,增强维护性和扩展性。

03

虚拟线程的试用

由于我们的应用是基于SpringCloud平台开发的,为了保证兼容性和稳定性,希望以最小的改动来试用虚拟线程。经过调查和测试,如下是支持JDK21的无漏洞的最低版本:

spring-boot:2.6.15
spring-cloud:2021.0.8

另外,只针对spring-boot内嵌的web容器Tomcat开启虚拟线程,配置如下:

@Configuration
public class TomcatProtocolHandlerConfiguration {
    @Bean
    public TomcatProtocolHandlerCustomizer protocolHandlerCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

上述代码中使用虚拟线程的核心代码是Executors.newVirtualThreadPerTaskExecutor(),进行上述配置后Tomcat即可使用虚拟线程处理请求。部署好进行流量压测,发现应用竟然僵死了。

3.1 服务器现场调查

由于应用在本地可以正常运行和访问,而部署到服务器压测会出现问题,所以需要到服务器上调查。

但是不幸的是,使用JDK的诊断工具执行都无反应,初步怀疑是整个应用僵死导致无法获取诊断信息。

3.2 日志调查

经过仔细调查系统日志,发现了如下可疑的地方:

这个是当虚拟线程被固定在平台线程上时打印的,JVM在发现这种情况时会打印被固定的线程栈。

在这里需要介绍一下什么是虚拟线程的固定

当使用虚拟线程运行代码时,JVM会将虚拟线程挂载到平台线程进行执行。
JDK中的绝大多数阻塞操作都会卸载虚拟线程,使平台线程能挂载其他虚拟线程运行。
然而,有以下两种情况,在虚拟线程阻塞时,它会被固定在平台线程上:
1 执行synctronized块内的代码
2 执行native方法或外部函数
当虚拟线程固定在平台线程时,挂载了虚拟线程的平台线程就不能再挂载其他的虚拟线程了。

如果有大量的虚拟线程被固定在平台线程上,势必会妨碍系统的扩展性,为了能够监控这种情况,JDK提供了-Djdk.tracePinnedThreads=full参数,以便能够发生固定时打印线程栈。

3.3 真相浮出水面

经过大量调查后,这个参数让我们产生了疑问,随后以pinned为关键字搜索虚拟线程僵死的问题,结果发现了如下Bug:

https://github.com/openjdk/jdk/pull/17221: Running with -Djdk.tracePinnedThreads set can hang

导致JVM僵死的是这个参数:-Djdk.tracePinnedThreads=full,去除这个参数后,僵死问题果然消失了。

在查看了该Bug的修复代码后,发现核心的逻辑竟然仅仅是将synchronized替换为了ReentrantLock,如下:

但是按照官方的说法,虚拟线程固定应该仅仅影响应用扩展性,参考JEP 444,官方描述如下:

固定不会使应用程序不正确,但可能会妨碍其可扩展性。
如果虚拟线程执行阻塞操作(例如 I/O)时被固定,则其载体和底层操作系统线程在操作期间将被阻塞。
应该将频繁运行synchronized的代码修改为ReentrantLock来避免长期的固定。

而现实情况是使用synchronized后,会导致系统僵死,与官方描述不符。并且synchronized自从Java诞生以来就是同步编程使用的关键字,如果把所有synchronized的代码替换成ReentrantLock,显然不切实际。

所以需要深入研究一下虚拟线程固定导致僵死的本质原因。

04

虚拟线程僵死的本质原因

4.1 僵死案例

跟该Bug相关的还有其他僵死的案例,在深入研究了相关代码后,发现了导致僵死的本质原因:一种死锁的变种-平台线程饥饿导致的死锁

这里列举一个尽量简单的简化代码:

public class Test {
    public static void main(String[] args) throws Exception {
        TestRunnable testRunnable = new TestRunnable();
        // 1.先启动一个虚拟线程抢占锁
        Thread.ofVirtual().name("unpinned").start(testRunnable);
        // 2.由于JVM采用CPU核数个平台线程调度虚拟线程,所以这里启动的虚拟线程数为CPU核数
        for (int i = 0; i < Runtime.getRuntime().availableProcessors(); i++) {
            Thread.ofVirtual().name("pinned-" + i).start(() -> {
                // 3.这里的锁是为了将虚拟线程固定在平台线程上
                synchronized (Test.class) {
                    testRunnable.run();
                }
            });
        }
        Thread.sleep(10 * 1000);
    }

    static class TestRunnable implements Runnable {
        ReentrantLock lock = new ReentrantLock();

        public void run() {
            try {
                lock.lock();
                Thread.sleep(10);
            } finally {
                lock.unlock();
            }
        }
    }
}

这里先解释一下上述代码的目的:

   - unpinned命名的虚拟线程获得ReentrantLock锁后,执行sleep时会被平台线程卸载,使平台线程可以运行其他虚拟线程;

   - pinned-*命名的虚拟线程由于synchronized而被固定在平台线程上。

运行上述代码僵死的情况必现,但是把Thread.ofVirtual()替换为Thread.ofPlatform(),即虚拟线程替换为普通的线程,则不会出现僵死的情况。

其实如下代码都可能会出现僵死,只是概率小一点:

for (int i = 0; i < Runtime.getRuntime().availableProcessors() + 1; i++) {
    Thread.startVirtualThread(() -> {
        System.out.println("1");
        synchronized (Test.class) {
            System.out.println("2");
        }
    });
}

因为只要涉及到synchronized的代码,都有可能出现僵死,下面分析一下原因。

4.2 线程栈分析

当僵死后,通过jstack和jcmd打印线程栈,经过分析,列出简要的线程栈如下:

   - JVM的ForkJoinPool调度线程(即“平台线程”)栈类似如下:

"ForkJoinPool-1-worker-8" #39 [7256] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=36.13s tid=0x00000285fb3fc980  [0x000000bf9bcfe000]
   Carrying virtual thread #33
 at jdk.internal.vm.Continuation.run(java.base@21/Continuation.java:248)
 at java.lang.VirtualThread.runContinuation(java.base@21/VirtualThread.java:223)
 at java.lang.VirtualThread$$Lambda/0x0000028581050af0.run(java.base@21/Unknown Source)
 at java.util.concurrent.ForkJoinTask$RunnableExecuteAction.exec(java.base@21/ForkJoinTask.java:1423)
 at java.util.concurrent.ForkJoinTask.doExec(java.base@21/ForkJoinTask.java:387)
 at java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(java.base@21/ForkJoinPool.java:1312)
 at java.util.concurrent.ForkJoinPool.scan(java.base@21/ForkJoinPool.java:1843)
 at java.util.concurrent.ForkJoinPool.runWorker(java.base@21/ForkJoinPool.java:1808)
 at java.util.concurrent.ForkJoinWorkerThread.run(java.base@21/ForkJoinWorkerThread.java:188)

Carrying virtual thread #39可以看出平台线程挂载了虚拟线程39正在运行。

上面类似的ForkJoinPool的线程栈共有8个(运行实例代码的机器为8核),也就是说所有的平台线程正挂载着虚拟线程。

   - 名字为unpinned的虚拟线程栈类似如下:

{
   "tid": "22",
   "name": "unpinned",
   "stack": [
      "java.base\/java.lang.VirtualThread.parkNanos(VirtualThread.java:631)",
      "java.base\/java.lang.VirtualThread.sleepNanos(VirtualThread.java:803)",
      "java.base\/java.lang.Thread.sleep(Thread.java:507)",
      "Test$TestRunnable.run(Test.java:27)",
      "java.base\/java.lang.VirtualThread.run(VirtualThread.java:311)"
   ]
 }

该线程正在执行Thread.sleep(10),等待时间到后被唤醒。

   - 名字为pinned-0的虚拟线程栈类似如下:

{
   "tid": "25",
   "name": "pinned-0",
   "stack": [
      "java.base\/jdk.internal.misc.Unsafe.park(Native Method)",
      "java.base\/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:673)",
      "java.base\/java.lang.VirtualThread.park(VirtualThread.java:603)",
      "java.base\/java.lang.System$2.parkVirtualThread(System.java:2639)",
      "java.base\/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)",
      "java.base\/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)",
      "java.base\/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)",
      "java.base\/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)",
      "java.base\/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)",
      "java.base\/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)",
      "Test$TestRunnable.run(Test.java:25)",
      "Test.lambda$main$0(Test.java:13)",
      "java.base\/java.lang.VirtualThread.run(VirtualThread.java:311)"
   ]
 }

根据VirtualThread.parkOnCarrierThread可以看出,此虚拟线程由于争夺ReentrantLock正在park,但是由于synchronized被固定在了平台线程上,对应JDK源码如下:

   - 名字为pinned-1~7的虚拟线程栈类似如下:

{
   "tid": "33",
   "name": "pinned-7",
   "stack": [
      "Test.lambda$main$0(Test.java:13)",
      "java.base\/java.lang.VirtualThread.run(VirtualThread.java:311)"
   ]
 }

pinned-1~7的虚拟线程都在等待synchronized锁。

从上述线程栈可以看出核心的地方在于竞争锁的过程:

   - unpinned的虚拟线程获得了ReentrantLock锁,之后进行sleep;

   - pinned-0的虚拟线程获得了synchronized锁,由于需要等待ReentrantLock锁,故执行了park;

   - pinned-1~7的虚拟线程在等待synchronized锁。

这里用下图来描述一下整体的过程:

  • 第①步中,unpinned线程获得ReentrantLock锁后,正在sleep;

  • 第②步中,pinned-0线程获得synchronized锁;

  • 第③步中,pinned-0线程尝试获取ReentrantLock锁,但是unpinned线程正在持有,所以pinned-0线程进行了park;

  • 第④步中,pinned-1~7的线程尝试获取synchronized锁,但是该锁已被pinned-0线程获取,故都进入block状态;

  • 第⑤步中,平台线程ForkJoinPool-1-worker-1~8均挂载了虚拟线程,但是由于synchronized的原因,即使虚拟线程已经park了,也无法卸载。

当unpinned线程醒来后,需要被调度到平台线程继续运行,但是没有空闲的平台线程,由于平台线程饥饿导致的死锁便出现了!

这里提到没有空闲的平台线程,可是之前说的JVM的ForkJoinPool调度线程栈类似如下:

"ForkJoinPool-1-worker-8" #39 [7256] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=36.13s tid=0x00000285fb3fc980  [0x000000bf9bcfe000]
   Carrying virtual thread #33
 at jdk.internal.vm.Continuation.run(java.base@21/Continuation.java:248)
 at java.lang.VirtualThread.runContinuation(java.base@21/VirtualThread.java:223)

上面的线程栈只是显示Carrying virtual thread #33,并没有显示线程状态,怎么知道没有空闲的平台线程呢?

确实是这样,JDK 21的调度线程比较特殊,无论是使用jstack -l还是jcmd Thread.dump_to_file -format=json都无法打印出调度线程的状态,甚至明明知道线程在等待synchronized锁,可是却无法输出等待锁的信息。

不过可以通过Arthas可以获取调度线程的状态,如下:

跟预料的一致,调度线程中,除了ForkJoinPool-1-worker-1在WAITING,其余的都被BLOCKED了,确实没有空闲的了。

下面再看一下AQS(AbstractQueuedSynchronizer)等待队列的情况,以便进一步明确上面的推断。

4.3  内存分析

得益于强大的MAT(Eclipse Memory Analyzer Tool),使得分析内存对象变得容易,这里获取到了代码中的TestRunnable对象的引用如下:

从上图可以看出,TestRunnable对象持有一个ReentrantLock对象,这里分别说一下AQS等待队列中的对象:

   - 由 ① exclusiveOwnerThread可知,它就是代码中的name为unpinned虚拟线程,由于该线程在执行sleep代码,所以它已经从平台线程卸载,所以carrierThread为null,目前该虚拟线程持有锁;

   - 由 ② head可知,在AQS队列中它是第一个节点(对应unpinned虚拟线程),它的status为0,waiter为null,即没有在等待锁;

   - 由 ③ next可知,在AQS队列中它是第二个节点(由waiter属性可知对应pinned-0虚拟线程),它的status为1,表示在等待锁。

其中head节点(unpinned线程)sleep完毕后,应该执行unlock,被移出队列,以便pinned-0线程执行,但是不幸的是,由于没有空闲的平台线程,它永远无法被移出,这就跟之前的线程栈分析对上了。

由于JVM默认的调度并行度就是cpu核数,如果虚拟线程固定在平台线程上,很容易出现平台线程不够用的情况,甚至出现线程饥饿导致的死锁。

总结:这种虚拟线程固定导致的调度线程饥饿进而引起的死锁问题,并不能通过扩大调度线程数彻底解决

05

虚拟线程为什么需要固定

根据Java官方的解释,之所以虚拟线程遇到synchronized不能从平台线程卸载,跟synchronized的实现有关。

具体为synchronized是通过监视器来实现的,每个Java对象都与一个监视器关联。同一时刻只有一个线程可以持有锁对象的监视器。而JVM会追踪当前哪个线程持有锁对象的监视器,但是JVM追踪的是平台线程,而非虚拟线程。

如果平台线程在synchronized块内卸载了虚拟线程,JVM会将其他虚拟线程调度到该平台线程,而该平台线程正在持有锁对象的监视器,那么其他虚拟线程就可以执行synchronized块内的代码,互斥就失效了。

具体底层原因跟JVM的锁实现有关,可以参见Java's Virtual Threads - Next Steps:https://www.youtube.com/watch?v=KBW4LbCoo6c。

在上述视频中,Loom团队为了解决固定情况已经制定了解决方案。

06

虚拟线程固定最新进展

6.1 固定问题解决

JEP 491中,Java官方宣称已经解决了synchronized引起虚拟线程的固定问题,具体更改如下:

他们更改了synchronized关键字的实现,以便虚拟线程可以独立于平台线程获取,保留和释放监视器,并且支持在等待监视器时释放平台线程,以便平台线程可以挂载其他虚拟线程。当监视器被释放时,重新挂载虚拟线程,以恢复执行并再次尝试获取监视器。

该JEP将于2025年3月18日,包含在JDK 24的发布中。

我使用JDK 24的体验版本测试,虚拟线程固定导致僵死的情况确实消失了,并且确实如官方所说,虚拟线程在等待synchronized锁时,会释放平台线程。

JEP 491描述中,关于JEP 444中使用ReentrantLock替换synchronized的描述也变了,参见如下:

如果您正在编写新代码,我们同意《Java 并发实践》第 13.4 节中的建议:
synchronized在切实可行的情况下使用,因为它更方便且更不容易出错,而ReentrantLock在需要更多灵活性时使用。
无论哪种方式,都应通过缩小锁定范围来减少争用的可能性,并尽可能避免在持有锁定时执行 I/O 或其他阻塞操作。

另外,除了synchronized外,一些类加载或初始化时也会导致固定,但是这些情况目前很少引起问题,如果将来被证明有问题,官方会重新审视它们。

6.2 前景展望

目前最新的JDK LTS版本还是JDK 21,其将支持到2031年,而一些业务已经升级到JDK 21,那么JEP 491是否会合并到JDK 21中呢?

由于JEP 491基于了破坏JDK 19-22兼容性的提议实现的,也就是说合并到JDK 21可能比较困难。

不过好消息的是,JDK发版很快,根据发布计划,下一个LTS版本马上也就发布了:

最后总结,任何事物生来都是不完美的,只有在实践中不断地调整与完善,才能实现真正的成熟与卓越。

07

参考文献

  1. JEP 444: https://openjdk.org/jeps/444

  2. JEP 491: https://openjdk.org/jeps/491

  3. jdk.tracePinnedThreads导致僵死:https://github.com/openjdk/jdk/pull/17221

  4. jdk.tracePinnedThreads修复:https://github.com/openjdk/jdk22/commit/3017281956f3c8b50f064a75444c74a18d59e96d

  5. 虚拟线程+c3p0导致僵死:https://blog.ydb.tech/how-we-switched-to-java-21-virtual-threads-and-got-deadlock-in-tpc-c-for-postgresql-cca2fe08d70b

  6. 虚拟线程+logback导致僵死:https://jira.qos.ch/browse/LOGBACK-1711

  7. 虚拟线程+系统输出导致僵死:https://www.reddit.com/r/java/comments/1512xuo/virtual_threads_interesting_deadlock/

  8. OpenJdk Mail list:https://mail.openjdk.org/pipermail/loom-dev/2023-July/

  9. Java's Virtual Threads - Next Steps:https://www.youtube.com/watch?v=KBW4LbCoo6c

  10. 破坏JDK 19-22兼容性提议:https://bugs.openjdk.org/browse/JDK-8331422

  11. JDK 21: https://openjdk.org/projects/jdk/21/

  12. JDK 24: https://openjdk.org/projects/jdk/24/

  13. Java release: https://www.java.com/releases/



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值