JDK 24 消除了虚拟线程进入 synchronized 监视器时引发的载体线程固定问题。对准备迁移到 JDK 25 LTS 的团队来说,这清除了一类曾经阻碍 Java 21 生产落地的故障,但并不意味着并发从此没有上限。虚拟线程能够廉价地等待,数据库连接、HTTP 配额、文件描述符和第三方服务容量却不会随之增加。
生产环境中的核心问题因此发生了变化:过去要排查“载体线程为什么被固定”,现在更常见的问题是“应用为什么同时向下游发出了数万个请求”。
JDK 24 究竟改变了什么
虚拟线程执行阻塞操作时,JVM 通常可以卸载虚拟线程,把有限的载体线程交给其他任务。Java 21 的一个重要例外是:虚拟线程在 synchronized 代码块或方法中阻塞时,可能固定其载体线程。
当大量请求都走到这条路径,载体线程会被逐渐占满。应用表面上创建了许多虚拟线程,实际却没有足够的载体线程继续执行它们。这也是 Netflix 等团队在 Java 21 上谨慎推进虚拟线程的重要原因之一。
JDK 24 改进了监视器实现,使虚拟线程即使持有 Java 监视器,也能在需要等待时从载体线程上卸载。JDK 25 LTS 继承了这一变化,因此仅仅为了避免这类固定而把所有 synchronized 改写成 ReentrantLock,通常已不再必要。
边界仍然要说清楚:这项变化针对的是监视器相关固定,并不保证所有本地调用、外部函数调用或 JVM 无法安全卸载的执行路径都不会固定载体线程。升级 JDK 可以删除一类瓶颈,不能替代压测和运行时观测。
锁的问题退场后,资源饱和开始显形
传统线程池同时承担了两个职责:复用昂贵的平台线程,以及用池大小间接限制并发。换成“每任务一个虚拟线程”之后,第一个职责不再重要,第二个职责也随之消失。
假设服务过去使用 200 个工作线程,并且每个请求都会访问数据库。即使代码没有显式限流,同时竞争数据库的请求通常也不会远超 200。迁移后,应用可以迅速创建 20,000 个虚拟线程;如果这些任务都等待一个只有 50 条连接的连接池,系统可能出现以下现象:
- 连接池等待队列膨胀,请求延迟从数据库查询时间变成排队时间。
- HTTP 下游收到突发流量,开始返回
429、503或触发超时。 - 大量等待任务继续占用堆内存、请求上下文和套接字。
- 超时触发重试,重试又扩大下游压力,形成正反馈。
- 吞吐量没有提升,但尾延迟、内存占用和故障恢复时间明显恶化。
这不是虚拟线程的缺陷。它只是让应用更容易表达高并发,也让原本藏在线程池大小中的容量限制失效了。新的设计原则是:虚拟线程负责等待,资源边界由应用显式表达。
可运行示例:用信号量限制稀缺资源
下面的示例可以直接在 JDK 25 上编译运行。它创建 1,000 个虚拟线程模拟请求,但只允许 32 个任务同时进入“下游调用”。实际项目中,应把 DOWNSTREAM_LIMIT 调整为数据库连接池容量、合作方并发配额或压测得到的安全值。
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public final class BoundedVirtualThreads {
private static final int REQUESTS = 1_000;
private static final int DOWNSTREAM_LIMIT = 32;
private static final Semaphore permits = new Semaphore(DOWNSTREAM_LIMIT);
private static String callDownstream(int requestId) throws InterruptedException {
if (!permits.tryAcquire(Duration.ofSeconds(1))) {
return "rejected:" + requestId;
}
try {
Thread.sleep(Duration.ofMillis(100));
return "ok:" + requestId;
} finally {
permits.release();
}
}
public static void main(String[] args) throws Exception {
long started = System.nanoTime();
int succeeded = 0;
int rejected = 0;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<java.util.concurrent.Future<String>> futures = new ArrayList<>();
for (int i = 0; i < REQUESTS; i++) {
int requestId = i;
futures.add(executor.submit(() -> callDownstream(requestId)));
}
for (var future : futures) {
if (future.get().startsWith("ok:")) {
succeeded++;
} else {
rejected++;
}
}
}
long elapsedMs = Duration.ofNanos(System.nanoTime() - started).toMillis();
System.out.printf(
"success=%d rejected=%d elapsedMs=%d maxConcurrent=%d%n",
succeeded, rejected, elapsedMs, DOWNSTREAM_LIMIT);
}
}
保存为 BoundedVirtualThreads.java 后运行:
java -version
javac BoundedVirtualThreads.java
java BoundedVirtualThreads
这里有三个值得保留到真实服务中的细节:
- 不要再创建一个固定大小的虚拟线程池。虚拟线程不是需要复用的稀缺资源,真正需要限制的是下游调用。
- 使用有超时的
tryAcquire,不要让等待队列无限增长。拿不到许可时,可以返回过载响应、降级结果或进入有界队列。 - 必须在
finally中释放许可,否则异常路径会永久吃掉容量。
如果一个请求依次访问数据库、支付服务和对象存储,应为三种资源分别设置边界,而不是用一个全局信号量把它们混在一起。全局限制虽然简单,却可能让缓慢的对象存储请求占满许可,连健康的数据库请求也无法执行。
压测时不要只看吞吐量
来源提到的公开基准测试所支持的关键方法,不是照搬某个并发数字,而是比较升级前后不同限流策略下的失效方式。由于摘要没有给出具体测试参数和结果,生产团队可以按相同思路建立自己的基线。
至少要记录以下指标:
- 请求吞吐量,以及
p50、p95、p99延迟。 - 活跃虚拟线程数量和任务排队时间。
- 数据库连接池的活跃连接、空闲连接、等待者数量和获取超时。
- HTTP 下游的并发请求、超时、
429、5xx和重试次数。 - 堆占用、GC 暂停、套接字数量和文件描述符使用率。
- 限流许可的使用量、等待时间和拒绝次数。
可以在压测期间启动一段 Java Flight Recorder 记录。把 PID 替换成目标 Java 进程号:
jcmd PID JFR.start name=virtual-threads settings=profile duration=60s filename=virtual-threads.jfr
jcmd PID Thread.dump_to_file -format=json threads.json
线程转储适合确认大量虚拟线程究竟在等待连接池、HTTP 响应还是应用锁;JFR 则更适合关联 CPU、分配、阻塞和固定事件。不同 JDK 构建提供的事件可能有差异,因此应在实际部署的 JDK 25 发行版上验证采集配置。
迁移到 JDK 25 的实施顺序
一次稳妥的迁移不应止于把基础镜像中的版本号改成 25。可以按下面的顺序推进:
- 升级测试环境,确认依赖、agent、APM 和启动参数支持 JDK 25。
- 保留现有流量模型,比较平台线程与虚拟线程下的吞吐量和尾延迟。
- 盘点每一种稀缺资源,包括连接池、外部 API 配额、文件描述符和内部队列。
- 在资源入口设置独立的并发边界,并为等待设置超时。
- 明确定义过载行为:快速失败、返回
429、降级,或者进入容量确定的队列。 - 在压测中加入慢下游、连接池耗尽、超时和重试场景,而不只是正常响应。
- 通过金丝雀发布观察拒绝率、队列时间、连接池等待和
p99,再逐步扩大流量。
JDK 24 解决了虚拟线程落地过程中的一个关键运行时障碍,JDK 25 LTS 因而成为更现实的生产目标。但虚拟线程带来的不是无限容量,而是更直接的并发模型。升级后的工程重点,应从“用线程池控制一切”转向“让虚拟线程承载等待,并在每个有限资源前明确设置预算”。