先说结论
虚拟线程是由 JVM 调度的轻量线程,允许大量阻塞式任务共享较少平台线程。它主要简化高并发 I/O 服务的线程成本和代码结构,不会让 CPU 计算突破核心数上限,也不能替代连接池、限流和背压。
阻塞操作能够挂起虚拟线程并释放载体线程时,平台线程可以继续运行其他任务,因此“一请求一线程”模型重新具有可扩展性。
关键机制
虚拟线程不是传统线程池中的稀缺资源,通常按任务创建,不需要为复用而池化。ThreadLocal 仍可使用,但海量线程会放大其内存成本,线程本地缓存策略需要重新评估。
平台线程和虚拟线程的分工
平台线程直接对应操作系统线程,创建和阻塞成本较高;虚拟线程由 JVM 调度,阻塞在支持的 I/O 或同步原语上时可以从载体平台线程卸载,载体继续执行其他虚拟线程。应用代码依然使用 Thread、阻塞 I/O 和 try/finally,心智模型比回调链更直观。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Socket socket : acceptedSockets) {
executor.submit(() -> handle(socket));
}
}
这不是“无限并发”开关。每个任务仍占用连接、数据库会话、远程服务配额和内存;虚拟线程只是把等待这些资源时的线程成本降下来。
适合与不适合的场景
| 场景 | 是否适合 | 原因 |
|---|---|---|
| 大量独立 HTTP/RPC 等待 | 很适合 | 阻塞等待可卸载载体线程 |
| JDBC 调用 | 通常适合 | 保留同步代码,仍受连接池限制 |
| CPU 密集图像/加密 | 不会更快 | 核数仍限制并行计算 |
| 长时间持锁后阻塞 | 需审查 | 可能 pin 住载体或放大竞争 |
| 海量 ThreadLocal 缓存 | 需谨慎 | 每任务状态带来内存放大 |
资源限制应该放在哪里
传统线程池常被同时用来“复用线程”和“限制并发”。虚拟线程不需要前者,但后者仍然重要:数据库连接池本身限制数据库并发;调用外部 API 可用 Semaphore 或 RateLimiter;CPU 密集阶段用固定大小执行器。把两种职责分开,系统更可控。
Semaphore permits = new Semaphore(100);
void callPartner(Request request) throws Exception {
permits.acquire();
try {
partnerClient.call(request);
} finally {
permits.release();
}
}
许可获取也要设置超时和取消响应,避免请求无限等待。外部依赖变慢时,限流、熔断和降级仍不可省略。
Pinning 与诊断
某些阻塞场景会让虚拟线程无法从载体卸载,例如特定 native 调用,或较旧/特定条件下在 synchronized 监视器内进行阻塞操作。应通过 JFR、线程转储和负载压测观察,而不是因看到一个 synchronized 就盲目替换成 Lock。现代 JDK 对许多场景已有改进,结论需与实际版本一致。
迁移建议
先选择一个 I/O 边界清晰、无复杂线程本地状态的服务进行压测迁移。观察吞吐、P99、载体线程占用、连接池等待、内存和错误率;随后逐步移除“为了省线程而写”的回调层,但保留超时、背压和上下文传播。
实际用时要注意什么
适合大量独立、以等待数据库或网络为主的任务。CPU 密集任务仍应限制并行度;访问数据库、第三方接口等有限资源时必须用信号量、连接池或速率限制保护。
容易踩坑的地方
吞吐提升来自降低等待线程成本,不是下游变快。某些长时间持有监视器执行阻塞操作的代码还可能影响载体线程利用率,应结合 JDK 版本和监控观察 pinning。
常见问题
追问:用了虚拟线程还需要线程池吗?
通常不为复用虚拟线程而池化,但仍需用并发限制保护 CPU 和外部资源;执行器可以负责每任务创建虚拟线程,容量控制由其他组件承担。
追问:虚拟线程会让同步代码变成非阻塞代码吗?
业务语义仍是阻塞等待,只是 JVM 可以更高效调度等待中的虚拟线程。网络协议、超时和下游资源没有自动变快。
追问:ThreadLocal 在线程复用减少后还会泄漏吗?
单个短生命周期虚拟线程结束后其 ThreadLocal 随线程回收,经典线程池串上下文风险降低;但大量线程上的大 ThreadLocal 值仍会造成内存成本,异步传播问题也仍存在。
追问:可以把所有线程池替换为虚拟线程执行器吗?
不建议机械替换。CPU 密集池、调度器、限流执行器和某些依赖特定线程模型的库需要单独评估,先从请求处理和阻塞 I/O 边界迁移。