Java虚拟线程在高负载下的三大生产限制
虚拟线程让我们可以保留普通的阻塞代码,同时让更多请求并发等待。然而,在负载下,这会引出三个关键问题:
- 我们允许多少工作进入? 线程再便宜,也可能堆积出代价高昂的队列。
- 什么应该放在锁内? 持有锁的线程在睡眠时,仍会让其他所有线程等待。
- 谁来停止被遗弃的工作? 调用方超时并不会取消其任务。
下面的示例用各自独立的前后对比程序来分别说明这些问题。每个程序都是完整的:把其代码块保存为所示文件名,并在不带参数的情况下运行随附命令。你只需要 JDK 25。它们使用 JDK 25 的紧凑源文件语法,因此无需类声明、构建工具、外部库或预览标志。
首先:更多重叠的等待
设想 1,000 个任务,每个任务等待 100 毫秒,且没有依赖项限制它们的并发:
这些是算术估算,忽略了启动和调度开销。使用 50 个工作线程时,这些等待需要二十批。让全部 1,000 个等待重叠,则只需一批。将虚拟线程并发限制为 50,二十批的结果又会回来。虚拟线程让更多相互独立的等待得以重叠。它们并不会更快地执行 CPU 指令。虚拟线程运行在一个称为载体的平台线程上。许多阻塞式 JDK 操作(包括套接字 I/O 和 sleep)会让虚拟线程在等待时卸载。它的栈和被引用的对象仍保留在堆上,而载体可以运行其他内容。
[LOADING...]
(M:N 调度模型:许多虚拟线程映射到较小的操作系统载体线程池)
调度器的默认目标并行度是 JVM 可用的处理器数量。某些阻塞操作仍会占用载体;即使在 JDK 25 上,本地调用和外部函数调用也可能固定虚拟线程。等待会使用更少的操作系统线程,但仍然消耗内存和资源。
1. 限制准入:八个槽位无法同时服务所有人
设想一个依赖项有八个连接。每次调用耗时 80 毫秒。其理想容量为 8 / 0.08 = 每秒 100 次调用。虚拟线程可以廉价地等待连接,但它们无法再创建一个连接。发送 100 个请求的突发流量。首先,全部接受。然后,用 32 的准入上限重复一次:最多八个正在使用依赖项,另有 24 个在等待它。
首先,接受每个请求。保存为 UnboundedAdmission.java:
运行它:java UnboundedAdmission.java
每个请求都会等待,直到有连接可用。现在添加一个准入信号量,让超额请求立即被拒绝。保存为 BoundedAdmission.java:
运行它:java BoundedAdmission.java
在 Oracle GraalVM 25.0.2 上的一次运行产生了:
这里有两个不同的限制。connections.acquire() 等待八个服务槽位之一。admission.tryAcquire() 会立即接纳请求或返回 false,并记录一次拒绝。它的许可会在请求等待连接并执行调用期间一直保持占用。没有准入控制时,全部 100 个请求在十三批内完成,耗时约一秒。有了这道闸门后,一次快速突发通常接纳 32 个并拒绝 68 个;四批大约耗时 320 毫秒。有界运行结束得更早,因为它拒绝了超额工作,避免了内存耗尽。即使每个线程缓存只有 10 KB,乘以 100,000 个虚拟线程,仍然会占用 1 GB 堆空间。要点:在把工作加入你需要限制的队列之前,先拒绝它。
2. 缩短锁:保护更新
现在使用四十个任务。每个任务等待 80 毫秒,并递增一个共享的完成计数器。这里没有连接限制:这些等待相互独立,因此锁是唯一的瓶颈。首先,把等待和计数器更新都放在锁内。保存为 WideLock.java:
运行它:java WideLock.java
现在把等待移到锁外,同时保持计数器更新受保护。保存为 NarrowLock.java:
运行它:java NarrowLock.java
在同一个 JDK 上,两次运行产生了:
当等待位于锁内时,四十个 80 毫秒的等待至少需要 3,200 毫秒。让其他三十九个线程更廉价地挂起,并不能让它们越过持有锁的线程。当等待位于锁外时,这些等待就会重叠。在 JDK 25 上,synchronized 完全适合这个演示,因为 JDK 24 移除了与监视器相关的虚拟线程固定。这里的问题不是平台线程固定;而是基本的线程串行化。规则是:找出需要保护的状态转换,然后把相互独立的慢操作留在它之外。
3. 取消工作:超时只结束等待
使用一个耗时 1,800 毫秒的任务。其调用方只愿意等待 200 毫秒。首先,让调用方超时但不取消任务。保存为 TimeoutOnly.java:
运行它:java TimeoutOnly.java
现在在超时处理程序中添加 task.cancel(true)。保存为 TimeoutWithCancellation.java:
运行它:java TimeoutWithCancellation.java
观察到的结果如下:
没有取消时,get() 在 200 毫秒后抛出异常,但任务仍继续睡眠。它最终会在 1.6 秒后完成工作。如果这个被遗弃的工作持有数据库连接或准入许可,那么该资源将在整个睡眠期间泄漏。取消是协作式的。检测超时、请求取消,并等待清理完成。这三个部分都很重要。
预告:结构化并发解决方案
在上面的最后一个示例中,你会注意到父线程被迫对任务的生命周期进行微观管理。它等待、捕获 TimeoutException、显式调用 cancel(true),然后祈祷清理逻辑会运行。但如果我们可以反转这种控制呢?借助 JDK 的预览版 StructuredTaskScope API,我们可以把线程的生命周期绑定到词法作用域本身,让取消成为一种自动的架构保证,而不是手动样板代码:我将在下一篇文章中探讨 StructuredTaskScope 如何彻底消除资源泄漏和被遗弃的工作。敬请期待!
总结
迁移到虚拟线程时,请把这三个问题带回你的服务中:
- 等待在哪里堆积? 在那个队列增长之前限制准入。
- 是什么让工作串行化? 把相互独立的等待留在保护状态的锁之外。
- 谁负责未完成的工作? 把截止时间与协作式取消和清理连接起来。
这里的程序使用 sleep 让每种效果都可见,但当你的等待对象是数据库、套接字或合作伙伴 API 时,完全相同的问题依然适用。