在Project Loom中,虚拟线程作为在ForkJoinPool中抽取的载体线程之上的继续存在运行。当虚拟线程遇到synchronized块或执行本地代码时,它会锁定其底层的载体线程,防止调度程序在阻塞的I/O操作期间拆卸虚拟线程。这有效地将并发度降低到载体池的大小(通常等于CPU核心数),在负载下可能导致吞吐量崩溃,因为争用的虚拟线程垄断了固定的载体池。
一家金融服务公司将其传统Tomcat每请求一个线程的遗留订单处理网关迁移到使用虚拟线程的Jetty,希望能够处理50,000个并发WebSocket连接。部署后,尽管采用了虚拟线程,但延迟在市场开盘波动期间飙升到几秒,吞吐量仅在800 TPS停滞。线程转储显示所有24个载体线程都卡在BLOCKED状态中,位于synchronized块内部,而成千上万的虚拟线程在I/O队列中无法继续。
第一个考虑的解决方案是通过-Djdk.virtualThreadScheduler.parallelism将ForkJoinPool的并行度提高到1000。这将提供更多的载体线程来吸收锁定的工作负载,有效地恢复到大型平台线程池的行为。然而,这种方法仅仅掩盖了底层架构的缺陷,通过消耗过多的OS资源来消耗,并且 nullifies虚拟线程虚拟化所承诺的内存效率好处。
第二个解决方案涉及重构所有保护共享速率限制缓存的synchronized块,改为使用ReentrantLock。与内置监视器不同,ReentrantLock与虚拟线程调度程序集成,允许在争用或阻塞操作期间拆卸,而不会锁定载体。这种方法保留了虚拟线程的轻量特性,但需要对代码库进行系统审计,并仔细处理锁中断语义。
第三个解决方案建议用纯无锁数据结构(如ConcurrentHashMap计算方法或StampedLock进行乐观读取)替换并发哈希映射缓存。虽然这消除了许多读取路径的阻塞,但未能解决需要独占访问状态外部资源(如数据库连接检出序列)固有要求互斥的场景。
团队选择了第二个解决方案,优先将五十个关键synchronized部分迁移到ReentrantLock,在分析中将它们识别为锁定热点。这个选择直接解决了根本原因,使调度程序能够在争用期间拆卸虚拟线程,而不改变底层应用程序的业务逻辑或增加内存占用。
重构和重新部署后,系统实现了目标50,000个并发连接,稳定的子100毫秒的p99延迟。载体线程池保持在24的默认大小(与CPU核心匹配),证明只有在代码避免通过内置同步锁定载体时,虚拟线程才能真正提供可扩展性。
// 之前:锁定载体线程 synchronized (rateLimiter) { // 虚拟线程如果在这里被阻塞无法拆卸 externalApi.call(); } // 之后:允许拆卸 rateLimiter.lock(); try { // 虚拟线程拆卸,释放载体 externalApi.call(); } finally { rateLimiter.unlock(); }
为什么锁定特定发生在synchronized块和本地方法时,而ReentrantLock则允许拆卸?
锁定的发生是因为JVM使用基于线程栈的监视器记录和内在的C++级VM内部结构实现内置监视器(synchronized),这些结构与物理OS线程的执行上下文依赖。当虚拟线程进入synchronized块时,JVM无法安全地将继续传递到另一个载体,而不损坏监视器状态或在本地级别违反先发生保证。相反,ReentrantLock完全在Java中实现,基于AbstractQueuedSynchronizer,使用VarHandle和LockSupport.park原语,虚拟线程调度程序插入其上,从而允许在没有本地线程状态依赖的情况下安全拆卸和重新装载。
载体线程锁定与ForkJoinPool的工作窃取如何相互作用以产生潜在的饥饿场景?
在正常操作下,ForkJoinPool假设任务是CPU绑定或非阻塞的;当工作线程阻塞时,它通过产生或激活额外的工人来补偿,直到并行度限制。然而,被锁定的虚拟线程阻止其载体而没有有效地通知池的补偿机制。因此,如果有二十个虚拟线程同时锁定二十个载体(例如,进入synchronized块),将没有载体可用于执行被调度器中的成千上万的就绪虚拟线程。这产生了一个优先级反转,即尽管可用任务,但未解除阻止的工作无法进展,从而有效地动态缩小可用池大小,并可能导致灾难。
在虚拟线程环境中,ThreadLocal变量的激进使用是否会导致载体线程锁定?
ThreadLocal变量不会导致锁定,因为虚拟线程实现在线路和拆卸操作期间在线路间移动线程局部映射。然而,候选人通常忽视ThreadLocal带来的不同内存管理灾难:对于接触线程本地的数百万个短期虚拟线程,每个载体线程会在其ThreadLocalMap中积累每个曾经托管的虚拟线程的条目。由于这些映射仅在显式删除或垃圾回收键(虚拟线程)时清理,这导致在长期运行的承载线程中无界内存增长。这有效地构成了与锁定无关的内存泄漏,但对大规模虚拟线程部署同样致命,需要迁移到ScopedValue(JEP 446)以进行适当清理。