Java基础、中级、高级、架构面试资料

16 个线程卡死 20 秒,Java 26 偷偷修了个大 Bug,详解 JDK-8369238

JAVA herman 14浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:xttblog2,发送下载链接帮助你免费下载!
本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:xttblog2,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领
【腾讯云】1核2G5M轻量应用服务器50元首年,高性价比,助您轻松上云

Java 8 是 2014 年发布的,12 年过去了,还有不少项目和程序员再使用 Java 8,这真的难以想象。

要是一款 AI 大模型,别说 10 年前了,就是去年或前年发布的大模型,现在几乎也凉透了,落后的不能在落后了。

但 Java 8 的魔力就是不一样,是 Java 语言里面最长青的“钉子户”,对虚拟线程之类的毫无兴趣。

说到虚拟线程,JDK 25 中依然存在着与其相关的 Bug,不得已 Java 26 中竟然悄悄的修复了它。

接下来,我们就一起来展开一下 Java 26 中的新改进:虚拟线程类初始化等待不再“钉住”载体线程。这是继 Java 24 JEP 491 之后,虚拟线程(Virtual Threads)领域的又一重要优化。

问题背景

虚拟线程(Virtual Threads)自 Java 21 正式发布以来,被誉为 Java 并发编程的里程碑特性。它允许开发者创建数十万个轻量级线程,而无需为每个线程分配独立的操作系统线程。虚拟线程通过挂载(mount)卸载(unmount)机制,在少量平台线程(载体线程,carrier thread)上高效调度执行。

然而,虚拟线程的“钉住”问题(Pinning)时有发生,它一直是其规模化应用的绊脚石。所谓“钉住”,是指当虚拟线程执行某些特定操作时,JVM 会强制将其固定在当前的载体线程上,无法卸载。这导致载体线程被阻塞,无法执行其他虚拟线程,严重时可引发“死锁”或“线程饥饿”。

可以说,从 Java 21 到 Java 25,虚拟线程一直陷在“钉住”(Pinning)的困境里。

Java 24 的 JEP 491

要知道 Java 24 通过JEP 491: Synchronize Virtual Threads without Pinning解决了 synchronized 关键字导致的钉住问题。在此之前,虚拟线程在 synchronized 代码块中阻塞时会被钉住,Java 24 后这一限制被解除。

但即便如此,Java 24 仍保留了三种钉住场景。

  1. 调用本地代码(Native Code)
  2. 类初始化时的静态代码块执行
  3. 等待其他线程完成类初始化

#JDK-8369238正是针对第三种场景的优化。

JDK-8369238 详解

接下来,我们一起来解密 JDK-8369238 是如何让类初始化等待不再钉住的。

问题是什么?

根据 JDK 26 Release Notes 的官方描述。

Virtual Threads Now Unmount from Carrier When Waiting for Another Thread to Execute a Class Initializer

翻译过来就是:

当一个虚拟线程尝试初始化一个正在被其他线程初始化的类时,在大多数情况下,该虚拟线程现在会从其载体线程上卸载并等待。而在此之前,虚拟线程会被钉住在载体线程上,等待其他线程执行完类初始化器。

为什么这是个问题?

要理解这个问题,需要先了解 Java 的类初始化机制。根据 JVM 规范(JVMS §5.5),类的初始化是线程安全的。也就是说当多个线程同时尝试初始化同一个类时,只有一个线程能执行 <clinit> 方法(类初始化器),其他线程必须等待。

文章配图参考我的原创链接 https://mp.weixin.qq.com/s/rtGevfRovNIvbC2nxWfXUw

在 Java 26 之前,如果一个虚拟线程(Thread B)发现类 Foo 正在被另一个线程(Thread A)初始化,Thread B 会在 JVM 层面阻塞等待。由于这种等待发生在 JVM 内部,虚拟线程无法卸载,载体线程被钉住

死锁场景示例

下面看一个老外网友的一个真实的生产环境问题。

  1. 假设有 16 个载体线程可供虚拟线程调度器使用
  2. 16 个虚拟线程同时尝试访问某个尚未初始化的类
  3. 其中一个虚拟线程获得类初始化权,开始执行 <clinit>
  4. 其余 15 个虚拟线程被钉住在各自的载体线程上,等待类初始化完成
  5. 如果执行 <clinit> 的虚拟线程在初始化过程中需要获取某个锁,而这个锁正被其他等待的虚拟线程间接持有(通过复杂的调用链),系统可能陷入死锁

更极端的情况是,如果所有载体线程都被钉住在等待类初始化的虚拟线程上,而执行初始化的线程又需要某个虚拟线程来继续执行(例如,初始化代码中触发了某些异步操作),系统将彻底死锁,没有任何虚拟线程能够继续执行。

JDK 26 是如何解决的?

JDK-8369238 的核心改进是,允许虚拟线程在等待其他线程执行类初始化时,从载体线程上卸载

具体实现上,JVM 修改了类初始化等待逻辑。

  • 之前:虚拟线程在 JVM 内部阻塞等待,无法卸载,载体线程被占用
  • 现在:虚拟线程主动卸载,将载体线程释放给调度器,让其他虚拟线程有机会执行

这一改进与 JEP 491 的理念一脉相承,将虚拟线程的调度权还给 JVM 调度器,而非强制绑定到特定载体线程

技术细节与限制

官方文档提到in most cases(在大多数情况下),这意味着并非所有类初始化等待场景都能卸载。可能的限制包括以下 3 条。

  • 某些特殊的类初始化路径可能仍需要钉住
  • 与本地代码交互的类初始化场景
  • 涉及复杂同步状态的边界情况

此外,JFR(JDK Flight Recorder)事件 jdk.VirtualThreadPinned 仍然会在这些场景下被触发,帮助开发者诊断剩余的钉住情况。

触发场景与影响分析

哪些场景会触发这个问题?

根据老外网友的反馈,以下代码模式容易触发类初始化等待导致的“钉住”问题。

public class BlockingLoadClass {
    static {
        // 模拟耗时的类初始化
        System.out.println("初始化开始: " + Thread.currentThread());
        try {
            Thread.sleep(20000); // 20秒初始化时间
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("初始化结束: " + Thread.currentThread());
    }

    public static void test() {
        System.out.println("测试方法: " + Thread.currentThread());
    }
}

// 主程序
public class JavaVirtualThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        // 平台线程先触发类初始化
        new Thread(() -> {
            BlockingLoadClass.test();
        }).start();

        Thread.sleep(1000); // 确保类初始化已开始

        // 虚拟线程尝试访问正在初始化的类
        Thread.startVirtualThread(() -> {
            BlockingLoadClass.test(); // 这里会被钉住(Java 26之前)
        });
    }
}

典型触发场景包括下面几点。

场景描述
高并发启动大量虚拟线程同时启动,同时访问尚未初始化的类
延迟加载使用懒加载模式的类(如单例模式)在高并发下首次访问
框架初始化Spring、Hibernate 等框架启动时扫描和初始化大量类
动态代理动态生成代理类时的类加载和初始化
反射调用通过反射首次访问某个类触发初始化

性能影响

在 Java 26 之前,这个问题可能导致下面 3 条性能影响。

  1. 吞吐量下降:载体线程被无谓占用,无法执行其他虚拟线程
  2. 延迟增加:等待类初始化的虚拟线程无法及时响应
  3. 死锁风险:极端情况下系统完全卡死

Java 26 的改进后性能得到改善。

  • 载体线程利用率提升:等待期间可执行其他虚拟线程
  • 避免死锁:不会因为类初始化等待耗尽所有载体线程
  • 更好的可扩展性:高并发场景下虚拟线程调度更灵活

社区实践与建议

如何检测钉住问题?

即使升级到 Java 26,仍然建议使用 JFR 监控虚拟线程钉住情况。

java -XX:StartFlightRecording=settings=profile,filename=recording.jfr \
     -Djdk.virtualThreadScheduler.parallelism=16 \
     YourApplication

查看 jdk.VirtualThreadPinned 事件,关注 reason 字段。

  • Native:调用本地代码导致
  • Class initialization:类初始化导致(Java 26 后应减少)
  • Monitor enter:监视器进入(Java 24 后已解决)

迁移建议

官方建议尽快升级到 Java 26 +,如果遇到正在使用虚拟线程并遇到性能或死锁问题。

其次,对于预热类初始化,对于已知的高并发类,可在应用启动时预初始化。

// 启动时强制初始化
Class.forName("com.example.HeavyInitializationClass");

最后,避免在 <clinit> 中执行阻塞操作,这是良好的编程实践,无论使用何种线程模型。

真实案例

国外的 Faire 公司的工程团队分享了他们使用虚拟线程的经验。他们在搜索索引系统中使用虚拟线程,发现 16 个载体线程全部被钉住,导致系统死锁。根本原因就是类初始化等待,所有虚拟线程都在等待一个类的初始化完成,而执行初始化的线程又依赖其他虚拟线程的协作。

他们的临时解决方案是在创建虚拟线程之前,强制预初始化相关类。Java 26 后,这类问题将得到根本性缓解。

总结与展望

JDK-8369238 是虚拟线程生态的又一重要补丁。它解决了 Java 24 之后剩余的钉住场景之一,使虚拟线程在高并发、大量类加载的场景下更加可靠。

改进时间线

版本改进内容
Java 21虚拟线程正式发布(JEP 444),但 synchronized 和类初始化会钉住
Java 24JEP 491 解决 synchronized 钉住问题
Java 26JDK-8369238 解决类初始化等待钉住问题

未来展望

目前虚拟线程仅剩的主要钉住场景是调用本地代码(Native Code)。这包括下面 3 点。

  • JNI 调用
  • Foreign Function & Memory API (FFM) 调用
  • 类加载过程中的本地代码(类加载器本身使用本地代码)

随着 Project Loom 的持续推进,我们可以期待未来版本进一步优化这些场景。虚拟线程正在逐步兑现其百万级并发、零成本抽象的承诺。

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号:xttblog2。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!

本文原文出处:业余草: » 16 个线程卡死 20 秒,Java 26 偷偷修了个大 Bug,详解 JDK-8369238