Java 虚拟线程实战指南(JDK 21+)

Java 虚拟线程实战指南(JDK 21+)

这是《Java 线程池》的姊妹篇,两篇建议对照着看。虚拟线程不是线程池的升级版,也不会取代线程池------它解决的是另一类问题,两者在生产环境里会长期共存。

代码基于 JDK 21+ (虚拟线程由 JEP 444 引入)。版本差异的地方都标了 JEP 编号,因为虚拟线程在 21 → 24 → 25 这几个版本里改动不小,网上很多文章还停留在 21 的结论,包括"虚拟线程会被 synchronized 钉死"这条------JDK 24 之后已经修了

Spring 部分适用于 Spring Boot 3.2+(Spring Framework 6.1+)。


目录

  • [1. 虚拟线程到底解决什么问题](#1. 虚拟线程到底解决什么问题)
    • [1.1 平台线程的成本](#1.1 平台线程的成本)
    • [1.2 算一笔账:并发数到底要多少线程](#1.2 算一笔账:并发数到底要多少线程)
    • [1.3 thread-per-request 是怎么撞墙的](#1.3 thread-per-request 是怎么撞墙的)
    • [1.4 响应式编程是解药吗](#1.4 响应式编程是解药吗)
    • [1.5 实测:跑一遍比讲十遍管用](#1.5 实测:跑一遍比讲十遍管用)
  • [2. 虚拟线程是什么](#2. 虚拟线程是什么)
    • [2.1 和平台线程的对照](#2.1 和平台线程的对照)
    • [2.2 挂载与卸载:虚拟线程为什么"便宜"](#2.2 挂载与卸载:虚拟线程为什么"便宜")
    • [2.3 调度器](#2.3 调度器)
  • [3. 怎么用](#3. 怎么用)
    • [3.1 真实场景一:批量调下游接口](#3.1 真实场景一:批量调下游接口)
    • [3.2 真实场景二:thread-per-request 的 Web 服务](#3.2 真实场景二:thread-per-request 的 Web 服务)
  • [4. 必须记住的几条规矩](#4. 必须记住的几条规矩)
  • [5. 钉住(Pinned):最该花时间搞懂的一节](#5. 钉住(Pinned):最该花时间搞懂的一节)
  • [6. 诊断与观测](#6. 诊断与观测)
  • [7. ThreadLocal 与 ScopedValue](#7. ThreadLocal 与 ScopedValue)
    • [7.1 代价到底有多大:算一笔账](#7.1 代价到底有多大:算一笔账)
  • [8. Spring Boot 3.2+ 集成](#8. Spring Boot 3.2+ 集成)
    • [8.4 不想全局开启:只给部分 @Async 用虚拟线程](#8.4 不想全局开启:只给部分 @Async 用虚拟线程)
    • [8.5 Web 容器的线程配置会失效](#8.5 Web 容器的线程配置会失效)
    • [8.6 上下文传递:虚拟线程下问题没消失](#8.6 上下文传递:虚拟线程下问题没消失)
  • [9. 什么时候仍然要用线程池](#9. 什么时候仍然要用线程池)
    • [9.1 混合架构:一个真实可落地的搭配](#9.1 混合架构:一个真实可落地的搭配)
  • [10. 迁移实操清单](#10. 迁移实操清单)
  • [11. 常见误区](#11. 常见误区)

1. 虚拟线程到底解决什么问题

1.1 平台线程的成本

我们平时 new Thread() 出来的线程,在 JDK 21 的术语里叫平台线程(platform thread) ,它就是 OS 线程的一层薄包装------1:1 绑定。这个绑定带来三个硬成本:

成本项 数量级
栈内存 默认 1MB(-Xss),调小也降不到哪去,受 OS 页大小约束
创建耗时 微秒级,还要走一次系统调用
上下文切换 要进内核,一次切换约 1~10 微秒,且会污染 CPU 缓存

所以一台 4C8G 的机器上,平台线程的实际上限大约在几千。再多就开始频繁切换、内存吃紧,最后 OOM。

1.2 算一笔账:并发数到底要多少线程

这里有个很多人没算过的公式 ------ 利特尔法则(Little's Law)

复制代码
并发数 = 吞吐量(请求/秒) × 延迟(秒)

套进 thread-per-request 模型(一个请求占一个线程):

  • 1000 QPS,平均延迟 500ms → 需要 500 个并发线程
  • 5000 QPS,平均延迟 800ms → 需要 4000 个并发线程
  • 服务间的调用一旦因为下游抖动把延迟从 200ms 拉到 2s → 并发线程数直接翻 10 倍

注意最后一条:并发线程数不是由你决定的,是由下游的延迟决定的。这就是为什么平时跑得好好的服务,在下游抖一下的时候突然就线程池打满、开始拒绝任务。

1.3 thread-per-request 是怎么撞墙的

thread-per-request 是最好写、最好排查的模型:一个请求从头到尾跑在一个线程上,栈就是调用链,日志能串起来,调试器一点就能看到全貌。

但它撞墙撞得很干脆:线程数撑不住。于是过去十年大家只有两条路:

  1. 把线程池调大。治标不治本,线程多了切换开销吃掉 CPU,内存也扛不住。
  2. 放弃 thread-per-request,改用异步/回调/响应式。

1.4 响应式编程是解药吗

WebFlux、CompletableFuture 这类方案确实能用少量线程撑起高并发,但代价很实在:

  • 代码被切成一堆回调/算子,读起来像天书 ,一个业务逻辑散在五六个 flatMap 里;
  • 异常栈基本没用,出问题时你看到的栈是 Reactor 内部的调度栈,不是你的业务调用栈;
  • 调试器没法用,断点跳来跳去;
  • 一个不小心在响应式链里写了个阻塞调用(比如 JDBC),整个事件循环被堵死------而且 JDBC 本身就是阻塞的,这让响应式在关系型数据库场景里一直很尴尬。

虚拟线程的意义就在这里:它让 thread-per-request 这个最简单的模型,重新变得可行

你照旧写阻塞式的同步代码(jdbc.query(...)httpClient.send(...)),代码结构不用变,但底层不再需要一万个 OS 线程来撑并发。

1.5 实测:跑一遍比讲十遍管用

下面这段是 JEP 444 里的经典例子,建议自己跑一次。任务很简单 ------ 睡 1 秒,然后结束。

java 复制代码
// A:平台线程版(每个任务一个平台线程)
long start = System.currentTimeMillis();
try (var executor = Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory())) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return null;
        });
    }
}
System.out.printf("平台线程:%d ms%n", System.currentTimeMillis() - start);
java 复制代码
// B:虚拟线程版(每个任务一个虚拟线程)
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return null;
        });
    }
}
System.out.printf("虚拟线程:%d ms%n", System.currentTimeMillis() - start);

在一台普通开发机(4C8G)上的典型结果:

版本 结果
A 平台线程 大概率直接 OutOfMemoryError: unable to create new native thread(1 万个线程 × 1MB 栈 ≈ 10GB 虚拟内存);侥幸建起来了,光创建本身也要数秒到数十秒
B 虚拟线程 约 1.0 秒完成,内存只占几十 MB

差距不在"快",而在能不能跑:同样的并发量,平台线程根本建不出来。

再补一个纯创建成本的对比(不 sleep,只创建 + 执行一个空任务):

java 复制代码
for (int n : new int[]{1_000, 10_000, 100_000}) {
    long t0 = System.nanoTime();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < n; i++) executor.submit(() -> null);
    }
    System.out.printf("创建 %d 个虚拟线程:%d ms%n", n, (System.nanoTime() - t0) / 1_000_000);
}

十万量级通常在几百毫秒内完成 ------ 换成平台线程,这个数字你不用试了。


2. 虚拟线程是什么

2.1 和平台线程的对照

平台线程 虚拟线程
实现 OS 线程的包装,1:1 JVM 管理的用户态线程,M:N 映射到平台线程
创建时一次性预留 1MB 存在堆上,按需增长,初始只有几百字节
创建成本 微秒级 + 系统调用 约等于创建一个对象
数量上限 几千 十万、百万量级(实际瓶颈会转移到别处)
调度者 操作系统 JVM(默认用 ForkJoinPool 做调度器)
是否 daemon 可配 永远是 daemon
优先级 可配(1~10) 固定 NORM_PRIORITY改不了(设了也不报错,直接忽略)
是否可池化 不应该

一句话概括:虚拟线程是 JVM 自己调度的一层"轻量线程",跑在少量平台线程之上。

2.2 挂载与卸载:虚拟线程为什么"便宜"

这是理解虚拟线程的关键机制,花两分钟看懂它,后面所有注意事项都顺理成章。

复制代码
虚拟线程  ──挂载(mount)──▶  平台线程(载体线程 carrier)  ──▶  OS 线程  ──▶  CPU
         ◀──卸载(unmount)──
  • 挂载:调度器把一个虚拟线程"装"到一个平台线程上,此时这段代码才真正占用 CPU 执行。
  • 卸载 :虚拟线程执行到阻塞操作时,JVM 会把它从载体线程上摘下来,载体线程立刻空出来去跑别的虚拟线程。
  • 恢复:等阻塞的操作就绪(比如 socket 收到字节了),虚拟线程重新排队,被挂载到某个平台线程上继续执行。

哪些操作会触发卸载? JDK 里所有标准的阻塞点都改过了:

java 复制代码
// 下面这些在虚拟线程里都不会占用载体线程
socket.getInputStream().read(buf);   // Socket / NIO Channel 阻塞读写
queue.take();                        // BlockingQueue
lock.lock();                         // ReentrantLock(内部走 LockSupport.park)
future.get();                        // Future 等待
Thread.sleep(1000);                  // 睡眠
CountDownLatch.await();              // 各类同步器

这就是全部秘密:阻塞不再占用 OS 线程。所以 4 个 CPU 核也能同时"跑"十万个卡在 IO 上的虚拟线程------因为它们大部分时间根本没在跑,只是在等。

怎么证明卸载真的发生了? 用一个比"跑得快"更硬的证据 ------ 看载体线程的数量:

java 复制代码
// 起 1000 个虚拟线程,每个睡 2 秒
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(2));
            return null;
        });
    }
}

// 同一时刻打印平台线程总数
System.out.println("平台线程总数 = " + Thread.getAllStackTraces().size());

在一台 8 核机器上,输出通常在 20~30 之间(载体线程 + JVM 自身的线程),而不是 1000。

这里用的是 Thread.getAllStackTraces().size(),它的 javadoc 明确写的是"all live platform threads",即只统计平台线程 。别用 Thread.activeCount(),它同样只统计平台线程(而且是当前线程组及其子组的估算值),口径不一样,容易误判。想看 OS 层面的真实线程数,直接看监控里的 OS 线程数指标。

如果换成 Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory()),同一个程序会真的去创建 1000 个 OS 线程。这一组对比能直接把"卸载"这件事坐实。

2.3 调度器

虚拟线程默认由一个内部的 ForkJoinPool 调度:

  • **并行度(carrier 线程数)**默认等于 availableProcessors()
  • -Djdk.virtualThreadScheduler.parallelism=N 可以调;
  • 上限由 -Djdk.virtualThreadScheduler.maxPoolSize=N 控制,默认 256

两个提醒:

  1. 这个池子和你业务用的线程池不是一回事,别去改它的队列、也别把它当业务线程池用。
  2. 并行度 = 载体线程数 = 真正能同时跑 CPU 的线程数。虚拟线程再多,CPU 密集的部分也只能用这几个核。这直接解释了第 4 节那条"CPU 密集不要用虚拟线程"。

3. 怎么用

方式一:直接起一个虚拟线程

java 复制代码
// 最省事的写法
Thread.startVirtualThread(() -> {
    // 任务逻辑
});

// 需要控制线程名、异常处理器时用 Builder
Thread.ofVirtual()
      .name("order-task-", 1)          // 名字会自动带序号:order-task-1、order-task-2...
      .uncaughtExceptionHandler((t, e) -> log.error("虚拟线程 {} 异常", t.getName(), e))
      .start(() -> {
          // 任务逻辑
      });

方式二:用 ExecutorService(推荐,方便统一关闭)

java 复制代码
// 每个任务一个虚拟线程,不池化
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> {
            // 阻塞 IO:调接口、查库、读文件
            return doSomething(i);
        })
    );
}   // 出了 try 块自动 shutdown() ------ ExecutorService 从 JDK 19 起实现了 AutoCloseable

方式三:结构化并发(预览 API,别急着上生产)

java 复制代码
// StructuredTaskScope 截至 JDK 27 仍是预览 API,需要 --enable-preview
// 官方 JEP 草案计划 JDK 28 转正,API 还在变,这里只展示思路
try (var scope = StructuredTaskScope.open()) {
    Subtask<User>  user  = scope.fork(() -> findUser(id));
    Subtask<Order> order = scope.fork(() -> fetchOrder(id));
    scope.join();                       // 等两个子任务都完成
    return new Response(user.get(), order.get());
}
// 作用域结束时会保证所有子任务都已结束 ------ 不会有线程泄漏

它解决的是"一批子任务"的编排问题:父任务失败时自动取消所有子任务,不会出现"父任务抛异常、子任务还在后台跑"的经典泄漏。思路值得学,API 等转正再用。

三种写法的对照

写法 适用场景
Thread.startVirtualThread() 临时起一个后台任务,用完不管
Executors.newVirtualThreadPerTaskExecutor() 批量提交一批同构任务,需要统一等结果、统一关闭
StructuredTaskScope 有父子关系的一批子任务,需要自动取消和结果汇聚(预览中)

平台线程的对应写法 (同一套 API,把 ofVirtual 换成 ofPlatform 即可,用来做对比或做最后的兜底):

java 复制代码
// 每个任务一个平台线程(不限流!只用于对比或特殊场景)
ExecutorService platformExecutor =
        Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory());

// 单个平台线程
Thread.ofPlatform().name("worker-", 0).start(task);

3.1 真实场景一:批量调下游接口

最常见的用法 ------ 一个请求要并发调 N 个下游,然后等齐结果:

java 复制代码
public OrderDetail loadOrderDetail(long orderId) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        // 提交三个互不依赖的查询
        Future<Order>   f1 = executor.submit(() -> orderService.get(orderId));
        Future<User>    f2 = executor.submit(() -> userService.getByOrder(orderId));
        Future<List<Item>> f3 = executor.submit(() -> itemService.listByOrder(orderId));

        // 阻塞等待 ------ 这里虚拟线程会卸载,不占载体线程
        return new OrderDetail(f1.get(), f2.get(), f3.get());
    }   // try 结束会等所有任务跑完再返回
}

要点:

  • future.get() 在虚拟线程里是安全的,不占用 OS 线程,写起来跟同步代码一样;
  • try-with-resources 关闭时会等待所有已提交任务结束,不会出现"方法返回了后台还在跑";
  • 如果三个调用之间有依赖,直接顺序写就行,不用强行并行。

3.2 真实场景二:thread-per-request 的 Web 服务

虚拟线程最对口的场景。下面是一个不用任何框架的最小实现,能直观看到"一个请求一个线程":

java 复制代码
public class VirtualThreadServer {
    public static void main(String[] args) throws IOException {
        try (var serverSocket = new ServerSocket(8080, 100)) {
            try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
                while (true) {
                    Socket socket = serverSocket.accept();      // 主线程只负责接客
                    executor.submit(() -> handle(socket));      // 每个连接一个虚拟线程
                }
            }
        }
    }

    static void handle(Socket socket) {
        try (socket) {
            // 直接写阻塞式代码,不用回调
            var in  = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            var out = new PrintWriter(socket.getOutputStream(), true);

            String line;
            while ((line = in.readLine()) != null) {
                // 模拟一次下游 IO,阻塞 200ms
                Thread.sleep(200);
                out.println("echo: " + line);
            }
        } catch (Exception e) {
            // 别忘了处理异常,虚拟线程里的异常一样会往上冒
        }
    }
}

同样的代码把 newVirtualThreadPerTaskExecutor() 换成 newCachedThreadPool(),并发一上来就开始拒绝连接 / OOM。这就是虚拟线程的全部价值:代码一行不改,只换执行器


4. 必须记住的几条规矩

规矩一:不要池化虚拟线程。

这是和线程池思维冲突最大的一点,也是最容易写错的地方。虚拟线程创建成本极低、生命周期短、用完即弃 ,把它塞进 ThreadPoolExecutor 只会:

  • 白白增加一层调度开销;
  • 让本来能并发 10 万的任务被限制成池子大小;
  • 完全拿不到虚拟线程的好处。
java 复制代码
// ❌ 错误:把虚拟线程塞进固定大小的池子
new ThreadPoolExecutor(10, 10, 0, SECONDS, new LinkedBlockingQueue<>(),
                       Thread.ofVirtual().factory());

// ✅ 正确:一个任务一个虚拟线程
Executors.newVirtualThreadPerTaskExecutor();

规矩二:CPU 密集型任务不要用虚拟线程。

虚拟线程对 CPU 密集任务零收益 ------它不能凭空变出 CPU。而且因为虚拟线程切换要走 JVM 调度,纯计算场景下甚至比平台线程略慢。CPU 密集请用固定大小的线程池(N + 1)或 ForkJoinPool

规矩三:并发上去了,共享资源仍然是瓶颈。

虚拟线程解决的只是"线程不够用",它不会帮你变出更多的数据库连接、更多的下游配额。并发从 200 提到 20000 之后,新的瓶颈立刻转移到:

  • 数据库连接池(HikariCP 默认 10,这是最常见的下一个瓶颈);
  • 下游服务的 QPS 配额
  • Redis / MQ 的连接数
  • 内存(每个虚拟线程虽然便宜,但十万个活着的请求上下文仍然占堆)。

所以开虚拟线程之前,先把连接池这些"真瓶颈"算清楚。

规矩四:虚拟线程永远是 daemon 线程。

setDaemon(false) 在虚拟线程上会直接抛异常。这意味着:如果 JVM 里所有线程都是虚拟线程,JVM 会直接退出 。纯虚拟线程的应用(比如只靠 @Scheduled 撑着的)要特别注意,Spring 侧的解法是 spring.main.keep-alive=true(见第 8 节)。

规矩五:优先级设了也没用。

虚拟线程优先级固定 NORM_PRIORITYsetPriority() 不报错但被忽略。依赖线程优先级做调度控制的代码会失效。

规矩六:别在虚拟线程里做线程身份相关的优化。

有些库/代码会缓存"线程 → 资源"的映射(比如某些老版本的对象池、按线程分片的计数器、某些 profiling agent)。虚拟线程数量大且短命,这类按线程缓存的模式会退化成"每个请求一份",内存直接爆。

规矩七:不要在虚拟线程里提交任务到有界线程池再阻塞等待。

java 复制代码
// ❌ 虚拟线程阻塞等一个只有 10 个线程的池子 ------ 白折腾一层,还可能死锁
Future<String> f = smallPool.submit(() -> queryDb());
String r = f.get();   // 这里会 park,虚拟线程确实会卸载,但池子仍然是瓶颈

// ✅ 直接跑
String r = queryDb();

规矩八:CompletableFutureparallelStream() 不会自动用虚拟线程。

这是迁移时最容易漏掉的一条。这两个 API 默认都跑在 ForkJoinPool.commonPool() 上 ------ 和你开不开虚拟线程毫无关系 ,commonPool 里仍然是平台线程,并行度只有 CPU 核数 - 1

java 复制代码
// ❌ 默认走 commonPool,虚拟线程完全没参与
CompletableFuture.supplyAsync(() -> callRemote());

// ✅ 显式传入虚拟线程执行器
ExecutorService vtExecutor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(() -> callRemote(), vtExecutor);

// ❌ parallelStream 永远走 commonPool,没有开关能改
list.parallelStream().forEach(this::callRemote);

// ✅ 想并发处理,显式用虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    list.forEach(item -> executor.submit(() -> callRemote(item)));
}

判断方法很简单:只要 API 内部自己取 ForkJoinPool.commonPool(),就绕过了虚拟线程 。这类 API 还有不少(CompletableFuture*Async 系列、parallelStream、部分老框架的默认执行器),迁移时要专门过一遍。

规矩九:别让虚拟线程长时间存活,留意 GC 压力。

虚拟线程的栈是存在堆上的对象 (chunk),不是 OS 预留的连续内存。好处是省内存、能按需增长,代价是:活着没结束的虚拟线程,它的栈 chunk 是 GC 要扫描的活对象

  • 十万个阻塞在 IO 上、迟迟不返回的虚拟线程 = 十万个活着的栈对象 + 十万个请求上下文;
  • 这些对象大多很"老"(跨过多次 GC),会进入老年代,抬高 GC 成本;
  • 表现是堆占用涨、GC 频率涨。

所以超时控制比以往更重要 :给所有下游调用设 connectTimeout / readTimeout,给 future.get() 设超时。别让虚拟线程无限期挂着。

java 复制代码
// 给等待设上限,别让请求无限期挂住虚拟线程
try {
    return future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    future.cancel(true);
    throw new BizException("下游超时");
}

Oracle 官方的《Virtual Threads: An Adoption Guide》里同样把"不要在 ThreadLocal 里缓存昂贵的可复用对象"和"避免长时间、频繁的钉住"列为关键注意事项,规则六和第五节讲的正是这两条。


5. 钉住(Pinned):最该花时间搞懂的一节

什么是钉住? 虚拟线程在某些代码里阻塞时无法从载体线程上卸载,只能死死占着那个平台线程------等于退化成普通平台线程。这就是 pinned。

5.1 哪些情况会钉住

情况一:synchronized 块/方法内阻塞(JDK 21~23)

原因是 JVM 的监视器(monitor)记录的是载体平台线程 持有者,而不是虚拟线程。如果允许虚拟线程在 synchronized 里卸载,另一个虚拟线程装到同一个载体线程上就会被误判为"持有该锁",互斥就废了。所以 JVM 干脆禁止卸载。

java 复制代码
// JDK 21~23 上:read 阻塞时虚拟线程无法卸载 → 载体线程被占死
synchronized byte[] getData() {
    return socket.getInputStream().readAllBytes();   // 阻塞点
}

这条在 JDK 24 已经被修了。 JEP 491(Synchronize Virtual Threads without Pinning,JDK 24)重新实现了监视器,让虚拟线程可以在 synchronized 内卸载。所以:

JDK 版本 synchronized 内阻塞是否钉住
21 / 22 / 23 ,这是当年最大的坑
24+ (JEP 491 已修复)

如果你在 JDK 21~23 上跑,而且第三方库里大量用了 synchronized(很多老牌库都有),那就得认真评估------这也是为什么虚拟线程建议直接上 JDK 24 或更新的 LTS

亲手复现一次 (JDK 21~23 上跑,配合 -Djdk.tracePinnedVirtualThreads=short):

java 复制代码
public class PinDemo {
    private static final BlockingQueue<String> QUEUE = new ArrayBlockingQueue<>(1);
    private static final Object LOCK = new Object();

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 5; i++) {
            Thread.ofVirtual().name("vt-" + i).start(() -> {
                synchronized (LOCK) {
                    // 在 synchronized 里阻塞 ------ JDK 21~23 会钉住
                    try {
                        QUEUE.take();
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
            });
        }
        Thread.sleep(2000);          // 等它们全部卡在 take() 上
        System.out.println("平台线程数 = " + Thread.getAllStackTraces().size());
    }
}
bash 复制代码
java -Djdk.tracePinnedVirtualThreads=short PinDemo

JDK 21~23 上的输出里能看到每个虚拟线程都报了一次钉住,并且打印的栈最后一行带 monitors:1;JDK 24+ 上同样的代码一条都不会报 ,因为 take() 的阻塞已经可以正常卸载了。

这个对比很值得自己跑一遍 ------ 比看十篇结论文章都管用。

情况二:native 方法 / FFM 调用中阻塞(所有版本)

JEP 491 只解决了 synchronized,在 native 方法或 Foreign Function 调用里阻塞仍然会钉住。这在纯 Java 业务代码里很少见,但用了 JNI 的库要留意。

5.2 怎么查

bash 复制代码
# 打印钉住时的完整栈(JDK 21+)
java -Djdk.tracePinnedVirtualThreads=full -jar app.jar

# 只打印简略信息
java -Djdk.tracePinnedVirtualThreads=short -jar app.jar

输出长这样(注意最后一行的 monitors:1,这是钉住的标志):

复制代码
Thread[#21,ForkJoinPool-1-worker-1,5,CarrierThreads]
    java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
    java.base/java.lang.System$2.wait(System.java:2555)
    com.example.OrderService.getData(OrderService.java:42) <== monitors:1

两个提醒:

  • JDK 24+ 上这个开关基本不会再因为 synchronized 输出东西了,如果你还能看到,那多半是 native 调用导致的(5.1 情况二)。所以别一看到"没输出"就以为开关没生效,先确认版本。
  • full 模式会打印完整栈,压测环境下开销不小,只建议在线下用;线上用 JFR。

也可以用 JFR 看 jdk.VirtualThreadPinned 事件(适合线上采样,开销小):

bash 复制代码
java -XX:StartFlightRecording:filename=recording.jfr,settings=profile -jar app.jar
# 之后用 JDK Mission Control 打开,看 Virtual Thread 相关事件

不想装 JMC 的话,用命令行也能直接把钉住事件打出来:

bash 复制代码
jfr print --events jdk.VirtualThreadPinned recording.jfr

5.3 遇到钉住怎么办

  1. 升级到 JDK 24+synchronized 那条直接消失,90% 的问题不用改代码。
  2. 卡在 JDK 21~23 的话,把阻塞点移出 synchronized,或者换成 ReentrantLock
java 复制代码
// 改前:synchronized 里做 IO
synchronized void refresh() {
    this.data = remoteApi.fetch();   // 阻塞 → 钉住
}

// 改后:用 ReentrantLock,阻塞时可以正常卸载
private final Lock lock = new ReentrantLock();
void refresh() {
    lock.lock();
    try {
        this.data = remoteApi.fetch();
    } finally {
        lock.unlock();
    }
}

6. 诊断与观测

虚拟线程时代,几个老工具的行为变了,容易踩空:

jstack 看不到虚拟线程的栈。 它只打印平台线程。要看虚拟线程栈必须用:

bash 复制代码
jcmd <pid> Thread.dump_to_file -format=json <输出文件>
# 必须用 json 格式,纯文本格式的 dump 不包含虚拟线程

"线程数"这个指标会骗人 ------ 而且是往小了骗。 这是虚拟线程时代最反直觉的一点:

  • Thread.activeCount() 的 javadoc 写的是"live platform threads",不统计虚拟线程
  • JMX 里的 ThreadMXBeangetThreadCount() / getAllThreadIds())同样看不到虚拟线程;
  • 也就是说,你的系统里可能同时跑着 5 万个虚拟线程,而监控面板上的线程数曲线依然是 30 ------ 一片祥和

所以线程数指标不是"太大没意义",而是根本不反映真实并发。该看的指标换成:

  • 在途请求数 / 并发数 (在网关或拦截器里自己埋点,AtomicLong 加减即可);
  • 请求排队时间 / P99 延迟;
  • 数据库连接池使用率(这个最能反映真实压力);
  • 下游调用的并发数和超时率;
  • JFR 里的 jdk.VirtualThreadPinned 事件数。

虚拟线程专属的 JFR 事件(JDK 21+,线上采样开销很小):

事件 含义 关注理由
jdk.VirtualThreadStart 虚拟线程启动 频率过高说明任务粒度太碎
jdk.VirtualThreadEnd 虚拟线程结束 和 Start 配对看,差值 = 当前存活数
jdk.VirtualThreadPinned 发生钉住 线上重点监控,应长期为 0 或极低
jdk.VirtualThreadSubmitFailed 提交失败(调度器资源不足) 出现说明并发已经超出调度能力
bash 复制代码
# 只看虚拟线程相关事件
jfr print --events "jdk.VirtualThread*" recording.jfr

命名习惯要调整。 虚拟线程数量大又短命,靠默认编号排查效率很低。默认名字是空字符串,所以一定要显式命名,而且建议带上业务标识:

java 复制代码
Thread.ofVirtual().name("req-" + requestId).start(task);

// 或者用 Builder 的前缀 + 自增序号
Thread.Builder builder = Thread.ofVirtual().name("order-task-", 0);

Oracle 官方的《Virtual Threads: An Adoption Guide》里也把"给虚拟线程起名字"列为调试的基础前提。

栈会很深、很多。 一次 dump 可能包含上万个虚拟线程栈,文件几百 MB 很正常,别被吓到。


7. ThreadLocal 与 ScopedValue

虚拟线程完全支持 ThreadLocal (包括 InheritableThreadLocal),已有代码不用改。

代价变了 :以前平台线程只有几百个,每个挂一个 ThreadLocal<Map> 无所谓;虚拟线程十万级的时候,ThreadLocal 持有的对象总量会变成实打实的内存压力,而且因为虚拟线程短命,这些数据刚建好就扔,纯浪费。

替代方案是 ScopedValue(JEP 506,JDK 25 转正)

java 复制代码
// JDK 25+ 正式 API
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();

void handle(Request req) {
    UserContext ctx = authenticate(req);
    // 在作用域内绑定,作用域结束自动消失
    ScopedValue.where(CONTEXT, ctx).run(() -> {
        bizService.process();      // 被调用方可以 CONTEXT.get()
    });
    // 出了这里 CONTEXT 就没了,不需要 remove()
}

// 任意深度的被调用方直接读
class BizService {
    void process() {
        UserContext ctx = CONTEXT.get();
    }
}

相比 ThreadLocal,ScopedValue 有三个好处:

  1. 不可变,只能在作用域入口绑定,里面改不了------不会有"某个中间层偷偷改了上下文"的问题;
  2. 生命周期由代码结构决定 ,出了 run() 自动消失,不用 remove(),也就没有忘 remove() 导致的脏数据;
  3. 对虚拟线程做了专门优化,读写成本更低。

7.1 代价到底有多大:算一笔账

别只记住"ThreadLocal 有代价",把它量化一下:

java 复制代码
// 一个很常见的写法:请求进来塞上下文,切出去靠 ThreadLocal 传递
private static final ThreadLocal<Map<String, Object>> CTX = new ThreadLocal<>();

void handle(Request req) {
    CTX.set(new HashMap<>(req.headers()));   // 假设一个 Map 加内容约 1 KB
    try {
        chain.doFilter(req);
    } finally {
        CTX.remove();                        // 别忘了这行
    }
}
场景 同时存活的线程 ThreadLocal 占用的堆内存
平台线程(线程池 200) 200 200 × 1KB ≈ 0.2 MB,可忽略
虚拟线程(并发 50,000) 50,000 50,000 × 1KB ≈ 50 MB,且都是活对象

50MB 看起来还能接受,但要命的是这三点:

  • 这些对象大多能活过几轮 GC,会晋升到老年代,抬高 GC 成本;
  • 如果虚拟线程因为下游慢而长时间挂着(见规则九),这些内存会被持续占用;
  • 框架层的 ThreadLocal 往往不止一个 ------ 日志 MDC、链路追踪、Spring 的 RequestContextHolder、Security 上下文......叠加起来是好几份。

InheritableThreadLocal 更危险 :子线程创建时会复制 父线程的一整份 map。在虚拟线程下如果大量创建子线程,这个复制会放大成 N 份完整副本。所以虚拟线程场景下,InheritableThreadLocal 能不用就不用 ------ 需要给子任务传上下文,用 ScopedValue(它天然支持子线程可见,且不需要复制)。

实操建议

情况 建议
已有代码用了 ThreadLocal 不用改,能正常工作;确保 finally 里有 remove()
JDK 21~24,不开预览特性 继续 ThreadLocal,但要控制里面放的东西别太大
JDK 25+ 写新代码 直接用 ScopedValue
用了 InheritableThreadLocal 优先改掉,这是放大效应最严重的一类

版本提示:ScopedValue 在 JDK 21~24 是预览 API,JDK 25 转正。如果你的基线是 21 且不开 --enable-preview,就继续用 ThreadLocal,只要在虚拟线程结束时记得清理就行。


8. Spring Boot 3.2+ 集成

8.1 开关

yaml 复制代码
spring:
  threads:
    virtual:
      enabled: true      # 需要 Java 21+
  main:
    keep-alive: true     # 强烈建议一起配上,原因见下

就这一个开关,Spring Boot 会把它应用到:

组件 开启后的行为
@Async 改用 SimpleAsyncTaskExecutor,每个任务一个虚拟线程
Web 容器(Tomcat/Undertow/Jetty) 请求处理改用虚拟线程(Boot 3.2 起有 TomcatVirtualThreadsWebServerFactoryCustomizer 做这件事)
@Scheduled 改用 SimpleAsyncTaskScheduler
WebFlux 的阻塞调用 Reactor 的 BoundedElastic scheduler 改用虚拟线程

8.2 开了之后必须知道的四件事

1. 所有线程池配置全部失效。

官方文档原话:"If virtual threads are enabled, properties which configure thread pools don't have an effect anymore."

spring.task.execution.pool.*(core-size / max-size / queue-capacity)全都不起作用了。这同时意味着队列和拒绝策略没有了 ------ 任务提交即执行,不再排队。

如果你的系统靠"有界队列 + 拒绝策略"做自我保护,这个保护就消失了。替代方案是用 Semaphore 或 Resilience4j 的 Bulkhead 显式限流:

java 复制代码
// 显式限流:最多 500 个并发,超出的在这里等
private final Semaphore limiter = new Semaphore(500);

public Result handle(Request req) throws InterruptedException {
    limiter.acquire();
    try {
        return doHandle(req);
    } finally {
        limiter.release();
    }
}

2. 虚拟线程都是 daemon,JVM 可能直接退出。

没有非 daemon 线程撑着时 JVM 会退出,@Scheduled 之类的后台任务就跑不起来了。解决:spring.main.keep-alive=true

3. 数据库连接池才是真瓶颈。

开了虚拟线程,Tomcat 能同时处理几万个请求,但 HikariCP 默认只有 10 个连接。所有请求会堵在 getConnection() 上------虚拟线程能帮你优雅地堵在这里,但不能让你不堵

yaml 复制代码
spring:
  datasource:
    hikari:
      maximum-pool-size: 50    # 按 DB 实际承受能力调,别盲目调大
      connection-timeout: 3000

4. 检查依赖里有没有钉住风险。

先看 JDK 版本:JDK 24+ 基本不用管;JDK 21~23 的话,用 -Djdk.tracePinnedVirtualThreads=short 压测一轮,看哪些库会触发。

8.3 一个典型配置

yaml 复制代码
spring:
  threads:
    virtual:
      enabled: true
  main:
    keep-alive: true
  datasource:
    hikari:
      maximum-pool-size: 50
      minimum-idle: 10
      connection-timeout: 3000
server:
  tomcat:
    connection-timeout: 10s

启动后验证一下是否生效:

java 复制代码
@Component
class ThreadCheck implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        Thread t = Thread.currentThread();
        log.info("当前线程 isVirtual={}, name={}", t.isVirtual(), t.getName());
    }
}

8.4 不想全局开启:只给部分 @Async 用虚拟线程

全局开关是"一刀切",实际项目里更稳妥的做法通常是混着用 ------ IO 型异步方法走虚拟线程,CPU 型或需要限流的继续走线程池:

java 复制代码
@Configuration
@EnableAsync
public class AsyncConfig {

    // 只有显式写 @Async("virtualExecutor") 的才走虚拟线程
    @Bean("virtualExecutor")
    public SimpleAsyncTaskExecutor virtualExecutor() {
        var executor = new SimpleAsyncTaskExecutor("vtask-");
        executor.setVirtualThreads(true);      // Spring Framework 6.1+
        executor.setConcurrencyLimit(500);     // 仍然可以限并发,相当于自己兜底
        return executor;
    }

    // 其余 @Async 继续用平台线程池,保留有界队列和拒绝策略
    @Bean("poolExecutor")
    public ThreadPoolTaskExecutor poolExecutor() {
        var executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("pool-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}
java 复制代码
@Async("virtualExecutor")
public void pushMessage(Long userId) {
    // IO 密集:发推送、调三方接口
}

@Async("poolExecutor")
public void generateReport(Long id) {
    // CPU 密集 / 需要限流:报表计算、批量导出
}

这样 spring.threads.virtual.enabled 就不用开了,Web 容器仍然走平台线程池(如果业务还没验证过虚拟线程下的表现,这其实是更稳的过渡方案)。

注意:一旦你定义了自己的 Executor / ThreadPoolTaskExecutor Bean,TaskExecutionAutoConfiguration 就会退让,spring.task.execution.* 配置不再生效 ------ 这条规则和全局开关无关,和线程池篇里讲的是同一条。

8.5 Web 容器的线程配置会失效

全局开关打开后,Boot 会把 Tomcat 协议处理器的执行器换成虚拟线程版(内部就是 TomcatVirtualThreadsWebServerFactoryCustomizer 干的)。结果是:

  • server.tomcat.threads.max / min-spare 这类针对平台线程池的配置不再起作用
  • 每个请求一个虚拟线程,不再有"请求排队等线程"这回事;
  • 这意味着请求侧的并发保护也一起没了 ------ 需要在网关层或 Semaphore 里补回来。

Undertow、Jetty 同理。所以如果你依赖容器线程数做容量控制(很多人其实都在依赖,只是没意识到),开全局开关前一定要先想清楚替代方案。

8.6 上下文传递:虚拟线程下问题没消失

一个常见误解是"用了虚拟线程,上下文就自动串起来了"。并没有 ------ 换线程这件事本身没变

  • Spring 的 RequestContextHolderSecurityContextHolder(默认 MODE_THREADLOCAL)在虚拟线程里照常工作 ,因为每个虚拟线程仍然是独立的 Thread
  • 但只要 @AsyncCompletableFuture 换到了另一个线程,上下文就断了,和平台线程时代一模一样;
  • 全局开关打开后 ThreadPoolTaskExecutor 被换掉了,原来靠 setTaskDecorator(...) 传 MDC 的那套要跟着换:
java 复制代码
@Configuration
public class TaskDecoratorConfig {

    @Bean
    public SimpleAsyncTaskExecutorCustomizer mdcCustomizer() {
        // 对 SimpleAsyncTaskExecutor(含虚拟线程版)统一套一层上下文传递
        return executor -> executor.setTaskDecorator(runnable -> {
            var ctx = MDC.getCopyOfContextMap();
            return () -> {
                try {
                    if (ctx != null) MDC.setContextMap(ctx);
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        });
    }
}

(如果走的是 8.4 那种混用方案,ThreadPoolTaskExecutor 还在,就继续用 setTaskDecorator(...),不用改。)


9. 什么时候仍然要用线程池

虚拟线程不是万能的。这张表是本文最实用的部分:

场景 该用什么 原因
阻塞 IO 为主、并发量高 newVirtualThreadPerTaskExecutor() 阻塞不占 OS 线程,同步代码最好维护
CPU 密集型计算 ThreadPoolExecutor(N+1)或 ForkJoinPool 虚拟线程对 CPU 零收益,甚至略慢
需要严格限流 / 背压 ThreadPoolExecutor(有界队列 + 拒绝策略) 有界队列和拒绝策略是现成的保护机制
定时 / 周期任务 ScheduledThreadPoolExecutor 虚拟线程没有调度能力
需要控制并发上限的下游调用 线程池,或虚拟线程 + Semaphore 保护下游
任务之间需要隔离(互不影响) 多个独立线程池 一个池打满不会拖垮另一个
池化的稀缺资源(连接、线程绑定的缓存) 线程池 资源本身就得复用

一句话判断标准 :如果你的任务大部分时间在 (等 IO、等锁、等下游),用虚拟线程;如果大部分时间在,用线程池。

9.1 混合架构:一个真实可落地的搭配

大多数线上系统其实是三层混用,下面是一个完整的配置示例:

java 复制代码
@Configuration
public class HybridExecutorConfig {

    // ① Web 请求层 ------ 交给 spring.threads.virtual.enabled=true,容器每个请求一个虚拟线程
    //    这层不需要自己建执行器

    // ② CPU 密集计算 ------ 固定大小线程池,别去抢载体线程
    @Bean("cpuExecutor")
    public ThreadPoolTaskExecutor cpuExecutor() {
        int n = Runtime.getRuntime().availableProcessors();
        var executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(n + 1);
        executor.setMaxPoolSize(n + 1);
        executor.setQueueCapacity(500);                 // 有界,满了触发拒绝策略
        executor.setThreadNamePrefix("cpu-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    // ③ 定时 / 周期任务 ------ 必须有线程池,虚拟线程没有调度能力
    @Bean("taskScheduler")
    public ThreadPoolTaskScheduler taskScheduler() {
        var scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(4);
        scheduler.setThreadNamePrefix("sched-");
        scheduler.initialize();
        return scheduler;
    }
}

业务代码里这样用:

java 复制代码
@RestController
@RequiredArgsConstructor
class ReportController {

    private final ThreadPoolTaskExecutor cpuExecutor;   // 注入上面那个

    @GetMapping("/report")
    public Report report(@RequestParam long id) throws Exception {
        // 这一段跑在虚拟线程上:查库、调下游,全是阻塞 IO
        RawData raw = dataService.fetch(id);
        List<Detail> details = detailClient.query(id);

        // 计算密集的部分挪到固定线程池 ------ 避免占住载体线程拖慢所有请求
        Future<Report> f = cpuExecutor.submit(() -> ReportBuilder.build(raw, details));
        return f.get(5, TimeUnit.SECONDS);   // 等待时虚拟线程会卸载,不占资源
    }
}

这里要和规则七区分开(不然看着像自相矛盾):

做法 判断
IO 提交给一个小线程池再 get() ❌ 白折腾,池子成了瓶颈,虚拟线程的优势没了
CPU 计算 提交给固定大小线程池再 get() ✅ 正确,把计算从载体线程上挪走,保护全局吞吐

判断依据就一条:你挪出去的那段代码,会不会占用 CPU? 会 → 值得挪;不会(纯等待)→ 别挪。


10. 迁移实操清单

按这个顺序做,能避开大部分坑:

第 1 步:确认版本

  • JDK:建议 24+ 或更新的 LTS 。21~23 能用,但要额外处理 synchronized 钉住的问题。
  • Spring Boot:3.2+。

第 2 步:先做只读评估,别急着改代码

bash 复制代码
# 用现有代码压一轮,看有多少钉住
java -Djdk.tracePinnedVirtualThreads=short -jar app.jar

同时统计一下:系统里有多少 synchronized 块里面包含 IO 操作(可以搜 synchronized 关键字人工过一遍)。

第 3 步:梳理共享资源上限

列一张表:DB 连接池、Redis 连接数、MQ 连接数、下游 QPS 配额。这些决定了开启虚拟线程后系统的真实上限。

第 4 步:改配置

  • 开启开关(Spring)或替换 ExecutorService 实现(原生);
  • 删掉那些"为了撑并发而调大的线程池配置"------它们现在是负担;
  • 如果是 Spring,加上 spring.main.keep-alive=true

第 5 步:补上限流

原来靠队列容量和拒绝策略做的保护没了,用 Semaphore / Resilience4j 补回来。

第 6 步:压测对比

对比指标别只看吞吐,重点看:

  • P99 延迟(虚拟线程通常改善明显);
  • 下游和 DB 的实际负载(会显著上升,这是预期内的);
  • CPU 使用率(如果 CPU 打满而吞吐没涨,说明瓶颈在计算,虚拟线程帮不上忙);
  • 钉住事件数(应为 0,或至少不增长)。

第 7 步:观测改造

  • 把"线程数"从告警指标里去掉(它不统计虚拟线程,见第 6 节);
  • 加上在途请求数、DB 连接池使用率、P99、钉住事件的监控;
  • dump 改用 jcmd Thread.dump_to_file -format=json

第 8 步:准备回滚预案

上线前先把退路留好,别等出问题再想:

  • 全局开关方案:回滚就是把 spring.threads.virtual.enabled 改回 false 重启 ------ 前提是第 4 步里那些被删掉的线程池配置要留一份备份,别彻底删干净;
  • 混用方案(8.4):回滚只需把 @Async("virtualExecutor") 改成 @Async("poolExecutor")
  • 灰度顺序建议:先上一个只读/低风险接口,观察 1~2 天,再逐步放开。

11. 常见误区

Q:虚拟线程比平台线程快吗?

A:不快 。单次任务的执行时间一模一样,甚至调度开销略大。它的价值是并发容量------同样的机器能同时处理更多请求,而不是单个请求更快。

Q:能不能把虚拟线程和线程池一起用?

A:能,但要想清楚边界。典型组合:Web 请求层用虚拟线程(撑并发),内部的 CPU 密集计算提交给固定大小的线程池(别抢载体线程)。但不要把虚拟线程放进线程池

Q:虚拟线程会不会让线程池的知识点作废?

A:不会。corePoolSize / 工作队列 / 拒绝策略这套东西在 CPU 密集、需要限流、需要隔离的场景下依然是唯一选择。虚拟线程只是把"IO 密集型"这块从线程池手里接了过去。

Q:Executors.newFixedThreadPool 换成 newVirtualThreadPerTaskExecutor 就完事了吗?

A:这是最常见的错误迁移。换了之后你会发现:并发上去了,然后 DB 连接池先崩。虚拟线程解决的是线程瓶颈,不是资源瓶颈

Q:虚拟线程能跑几十万个,那我开一百万试试?

A:技术上能做,但没有意义。并发能力最终由下游和共享资源决定,开到一百倍只会让所有请求排队等数据库连接,延迟反而更差。

Q:ThreadLocal 还能用吗?

A:能,完全兼容。只是数量级变了之后要留意内存;新代码建议用 ScopedValue(JDK 25+)。

Q:我的项目还在 Java 8 / 11,能用吗?

A:不能,必须升到 21+ 。虚拟线程是 JVM 层面的实现(JEP 444),没有任何第三方库能在 Java 8 上模拟出来。所以"上虚拟线程"本质上是一个升级 JDK 的决策,不是改几行配置的事。反过来想:如果项目近期没有升级 JDK 的计划,那线程池那套知识你得继续用好。

Q:JDK 24+ 之后,synchronized 还需要改成 ReentrantLock 吗?

A:不需要,别为了虚拟线程去改 。JDK 24 起 synchronized 内阻塞已经能正常卸载了(JEP 491),synchronized 的语义更清晰、不容易忘记解锁,继续用就行。只有在 JDK 21~23 上、且确认某个 synchronized 里的 IO 造成了钉住时,才有必要局部替换。

Q:虚拟线程里的异常怎么处理?没捕获会怎样?

A:和平台线程一样 ------ 未捕获异常会交给 UncaughtExceptionHandler,默认行为是打印栈到 System.err不会让 JVM 退出)。麻烦的是:虚拟线程数量大,异常一多日志会被冲垮,而且默认输出不带业务标识。所以一定要配:

java 复制代码
Thread.ofVirtual()
      .name("order-" + orderId)
      .uncaughtExceptionHandler((t, e) -> log.error("[{}] 虚拟线程异常", t.getName(), e))
      .start(task);

ExecutorService.submit() 提交的也一样:异常被封进 Future不调 get() 就永远看不到 。要么 get(),要么在任务内部自己 try-catch 打日志。

Q:用了虚拟线程,是不是就不用关心拒绝策略了?

A:恰恰相反,保护机制从"自动"变成了"你得自己写" 。线程池时代,有界队列 + 拒绝策略是默认自带的刹车;虚拟线程没有队列,任务提交即执行,刹车没了。所以必须自己补一道限流(Semaphore、Resilience4j Bulkhead、网关限流都行),否则下游抖动时会把压力原封不动地传导下去。

Q:虚拟线程和数据库连接池的关系?池子要调大吗?

A:连接池不要盲目调大,它现在成了系统的主闸门。合理的顺序是:

  1. 先测出数据库本身能承受多少并发连接(一般 50~200 之间,太多反而因锁竞争变慢);
  2. 把 HikariCP 的 maximum-pool-size 设到这个值附近;
  3. 再用 Semaphore 把进入业务代码的并发数限制在连接池大小附近。

让请求堵在 Semaphore 上(快速失败、可控)比堵在 getConnection() 上(超时、雪崩)要好得多。


附:版本演进速查

版本 变化
JDK 19 虚拟线程作为预览 API 首次亮相(JEP 425)
JDK 21 虚拟线程转正(JEP 444),LTS
JDK 21~23 synchronized 内阻塞会钉住载体线程
JDK 24 JEP 491:修复 synchronized 钉住问题
JDK 25 JEP 506:ScopedValue 转正,LTS
JDK 25 / 26 / 27 StructuredTaskScope 仍在预览(JEP 505 / 525 / 533),官方草案计划 JDK 28 转正

结论:要用虚拟线程,直接上 JDK 25(最新 LTS)最省心------钉住问题已经修掉,ScopedValue 也转正了。

相关推荐
大猫和小黄5 小时前
吃透Java泛型:从原理避坑到实战落地
java·泛型
Bingo_BIG5 小时前
Java Spring框架:单表的查询、新增、修改、删除、导入、导出
java·spring·前后端
程序猿_极客5 小时前
【免费】分享一套优质的基于SSM的校园失物招领系统的设计与实现,源码+文档+视频详解(讲解)
java·mysql·ssm·课程设计·失物招领系统
白远山6 小时前
上海24小时自助健身房解决方案实战指南与经验分享
java·数据库·架构·需求分析
码兄科技6 小时前
24小时自助健身房系统软件开发实战指南:功能设计与部署方案
java·spring boot·uni-app
程序猿_极客6 小时前
【免费】分享一套优质的基于SpringBoot的扶贫助农管理系统(详解)
java·springboot·课程设计·计算机项目
2603_965148116 小时前
AI+API选品:下一代智能商务助手雏形已现
java·大数据·数据库·人工智能·数据挖掘
wuminyu7 小时前
HotSpot的轻量锁与膨胀后的ObjectMonitor状态重建原理
java·linux·c语言·jvm·c++