Java 虚拟线程实战:从线程池到虚拟线程的性能改造与坑位记录

2026-10-12 0 491

我手头有一个做了三年的数据采集服务,负责从十几个上游接口抓数据,每个请求耗时很不均匀——有的 50 毫秒返回,有的要等三四秒。服务跑在一台 8 核 16G 的机器上。原来的实现非常朴素:固定 200 个线程的池子,队列大小 500。

平时流量不大,200 个线程绰绰有余。怕的是上游抽风。上一次某个接口响应从 80 毫秒涨到 5 秒,200 个线程几分钟就被占满,队列眨眼堆到 500,之后所有请求直接拒绝。整个服务差不多半小时处于半瘫状态。

Java 21 把虚拟线程正式做出来之后,”一个任务一个线程”这种老派的写法终于不用再心疼。理论上这种 IO 密集、单任务又长的场景就是给虚拟线程量身定做的。上个月我把这个服务改了一遍,过程中撞到两个坑,这篇文章把整条路径记录下来。

改之前的代码长什么样

去掉业务细节,核心就是一个 ExecutorService:

public final class FetcherService implements AutoCloseable {

    private final ExecutorService pool = new ThreadPoolExecutor(
        200, 200,
        60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(500),
        new ThreadPoolExecutor.AbortPolicy()
    );

    public String fetch(String url) throws Exception {
        Future<String> future = pool.submit(() -> httpGet(url));
        try {
            return future.get(10, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            future.cancel(true);
            throw e;
        }
    }

    private String httpGet(String url) throws IOException {
        // 使用 java.net.http.HttpClient
        ...
    }

    @Override
    public void close() {
        pool.shutdown();
    }
}

这段代码有两个特点:池子大小是硬边界,请求排队靠 ArrayBlockingQueue。当上游慢下来的时候,队列是唯一缓冲,一旦满了就 AbortPolicy 拒绝——和前面描述的现场一致。

改造成虚拟线程有两种方向。一种是把线程池换成 Executors.newVirtualThreadPerTaskExecutor(),另一种是把 Thread.Builder 拿出来直接 startVirtualThread。前者改动最小,语义上依然是一个 Executor,submit/get 的用法完全不变。

第一版:一行替换

private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

就这一行。跑起来,功能正常。之后压了一次,发现了一个奇怪的数字:

固定线程池,200 并发:     吞吐量约  1800 req/s
虚拟线程池,无限并发:      吞吐量约  1450 req/s

不仅没变快,反而慢了一点。虚拟线程理论上在 IO 密集场景要比平台线程高效得多,这里却没有体现出来,说明有个瓶颈挡在路上了。

先用 jcmd 看一眼线程状态:

jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json

输出里能看到大量虚拟线程的状态是 RUNNABLE,但 CPU 使用率并不高。RUNNABLE 还占着载体(carrier)线程不放——这是典型的”线程固定“(pinning)症状。虚拟线程被固定在了它的载体线程上,不能在被阻塞时把载体让出来干别的活。

虚拟线程被”固定”的常见原因只有两个:

  • 在 synchronized 块或方法里执行了阻塞操作
  • 调用了 native 方法或 Foreign Function 接口,且该调用阻塞了载体线程

第二个不常见,第一个几乎是一抓一个准。翻代码,果然:

private final Object lock = new Object();

private String httpGet(String url) throws IOException {
    synchronized (lock) {
        // 有一段缓存读取逻辑
        String cached = cache.get(url);
        if (cached != null) return cached;

        // 真正发请求
        String body = doRequest(url);
        cache.put(url, body);
        return body;
    }
}

这是一段三年前写的缓存逻辑,本来用了 synchronized 是想避免同一 URL 并发穿透。问题就在于 doRequest 这个阻塞操作——HTTP 请求——被放在了 synchronized 块里。

为什么 synchronized 会固定虚拟线程

要理解这个问题,得先知道 synchronized 在 JVM 里的实现方式。

synchronized 在字节码层面用的是 monitorenter / monitorexit 指令,对应对象头里的 Monitor。Monitor 的所有权是绑定在操作系统线程上的——它知道是哪个 OS 线程持有了这个锁,也是为了应对操作系统层面的线程抢占和优先级处理。

虚拟线程是 JVM 自己调度的,多个虚拟线程会跑在同一个平台线程之上。当虚拟线程要阻塞(比如等 HTTP 返回)的时候,正常情况下 JVM 会把这个虚拟线程从载体上摘下来,把载体还给别的虚拟线程用。但如果此刻它正持有一个 Monitor,JVM 不能把虚拟线程摘走——因为 Monitor 的持有者信息还挂在载体线程上,摘走了就没人来释放了。于是它只能让载体线程一直等着,这就是”固定”。

固定本身不是 bug,只是性能损失。但如果一个老服务里到处都是 synchronized,性能损失会累积到完全体现不出虚拟线程的优势,甚至会因为大量载体线程被白白占用而变得更慢。

官方的建议很明确:需要保护临界区的地方,应该用 ReentrantLock 替代 synchronized。前者是 java.util.concurrent.locks 里的类,不绑定在 OS 线程上,虚拟线程可以被正确挂起。

改造临界区

private final ReentrantLock cacheLock = new ReentrantLock();

private String httpGet(String url) throws IOException {
    cacheLock.lock();
    try {
        String cached = cache.get(url);
        if (cached != null) return cached;

        String body = doRequest(url);
        cache.put(url, body);
        return body;
    } finally {
        cacheLock.unlock();
    }
}

再压一次:

固定线程池,200 并发:     吞吐量约  1800 req/s
虚拟线程池,第一次改造:    吞吐量约  1450 req/s
虚拟线程池,替换锁之后:    吞吐量约  9200 req/s

差了两个数量级。最直观的一个变化是并发上限从”池子大小”变成了”下游允许的连接数”,在测压环境没有开连接限流的前提下,虚拟线程把上游的响应时间完全重叠掉了。

值得注意的是,这一切都没有修改 HTTP 客户端的任何参数,也没有换异步编程模型。代码还是同步的、一个线程一个请求,只是换了一个执行器,改了一个锁。

第二个坑:ThreadLocal

调优到这里,功能已经稳定了,但还有一件事值得处理一下。项目里有一段老的日志 MDC 逻辑:

public class TraceIdHolder {
    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

    public static void set(String id) {
        TRACE_ID.set(id);
    }

    public static String get() {
        return TRACE_ID.get();
    }

    public static void clear() {
        TRACE_ID.remove();
    }
}

在虚拟线程模型里,这个类的语义会悄悄发生变化。

表面上看它是线程私有的,TRACE_ID.set() 只影响当前线程。但虚拟线程的创建成本极低,允许一个进程里同时存在几十万乃至上百万个虚拟线程。每一个虚拟线程都有自己独立的 ThreadLocal 存储,一份 ThreadLocalMap。如果忘记 remove,而线程生命周期又很长,内存就会被慢慢撑起来。

虚拟线程被切换到不同载体上的时候,它的 ThreadLocalMap 也跟着它一起走。所以”线程销毁就自动清理”这个隐式保障,在虚拟线程上其实依然成立——只要虚拟线程真的结束了。它不成立的地方在于:有人创建了虚拟线程池(虽然一般不建议)或者创建了长生命周期的虚拟线程并复用它们。这时候 ThreadLocal 就跟着一起变成了”长期驻留”。

更麻烦的是有些库在 ThreadLocal 里塞了不小的对象——Session、UserContext、缓存对象。一个进程里几十万个虚拟线程,每个塞一个 100KB 的对象,就是几十 GB 的占用,会直接把服务干翻。

用 Scoped Value 替代部分 ThreadLocal

Java 21 里引入的 ScopedValue(JEP 446,在 Java 25 中正式定型)就是来解决这个问题的。它不把值存在线程上,而是存在”作用域”上。作用域结束值就消失,没有清理负担。

public final class TraceContext {

    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

    public static <T> T runWith(String traceId, Callable<T> task) throws Exception {
        return ScopedValue.where(TRACE_ID, traceId).call(task);
    }

    public static String current() {
        return TRACE_ID.isBound() ? TRACE_ID.get() : "anonymous";
    }
}

调用的地方:

String result = TraceContext.runWith(
    UUID.randomUUID().toString(),
    () -> fetcher.fetch(url)
);

跟 ThreadLocal 相比,好处很直接:

  • 不需要显式 remove,作用域退出自动清空
  • 值是不可变的,作用域内无法改
  • 虚拟线程继承值的时候,是共享引用,不复制一份(也就是说 100 万虚拟线程共享同一个 traceId,占用只有一份)

它的限制也是明确的:只能在 runWith/call 的闭包里读,闭包外就看不见。所以它替代的是”请求上下文”这类场景,替代不了”每个线程需要判断自己状态的私有缓存”。

我这边把所有 TraceId、UserContext、TenantContext 相关的 ThreadLocal 都换成了 ScopedValue,剩下少数几个真正是”线程本地”的缓存还保留着 ThreadLocal,但都加了一次性的显式清理。

两个辅助诊断的小工具

过程中用得比较多的两个命令,记一下。

第一个是查看线程固定:

jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json

输出的 JSON 里每个虚拟线程都有 carrierThread 字段。如果某个载体线程上同时挂着几十个 RUNNABLE 状态的虚拟线程,同一个时刻都卡在那里不动,基本可以确认是固定了。

第二个是启动参数打开固定事件告警:

-Djdk.tracePinnedThreads=full

加上之后 JVM 会在检测到线程固定时往标准输出打一段栈。开发阶段建议打开,改完之后再关掉——生产环境打开会加一点日志开销。

还有一个细节:jdk.tracePinnedThreads 早期是 short 和 full 两个选项,后来官方统一改了行为,新版 JDK 上直接用 full 就行。老版本上如果只打了 full 没生效,可以试试 short。

什么时候不该用虚拟线程

改造完成后我梳理了一下,发现虚拟线程明显不擅长的场景有几个。

CPU 密集型任务。虚拟线程的优势来自”阻塞时让出载体”,如果任务根本不阻塞,让它跑满 CPU,那载体数量就等于 CPU 核心数,跟平台线程表现一样。这种场合还不如老实用 ForkJoinPool。

下游资源有限、需要背压的场景。虚拟线程没有”池子大小”这个概念,你用 newVirtualThreadPerTaskExecutor 就意味着并发没有上限。如果下游数据库连接池只有 30 个连接,同时开 5000 个虚拟线程去抢,只会有一大堆等连接、超时、重试。这种场合要么给任务加信号量,要么老老实实保留有界线程池。

精确控制执行时机的地方。虚拟线程由 JVM 调度,它什么时候真正开始跑不由你说了算。需要按优先级或顺序调度的时候,ThreadPoolExecutor 的语义更好把握。

用到了 native 方法或 FFI 且会阻塞的库。这些调用会固定虚拟线程,跟 synchronized 是同一个问题。JDBC 驱动如果用了本地实现,也会碰到这个坑。好在主流驱动基本都改过了。

改造之后的最终样子

public final class FetcherService implements AutoCloseable {

    private final ExecutorService pool =
        Executors.newVirtualThreadPerTaskExecutor();

    private final ReentrantLock cacheLock = new ReentrantLock();
    private final Map<String, String> cache = new ConcurrentHashMap<>();

    public String fetch(String url) throws Exception {
        Future<String> future = pool.submit(() -> httpGet(url));
        try {
            return future.get(10, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            future.cancel(true);
            throw e;
        }
    }

    private String httpGet(String url) throws IOException {
        cacheLock.lock();
        try {
            String cached = cache.get(url);
            if (cached != null) return cached;

            String body = doRequest(url);
            cache.put(url, body);
            return body;
        } finally {
            cacheLock.unlock();
        }
    }

    private String doRequest(String url) throws IOException {
        // java.net.http.HttpClient 实现
        ...
    }

    @Override
    public void close() {
        pool.shutdown();
    }
}

改动幅度不大:一行执行器,一处锁,外加把 trace 相关的上下文换成了 ScopedValue。但吞吐量上的差异是数量级的,而且整个服务在极端上游延迟下的表现比原来稳得多——不会再有”线程被占满,队列爆掉”这种场景,因为池子这个概念已经不存在了。

一点个人感受

虚拟线程的引入门槛其实比想象中低。代码不需要重写成 CompletableFuture 链,不需要学响应式编程,同步阻塞的代码直接搬过去就行。真正需要费心思的地方在于——

把持有资源的方式和保护资源的锁,从”和线程绑定”改成”和作用域绑定”。

ThreadLocal 是绑在线程上的,虚拟线程让线程变成了廉价品,捆绑就失去了意义。synchronized 的锁也绑在 OS 线程上,虚拟线程要挂起的时候挂不动,必须换成 ReentrantLock 这类 java.util.concurrent 里的实现。

想清楚这一点,重构的方向就清楚了。剩下的就是逐个扫描那些用了 synchronized 和 ThreadLocal 的老代码,一处一处处理。这次我在一个模块里改了差不多十处,每一处都不是新技术,就是把老工具换成对的工具。

Java 虚拟线程实战:从线程池到虚拟线程的性能改造与坑位记录
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java 虚拟线程实战:从线程池到虚拟线程的性能改造与坑位记录 https://www.taomawang.com/server/java/2928.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务