Java虚拟线程实战踩坑:pinning、ThreadLocal膨胀与连接池堵死

2026-09-23 0 128

去年年底接了一个老项目的改造,Spring Boot 2.7 加 Tomcat,接口里大量调用第三方 HTTP 服务,平均响应 300ms 上下。压测到 400 并发的时候,Tomcat 那 200 个线程就全泡在等待里了,QPS 死活上不去,CPU 却闲着。典型的 IO 密集场景,虚拟线程刚好对症。

于是升级到 Spring Boot 3.2,一行配置切过去:

spring.threads.virtual.enabled=true

压测结果确实漂亮,200 并发下的 QPS 从 900 涨到了 3400,P99 也降了。团队挺高兴,直接上了预发环境。然后问题就来了——而且不是一个,是三个,一个接一个地冒出来。这篇文章就是把这三次排查的过程记下来,代码都是当时用来复现的最小样例。

一、先把「虚拟线程不是线程池」这件事说透

很多人第一次接触虚拟线程,会下意识地把它理解成「更轻量的线程池」。这个类比不太对,会误导后续所有的判断。

平台线程是操作系统线程的一对一封装。创建一个平台线程,就是让内核创建一个调度实体,栈空间按 MB 计,上下文切换要走内核。所以线程池的逻辑是:线程太贵,那就建一小撮反复用,把任务排队塞进去。

虚拟线程完全是另一个层次的东西。它由 JVM 管理,栈存在堆里,初始只有几百字节,可以按需增长。一个虚拟线程在执行时,会被安排到一个「载体线程」(carrier thread,本质是 ForkJoinPool 里的平台线程)上跑。一旦这个虚拟线程遇到阻塞操作,比如 Socket.read()LockSupport.park()Thread.sleep(),JVM 会把它从载体线程上卸下来(unmount),把栈帧存进堆里,然后让载体线程去跑别的虚拟线程。等 IO 就绪了,再找个载体线程把它挂回去(mount)继续跑。

关键在于「卸下来」这个动作。平台线程阻塞时,它占着内核线程干等;虚拟线程阻塞时,它会主动让出载体线程。所以载体线程的数量不需要随着并发量增长。默认情况下,载体线程池的大小等于 CPU 核数——我的机器是 8 核,那 8 个载体线程理论上就能支撑十万个虚拟线程同时等待。

想明白这一层,后面的三个坑就有了共同的解释:只要虚拟线程没法正常 unmount,它就会退化成平台线程,把你的载体池占满。

二、第一个坑:synchronized 把载体线程钉住了

上线第三天,监控报警说服务响应变慢,线程 dump 出来一看,ForkJoinPool 里的 8 个载体线程全部卡在 WAITING 状态,而且堆栈顶上都停在同一个方法上。

那个方法长这样:

public class RemoteConfigCache {

    private final Map<String, Config> cache = new HashMap<>();

    public synchronized Config get(String key) {
        Config cached = cache.get(key);
        if (cached != null) {
            return cached;
        }
        // 这一步是 HTTP 调用,可能耗时几百毫秒
        Config fresh = remoteClient.fetch(key);
        cache.put(key, fresh);
        return fresh;
    }
}

问题就出在那个 synchronized 上。在 JDK 21 到 JDK 23 这几个版本里,虚拟线程如果在 synchronized 块内部发生阻塞,它是没法被卸下来的。原因在于 synchronized 的监视器锁和对象头是绑定的,而对象头记录的是载体线程的身份。如果一个虚拟线程抱着监视器锁被卸载,另一个虚拟线程挂到同一个载体线程上再想获取这把锁,JVM 没法区分它们——监视器的所有权模型根本不支持这种「跨载体线程持有」的语义。

所以 JVM 的选择是:干脆不卸了。虚拟线程在 synchronized 块里阻塞时,会一直钉在载体线程上,这个状态就叫 pinning。

我的场景更糟——synchronized 修饰的是实例方法,锁的粒度是整个对象,同一时刻只有一个虚拟线程能进去。其他几百个虚拟线程全在外面排队等锁,而进去的那个在慢慢做 HTTP 调用。8 个载体线程,只要 8 个这类调用同时发生,整个调度器就瘫痪了。

怎么发现的。JDK 提供了一个诊断开关:

java -Djdk.tracePinnedThreads=full -jar app.jar

只要有虚拟线程因为 pinning 而阻塞,控制台就会打印一段堆栈,明确指出是哪一行 synchronized 导致的。我加上这个参数跑了一次压测,日志里刷屏的都是 RemoteConfigCache.get,问题定位得很快。

怎么改。synchronized 换成 ReentrantLock

public class RemoteConfigCache {

    private final Map<String, Config> cache = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    public Config get(String key) {
        lock.lock();
        try {
            Config cached = cache.get(key);
            if (cached != null) {
                return cached;
            }
        } finally {
            lock.unlock();
        }

        // 关键:把网络调用挪到锁外面
        Config fresh = remoteClient.fetch(key);

        lock.lock();
        try {
            return cache.computeIfAbsent(key, k -> fresh);
        } finally {
            lock.unlock();
        }
    }
}

这里做了两件事。ReentrantLock 的底层是 AbstractQueuedSynchronizer,它的等待队列是纯粹的 Java 对象,不依赖对象头,所以虚拟线程持锁阻塞时可以安全卸载。另一件事更重要:把 HTTP 调用移出临界区。就算锁的问题解决了,让几百个虚拟线程排着队等一个网络请求,吞吐量也好不到哪去。

顺带提一句版本。JDK 24 通过 JEP 491 重构了 synchronized 的实现,虚拟线程在 synchronized 块里阻塞也能正常卸载了。所以如果你是 JDK 24 及以上,这个坑基本不存在。但在 JDK 21 这个 LTS 上,它是个必须处理的问题,而且因为不报错、不抛异常,只是悄悄变慢,很容易被漏掉。

三、第二个坑:ThreadLocal 从 200 份变成 50 万份

pinning 修完,接口确实稳了,但过了两天运维反馈说容器内存涨得厉害,GC 频率也上去了,Full GC 从一天两次变成一小时三次。

堆 dump 拉下来分析,发现占用最大的是一堆 ThreadLocalMap$Entry,加起来有几百 MB。

原因是这样。项目里有个自研的链路追踪组件,用 ThreadLocal 存 traceId 和调用链上下文:

public final class TraceContext {

    private static final ThreadLocal<Trace> CURRENT = new ThreadLocal<>();

    public static void set(Trace trace) {
        CURRENT.set(trace);
    }

    public static Trace get() {
        return CURRENT.get();
    }

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

用平台线程池的时候,这个写法没什么问题。线程池里就 200 个线程,不管处理多少请求,ThreadLocalMap 始终只有 200 份,而且每当复用线程处理下一个请求时,set 会覆盖旧值,老对象很快就能被回收。

换成虚拟线程之后,情况完全不同。Executors.newVirtualThreadPerTaskExecutor()每个任务创建一个新虚拟线程。也就是说,一个请求进来就多一个虚拟线程,多一份 ThreadLocalMap。压测跑十分钟,创建了五十万个虚拟线程,就产生了五十万份 ThreadLocalMap。虽然虚拟线程执行完就被回收了,但 ThreadLocalMap 里的 Trace 对象持有的是业务上下文,里面还挂着一些不小的集合,这些对象在 Young GC 里活不过几轮就被晋升到老年代,堆积起来就是几百 MB。

这个问题本质上是「ThreadLocal 的语义」和「线程复用」之间的矛盾。ThreadLocal 之所以能工作,前提就是线程会被反复使用。一旦线程不再复用,ThreadLocal 就退化成了「随任务创建、随任务销毁」的临时变量——那还不如直接方法传参。

短期修法是严格保证清理。把 remove() 放进 try-finally,确保任何路径都会执行:

public void handle(Request req) {
    TraceContext.set(new Trace(req.traceId()));
    try {
        doHandle(req);
    } finally {
        TraceContext.clear();
    }
}

长期方案是换用 ScopedValue。它是为虚拟线程时代设计的,写法和 ThreadLocal 类似,但作用域是结构化、不可变的,绑定关系会随作用域自动解除,不需要手动清理:

private static final ScopedValue<Trace> TRACE = ScopedValue.newInstance();

public void handle(Request req) {
    ScopedValue.where(TRACE, new Trace(req.traceId()))
               .run(() -> doHandle(req));
}

public Trace currentTrace() {
    return TRACE.isBound() ? TRACE.get() : Trace.empty();
}

ScopedValue 在 JDK 21 到 JDK 24 都还是预览特性,JDK 25 转正。如果暂时用不了,至少要把 ThreadLocal 的清理做到位,另外可以给线程池加个包装,在任务执行前后统一做 clear。

四、第三个坑:8000 个虚拟线程在等 10 个连接

前两个坑填完,我以為可以收工了。结果做全链路压测的时候发现,QPS 曲线在 800 并发之后就不涨了,反而开始出现大量超时,日志里满是 HikariCPConnection is not available, request timed out after 30000ms

这个现象挺有迷惑性。按理说虚拟线程那么多,吞吐量应该无限增长才对,怎么会卡在这个数上?

答案在连接池的配置里:

spring.datasource.hikari.maximum-pool-size=10

这是很多项目的默认值,来源于 HikariCP 作者的一个经典建议:「池子大小应该等于 CPU 核数加磁盘数」。这个建议本身没错,它的前提是每个请求很快就能拿到连接、很快归还,池子只是一个复用缓冲

但虚拟线程改变了压力的形态。之前平台线程池只有 200 个线程,最多也就 200 个请求同时抢 10 个连接,排队是必然的,但队列长度可控。换成虚拟线程之后,Tomcat 不再限制并发数,一个瞬时流量打进来可能瞬间开出一万个虚拟线程,全都堵在 getConnection() 那一行上。HikariCP 内部用的是 ReentrantLock 加超时机制,10 个连接被占满之后,后面 9990 个虚拟线程全部在等待队列里排着,等满 30 秒之后一起抛异常。

结果就是:连接池变成了整个系统的实际并发上限,而虚拟线程把等待的队伍拉长了 50 倍,超时时间一到,大批请求同时失败。

修法不是简单地把池子调大。数据库能承受的连接数是有限的,把 maximum-pool-size 从 10 调到 500,数据库那边就先撑不住了,而且会引发大量上下文切换,反而更慢。

正确做法是在业务层加一道显式的限流,把「能进到数据库这一层」的并发数控制住,多出来的请求快速失败或者降级:

public class DbGuard {

    private final Semaphore permits;

    public DbGuard(int concurrency) {
        this.permits = new Semaphore(concurrency);
    }

    public <T> T withPermit(Callable<T> action) throws Exception {
        if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
            throw new TooManyRequestsException("数据库繁忙,请稍后重试");
        }
        try {
            return action.call();
        } finally {
            permits.release();
        }
    }
}

几个参数的意义需要拿捏一下。concurrency 一般设成连接池大小的 1.5 到 2 倍,让池子保持饱和但不至于排队过长。tryAcquire 的超时时间不能太长,200ms 是个合理的起点——超过了就说明已经过载,让请求带着明确的错误信息回去,比让它等 30 秒然后失败要体面得多。

归根结底一句话:虚拟线程解决的是「线程不够用」,解决不了「资源不够用」。CPU、数据库连接、下游服务的 QPS 配额,这些都是真实存在的瓶颈。虚拟线程让瓶颈更容易被暴露出来,而不是让它消失。

五、多个调用能不能并行:StructuredTaskScope

改造过程中还有一个意外收获。有个接口需要同时调用用户服务和订单服务,然后拼装结果。原来的写法是串行调用,两个各 200ms 左右,加起来 400ms。

CompletableFuture 当然可以并行,但它的异常处理确实有点绕——两个 future 一个成功一个失败,要保证把成功那个也取消掉,代码写起来很啰嗦。JDK 21 引入的 StructuredTaskScope 就是干这个的:

public Profile loadProfile(long userId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

        Supplier<User> userTask =
            scope.fork(() -> userClient.fetch(userId));

        Supplier<List<Order>> orderTask =
            scope.fork(() -> orderClient.fetchByUser(userId));

        scope.join();

        // 任意一个失败,就抛异常,同时取消另一个
        scope.throwIfFailed();

        return new Profile(userTask.get(), orderTask.get());
    }
}

这个 API 最舒服的地方在于「结构化」三个字。子任务的生命周期被严格限制在 try 块里。join() 会一直等到所有子任务结束;只要有任何一个抛异常,throwIfFailed() 就会抛出,同时 ShutdownOnFailure 策略已经帮你把还在跑的其他子任务取消掉了。不会再有「主流程已经返回了,后台还有一个 future 在悄悄跑」这种事。

另外还有一种策略叫 ShutdownOnSuccess,适合「多个数据源同时查,谁先返回用谁」的场景,比如从三个镜像节点里取同一份数据。

需要提醒的是,StructuredTaskScope 的 API 从 JDK 21 到 JDK 25 之间改动过好几次——ShutdownOnFailure 在后期版本里被 joinAll() 返回结果的写法取代了。所以上面的代码请以你实际使用的 JDK 文档为准。目前它仍需加 --enable-preview 才能编译,用在生产环境要谨慎评估,但作为技术预研方向是值得的。

六、什么情况下别用虚拟线程

踩完这三个坑之后,我反而对虚拟线程的适用边界清楚了很多。它不是什么场景都能救的银弹。

CPU 密集型的任务不要用。计算密集任务的瓶颈就是核数,虚拟线程再多也得排队等 CPU。相反,虚拟线程的创建和调度本身还有开销,在高强度计算下反而比平台线程慢。这类任务应该用 ForkJoinPool 或者固定大小的线程池,让线程数量和核数对齐。

依赖线程局部变量的老代码要慎重。刚才说的 ThreadLocal 膨胀是一个例子。另外像 SecurityManagerInheritableThreadLocal、基于 Thread.currentThread() 做身份识别的代码,在虚拟线程下都可能有意外行为。

用了本地方法或者 JNI 的地方。本地方法的栈帧不受 JVM 管理,如果它长时间阻塞,载体线程依然会被钉住。这类调用通常只能靠调大载体线程池(jdk.virtualThreadScheduler.maxPoolSize)来缓解。

反过来说,如果你的服务特征是「请求量大、每个请求都在等 IO、线程数经常被打满、CPU 使用率却不高的」,那虚拟线程的收益会非常明显。判断标准就这么简单,不用想太复杂。

七、改造的落地顺序

如果让我重新走一遍这个流程,我会按这个顺序来,能少踩不少坑。

第一步,升级 JDK 到 21 或者更高,先把 -Djdk.tracePinnedThreads=full 挂上,别急着改业务代码,压测一轮,把所有的 pinning 点都找出来。这一步纯观察,不改代码,但能拿到最真实的问题清单。

第二步,把同步块从 synchronized 换成 ReentrantLock,并且检查临界区里有没有 IO 调用,有的话挪出去。这一步做完,虚拟线程才算真正用起来了。

第三步,审计所有的 ThreadLocal 使用,加上 finally 里的 remove()。有条件的话开始规划向 ScopedValue 迁移。

第四步,给所有外部资源加上显式限流。数据库、Redis、每个上游服务,都应该有一个 Semaphore 守着。这一步是防雪崩的关键,也是虚拟线程改造里最容易被忽略的一步。

第五步才是打开 spring.threads.virtual.enabled=true,小流量灰度,观察载体线程的队列深度和连接池的等待时间。这两个指标如果都在正常范围,基本就稳了。

写在最后

虚拟线程的好,不在于它是新东西,而在于它把「一个请求一个线程」这个最直观的编程模型还给了我们。过去十几年,我们从同步模型退到异步回调,又从回调进化到 Reactor 那样链式的表达,本质上都是在用复杂度的代价去换线程资源的节省。虚拟线程把这个权衡拿掉了——你可以继续写阻塞的、顺序的代码,同时拿到高并发的能力。

但有一个前提不能忘:它优化的只是「线程」这一种资源。总吞吐量的上限,永远由系统里最稀缺的那个资源决定。所以改造的时候,注意力不该全放在虚拟线程本身,而要花一半时间去看那些真正的瓶颈——数据库连接、下游服务的承载能力、外部 API 的配额。这些地方做扎实了,虚拟线程的收益才能完整兑现。

Java虚拟线程实战踩坑:pinning、ThreadLocal膨胀与连接池堵死
收藏 (0) 打赏

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

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

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

淘吗网 java Java虚拟线程实战踩坑:pinning、ThreadLocal膨胀与连接池堵死 https://www.taomawang.com/server/java/2806.html

常见问题

相关文章

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

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