两个数据库连接、四个准入名额、每条 SQL 休眠 50 ms。平台线程和虚拟线程在高并发的三轮实验中都接受了四个请求、拒绝了二十个请求。平台模式主要在执行器队列等待,虚拟模式主要在连接池等待;两者成功响应的 p95 都在约 110 ms 附近。

去掉准入后,同一个虚拟线程执行器同时启动 48 个任务。数据库仍只有两个活跃连接,另外 46 个任务等待借连接,最长借用耗时达到 1282 ms。线程创建成本降低之后,下游容量与等待队列仍需要独立限制。

固定版本与实验边界

独立工程位于 examples/play-electives/e07-virtual-threads,没有替换累计工程的 dispatcher,也没有改变 Play 的 HTTP 服务端。控制器将 JDBC 工作交给一个平台线程执行器或一个虚拟线程执行器,并向 Play 返回 CompletionStage<Result>。这个结构延续了第 13 篇的线程隔离、第 22 篇的连接池以及第 31 篇的容量观测。

固定版本为 Play 3.0.6、Scala 2.13.15、sbt 1.10.7、Amazon Corretto 21.0.11+10、Pekko 1.0.3、HikariCP 5.0.1、PostgreSQL JDBC 42.7.5、PostgreSQL 17.6。Play 源码固定在 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。stage-manifest.json 保存实际运行 JAR 的名称和 SHA-256,java-version.txt 与线程转储保存 JDK 版本。

以下数字来自隔离工程 run-02 的真实 production stage。2 项 JUnit 与 stage 通过之后,脚本经回环 HTTP 执行实验,再从独立 psql 连接核对 SQL 提交。原始记录位于 examples/play-electives/evidence/e07/isolated/;这层证据不代替博客页面渲染验收,也不代表生产环境容量。

固定项 配置与含义
平台模式 2 个工作线程,执行器队列容量 2
虚拟模式 每任务一个具名虚拟线程,调度器并行度设为 2
业务准入 两种模式共用容量 4 的 Semaphore,提交前 tryAcquire
JDBC 同一个 Hikari 池,最小与最大连接数均为 2
事务 pg_sleep(0.05)、插入唯一合成 ID、commit;失败 rollback
有限负载 3 轮 × 并发 1/8 × 两模式,每单元 24 次 HTTP
JVM -Xms256m -Xmx768m;JFR pinned 阈值 20 ms

每个单元采用闭环客户端:一个客户端完成响应后才发送下一次请求。平台模式总在虚拟模式之前执行,没有额外预热;三轮也没有充分覆盖 JIT、GC 与数据库缓存的长期变化。这里验证排队位置、容量和终态,不把几个毫秒的差距解释为线程模型的稳定排名。

所有服务仅绑定回环地址。实验为了减少业务外围代码,使用了合成密钥、关闭鉴权,并用 GET 触发写入和 JFR 归档。这些端点只供有限验证脚本调用,不是生产 API 模板。

Play 的异步结果与虚拟线程是两个层次

固定 Play 源码中,Java Action.call(Request) 的返回类型是 CompletionStage<Result>。框架可以在结果完成后继续处理响应,但调用 JDBC 的线程仍然执行阻塞 API。更换这个业务执行器,不会自动把路由、body parser、过滤器或服务端事件循环全部改成虚拟线程。Play 3.0.6 Action 源码

JDK 21 的虚拟线程由运行时调度到平台线程上执行;被占用的平台线程称为 carrier。可卸载的阻塞等待能释放 carrier,使它继续运行其他虚拟线程。CPU 密集代码仍要消耗处理器时间,因此虚拟线程适合大量等待型任务,并不承诺单条计算指令更快。JDK 21 虚拟线程指南

flowchart LR
    H[Play Action] --> A[准入四名额]
    A -->|满| R[503]
    A --> P[平台 两线程加两槽队列]
    A --> V[每任务一个虚拟线程]
    P --> C[Hikari 两连接]
    V --> C
    C --> D[相同 SQL 与 commit]
    D --> F[释放连接]
    F --> S[完成 CompletionStage]
    S --> Q[finally 释放准入]

实验使用 Thread.ofVirtual().name(...).factory() 与 Executors.newThreadPerTaskExecutor 创建具名虚拟线程,以便对应 HTTP 结果与线程转储。它与 newVirtualThreadPerTaskExecutor 的关键共同点是每任务创建线程,执行器自身不提供四任务上限。四个名额来自显式准入,而非虚拟线程工厂。JDK 21 Executors 文档

每个成功响应都返回 Thread.currentThread().isVirtual()、线程名称、执行器等待与借连接耗时。验证脚本断言平台路径为 false 且名称以 vt-platform- 开头,虚拟路径为 true 且名称以 vt-business- 开头。仅观察返回类型是 Future,无法证明业务真正运行在虚拟线程上。

四个请求被接受以后,各自在何处等待

VirtualController.work 在提交前获取名额。平台模式最多有两个任务执行,另外两个留在执行器队列;虚拟模式可以启动四个任务,但只有两个能借到连接。任务真正退出时释放名额,执行器拒绝提交时则由提交方归还名额。

并发与模式 每轮成功 / 拒绝 三轮成功响应 p95,ms 三轮最大执行器等待,ms 三轮最大借连接耗时,ms
1,平台 24 / 0 61.326 / 60.309 / 59.493 1.147 / 0.031 / 0.113 0.038 / 0.038 / 0.056
1,虚拟 24 / 0 60.300 / 59.730 / 60.712 2.228 / 0.130 / 0.071 0.027 / 0.038 / 0.024
8,平台 4 / 20 111.117 / 107.557 / 109.446 52.155 / 51.735 / 52.427 0.690 / 0.635 / 0.545
8,虚拟 4 / 20 109.153 / 108.057 / 108.152 0.058 / 0.094 / 0.056 51.465 / 52.076 / 53.174

表中的 p95 只包含成功请求,拒绝次数单独列出;高并发每轮只有四个成功样本,这个百分位很粗。原始 cells.json 保留每轮耗时、成功吞吐和状态计数,observations.json 保留单次 HTTP。快速返回的 503 不计入成功吞吐。

平台模式约 52 ms 的等待发生在 execute 之后、任务开始之前。虚拟模式几乎立即启动任务,随后在 getConnection 等待约 52 ms。排队没有消失,只是位置改变了。两种模式的数据库活动连接峰值都没有超过 2;每个测量单元结束后,独立 SQL 查询到的行数与成功响应数一致。

时间差分别在同一进程的单调时钟域计算。服务端用 System.nanoTime 测量队列与借用,客户端用自身单调时钟测量完整响应,不能把两端原始时间戳直接相减。池活跃数与等待者数由 2 ms 周期采样得到,可能漏掉更短暂的峰值;单任务借用耗时则不依赖该采样命中。

48 个虚拟线程与两个连接

/burst 是一个有意绕过业务准入的反例,每次只创建 48 个任务。脚本只调用一次,SQL 与连接池保持不变;它没有增加数据库连接,也没有改变查询时间。

采样时记录到 running=48、poolActive=2、poolWait=46。48 个任务最终全部提交,最长借连接等待 1282.461 ms。等待中的虚拟线程仍然持有任务对象、业务参数和调用栈,它们占用的资源更便宜,但不等于零成本。

该阶段还执行了真实的 jcmd Thread.dump_to_file -format=json。转储完成时已经有部分事务结束,因此文件中保留了 40 个 vt-business- 线程,其中 38 个栈包含 ConcurrentBag.borrow,2 个栈包含 JDBC execute。采样的 48 个在途任务与稍后转储的 40 个线程并不矛盾,它们是两个时刻的观察。

sequenceDiagram
    participant C as HTTP 客户端
    participant A as Action 与响应计时器
    participant V as 虚拟业务线程
    participant D as PostgreSQL
    C->>A: 提交 100 ms SQL 任务
    A->>V: 获取名额并执行
    V->>D: pg_sleep 与事务
    A-->>C: 10 ms 响应预算到达 504
    C->>A: 读取状态
    A-->>C: running 1 permits 3 finished 未增加
    D-->>V: SQL 完成并提交
    V->>A: 释放连接与准入名额
    C->>D: 独立连接查询
    D-->>C: 合成记录存在

另一条独立请求使用 100 ms SQL 与 10 ms 响应预算。客户端在 14.395 ms 完整收到 504;随后读回仍有一个业务任务运行、剩余三个准入名额、完成数没有增加。排空之后,独立数据库连接查询到该记录已经提交。

这个结果说明实验中的响应超时没有取消 JDBC 工作。它不证明所有驱动都无法取消,也不说明取消必然安全;真正的取消方案还需要定义事务回滚、连接状态和取消与 commit 竞争时的终态。虚拟线程不会替业务定义这些语义。

一个可编译的准入与超时例子

以下完整类使用 JDK 21。响应 Future 可以先被超时完成,名额仍在业务任务的 finally 中归还。close 等待执行器任务终止,因此不会在输出超时结果之后直接遗失后台工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public final class AdmissionExample implements AutoCloseable {
private final Semaphore permits = new Semaphore(4);
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

public CompletableFuture<String> submit() {
if (!permits.tryAcquire()) {
return CompletableFuture.failedFuture(new RejectedExecutionException("full"));
}
CompletableFuture<String> response = new CompletableFuture<>();
try {
executor.execute(() -> {
try {
Thread.sleep(100);
response.complete("business finished");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
response.completeExceptionally(e);
} finally {
permits.release();
}
});
} catch (RejectedExecutionException e) {
permits.release();
response.completeExceptionally(e);
}
return response;
}

@Override
public void close() {
executor.close();
}

public static void main(String[] args) throws Exception {
try (AdmissionExample service = new AdmissionExample()) {
CompletableFuture<String> response = service.submit();
String value = response.completeOnTimeout("response timeout", 10, TimeUnit.MILLISECONDS).get();
System.out.println(value);
}
System.out.println("executor drained");
}
}

工程中的同名文件已通过 javac 编译并实际运行,输出依次是 response timeout、executor drained。这是准入生命周期示例,数据库事务仍由完整 Play 实验验证。2 项 JUnit 分别检查虚拟线程身份,以及响应先完成时名额不会提前释放。

JDK 21 固定点必须看实际栈

在 JDK 21,虚拟线程持有 synchronized 监视器时发生阻塞,可能无法从 carrier 卸载。原生或外部函数调用也涉及固定点边界。这里的版本限定很重要:不能把 JDK 21 的机制描述当成所有后续 JDK 的永久规则。JDK 21 调度与固定点说明

对照接口在一个真正共享的静态监视器内执行六次 60 ms Thread.sleep,避免使用可能被优化掉的局部锁。另一接口用 ReentrantLock 包围同样六次休眠。两个接口都在虚拟线程上运行,录制显式启用 jdk.VirtualThreadPinned,阈值为 20 ms,并记录栈。

runtime.jfr 中实际出现 6 个 pinned 事件,全部包含 monitorSleep,持续时间为 60.653 至 69.998 ms;没有包含 lockSleep 的事件,也没有 VirtualThreadSubmitFailed。这组结果验证了所选 JDK 和阻塞位置的差异。它不支持“有 synchronized 就必须替换”的规则:短小临界区、无阻塞代码、锁竞争成本都要分别判断。

本次 JDBC 路径没有跨越阈值的 pinned 事件。这个结论只覆盖本次录制、驱动版本、SQL 与 20 ms 阈值,不能推广成 JDBC 从不固定 carrier。减少阈值、换驱动、增加 SSL 或调整数据库负载后,需要重新检查事件栈,不能用线程数量猜测固定点。

JMX 同时记录了整个 JVM 的近似分配字节、堆占用和 GC 累计值。12 个单元的分配增量约为 1.84 至 3.83 MB,含 Play、HTTP、JFR 和统计代码开销;大多数单元 GC 次数增量为 0,最后一轮低并发虚拟模式为 1。这些短样本不足以比较内存效率。

getTotalThreadAllocatedBytes 是 JDK 21 的进程累计近似量。按线程 ID 查询的 getThreadAllocatedBytes 对虚拟线程返回 -1;因此不能拿这个缺失值当成“虚拟线程零分配”。本实验没有把进程级增量归因到单个虚拟线程。ThreadMXBean 的分配统计约定

停机终态与复现入口

下载本篇源码与实验记录,并核对SHA256SUMS。解压后的 play-electives/e07-virtual-threads 是独立工程;接入后重跑2项JUnit、stage、三轮请求、线程转储与JFR,10个应用及生成class与生产jar字节一致,实际进程退出143且监听端口关闭,见 evidence/e07/shared-build.json 与 shared-http-02。

录制在 /recording 返回之前停止、归档并关闭,随后脚本向自己启动的 Play 进程发送 SIGTERM。这样可以避开 JVM 的 JFR 关闭钩子与应用钩子同时停止同一录制的竞争。连接池关闭放在独立清理分支中,归档失败也不应跳过连接资源清理。

本次进程退出码为 143,没有强杀。cleanup.json 显示平台执行器、虚拟执行器、采样时钟、Hikari 池与录制均已关闭,运行任务为 0,四个名额全部归还。脚本又验证监听端口拒绝连接,并从新数据库连接确认本实验 ApplicationName 对应的会话数为 0。

在工程目录配置 JDK、sbt launcher 与已经启动的本地 PostgreSQL 容器。容器内数据库名为 play_lab,SQL 验证用户为 postgres;应用连接信息使用环境变量。每次选择新的证据目录,脚本拒绝覆盖历史运行。

1
2
3
4
5
6
7
8
export JAVA_HOME=/path/to/jdk-21
export PLAY_LAB_SBT_LAUNCHER=/path/to/sbt-launch-1.10.7.jar
export VT_JDBC_URL='jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5'
export VT_DB_USER=postgres
export VT_DB_PASSWORD=lab-only-password
export VT_DB_CONTAINER=your-disposable-postgres-container
bash sbtw test stage
python3 verify.py evidence/run-new

cells.json 与单次 HTTP 记录回答响应行为,sql-observations.json 回答提交与连接回收,threads.json 回答转储时刻的栈,pinned-events.json 保留 JFR 事件的 JSON 表达。只执行 sbt test 无法取得这些网络与数据库证据。

场景 本次状态 可支持的结论
相同池、准入与 SQL 的三轮对照 VERIFIED_LOCAL 等待位置变化,数据库连接峰值仍为 2
固定 48 任务无准入反例 VERIFIED_LOCAL 46 个任务等连接,48 个最终提交
504 后 SQL 提交 VERIFIED_LOCAL 响应预算未取消业务,名额保持到任务退出
JDK 21 监视器与 ReentrantLock 对照 VERIFIED_LOCAL 指定阈值下分别观测到 6 / 0 个固定点事件
长时间开放到达率、远程数据库、故障切换 NOT_RUN 无线上容量或可用性结论

练习

把准入上限从 4 改成 2,保持两个连接与 50 ms SQL 不变。预期是虚拟模式的连接等待减少、高并发拒绝增加;验证成功数、实际 SQL 行数和借连接耗时,而不是只比较 HTTP 总吞吐。这个变体尚未运行。

保持业务输入不变,将 JFR 固定点阈值从 20 ms 降到 1 ms,分别运行监视器、ReentrantLock 与 JDBC 路径。比较事件栈、录制开销和任务时延,判断新增事件是否来自真正影响容量的长等待。这个变体也需要新的原始记录,不能沿用本文的六个事件。

系列导航

  • 系列入口:最小应用
  • 前一篇:E06,OpenTelemetry 与跨执行上下文观测(待发布)。
  • 后一篇:E08,国际化与内容协商(待发布)。