我们切换到 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既会释放监视器,又是通过本地代码实现,因此问题不仅仅是synchronized,wait本身也不配合 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 版本,让
synchronized和Object.wait能与虚拟线程完全协作,尤其是在包含大量第三方库的代码库中。
比较与元讨论
- 有人把它与 Go、Erlang、Node.js、Python async 以及 .NET 的线程池/async 进行比较:这些生态在混合阻塞与非阻塞模型时,早期都经历过一些怪癖。
- 旁支讨论质疑,在围绕 OS 线程构建的生态中,绿色线程/M:N 线程是否真的能做到完全透明。
- 还有一些较小的分支话题涉及 HN 标题的编辑化表达,以及技术帖里显眼的 AI 生成封面图所引发的普遍反感。