我们切换到 Java 21 虚拟线程后,在 PostgreSQL 的 TPC-C 中遇到了死锁

Java 21 的新虚拟线程正在暴露现有 Java 代码中的细微死锁风险,尤其是在 JDBC 连接池等库内部使用 `synchronized` 和 `Object.wait()` 这类旧式同步原语时。评论者指出,这些构造会“钉住”载体线程并耗尽有限的底层线程池,打破那些在大量 OS 线程下原本安全的程序。共识是:虚拟线程对 I/O 密集型工作负载很有前景,但还不能在所有场景下直接替代;开发者应审计库代码,优先使用现代并发工具(锁、信号量、并发集合),并在广泛采用前对运行时进行调优或等待后续修复。

死锁的性质

  • 普遍认为虚拟线程并没有“神奇地”制造出一种新的死锁类别;基准测试只是把已有的设计问题暴露在新的约束之下。
  • 死锁之所以出现,是因为虚拟线程在 c3p0 连接池代码内部被阻塞,而该代码使用了 synchronized + Object.wait,耗尽了载体线程。
  • 文中给出的修复方式——用 Semaphore 保护数据库连接——能让虚拟线程在有可用连接之前不要进入 c3p0,因此它们是在 VT 感知的代码中阻塞,而不是卡在连接池内部。
  • 若干评论者强调,这这是一个通用的 JDBC/连接池问题,并非 PostgreSQL 特有。

虚拟线程、pinning 与前进性

  • 虚拟线程在以下情况下无法被卸载:
    • 运行在 synchronized 块/方法内,或
    • 执行本地/外部代码,或某些旧式阻塞调用如 Object.wait
  • 在这些情况下,载体(OS)线程会被“pinning”;如果这类调用过多,VT 调度器的载体线程池(默认最多 256,可通过 jdk.virtualThreadScheduler.maxPoolSize 配置)可能被耗尽。
  • 有人进一步说明,Object.wait 既会释放监视器,又是通过本地代码实现,因此问题不仅仅是 synchronizedwait 本身也不配合 VT 调度。
  • 关注点与其说是锁的正确性,不如说是当所有载体都被阻塞时,系统会失去“前进性”。

“即插即用” vs. 新语义

  • 文档和一些宣传把虚拟线程描述成大体可直接替换;但另一些人指出采用指南明确警告不要盲目把所有平台线程都换成 VT。
  • 一派观点:如果你的设计需要同时可运行的线程数超过载体线程数,那这个设计本来就很脆弱;VT 只是把这一点暴露出来。
  • 另一派观点:以前的代码在“操作系统总能再创建更多线程”的假设下是正确的;引入硬性的载体上限会悄然改变系统行为,并可能打破这类代码。
  • 对于文档到底是否充分警告了死锁问题,还是仅仅警告了“可扩展性”问题,存在分歧。

讨论中的变通方案与最佳实践

  • synchronized/wait/notify 模式替换为:
    • java.util.concurrent 的锁、信号量和条件变量。
    • 生产者–消费者模式使用并发队列。
  • 避免在 synchronized 内执行长时间运行或阻塞操作;即使没有 VT,这也应被视为不良风格。
  • 使用载体线程池和配置:
    • 在合适场景下增大 jdk.virtualThreadScheduler.maxPoolSize
    • 将 CPU 密集型或本地调用卸载到平台线程执行器。
  • 使用诊断工具如 -Djdk.tracePinnedThreads 找出问题区域。
  • 有人建议从 c3p0 这类老旧池切换到仍在积极维护的实现(例如 HikariCP),不过即使是现代连接池也仍在为 VT 做适配。

虚拟线程的成熟度与未来修复

  • 许多人认为虚拟线程很强大,但还不是“设好就忘”的技术;它们最适合 I/O 密集、并避免旧式原语的现代代码。
  • 多位评论指出,synchronized/wait 的行为是一个已知的、临时性的实现限制,JDK 团队正在让这些机制对 VT 更友好。
  • 还有人建议,在更广泛采用之前,最好等到未来某个 LTS 版本,让 synchronizedObject.wait 能与虚拟线程完全协作,尤其是在包含大量第三方库的代码库中。

比较与元讨论

  • 有人把它与 Go、Erlang、Node.js、Python async 以及 .NET 的线程池/async 进行比较:这些生态在混合阻塞与非阻塞模型时,早期都经历过一些怪癖。
  • 旁支讨论质疑,在围绕 OS 线程构建的生态中,绿色线程/M:N 线程是否真的能做到完全透明。
  • 还有一些较小的分支话题涉及 HN 标题的编辑化表达,以及技术帖里显眼的 AI 生成封面图所引发的普遍反感。