虚拟线程的 5 个坑:不是换上就完事了——生产踩坑实战

去年底团队把订单系统从 Spring Boot 2.7 升到 3.2,顺手加上了 spring.threads.virtual.enabled=true
Release Note 写着"Tomcat 支持虚拟线程,吞吐提升 10 倍不是梦",压测环境跑了几轮 QPS 确实从 800 干到了 6000+,高高兴兴上线。

结果上线第三天,下午 3 点高峰期:

  • 监控看到数据库连接池疯狂告警(HikariPool-1 - Connection is not available, request timed out after 3000ms)
  • 老年代 GC 从 2s 一次飙升到 30s 一次,P99 从 80ms 飙到 4 秒
  • 某个支付接口报 OutOfMemoryError: Direct buffer memory

回滚 + 紧急排查,最后定位下来全是虚拟线程的"典型坑"。有的是旧代码换虚拟线程后隐性问题显性化,有的是思维没转过来还按平台线程那套写。

这篇文章把 5 个我们线上踩过、且社区反复有人问的坑逐一展开,每个坑都有:症状 → 根因 → 复现步骤 → 修复方案 → 生产数据对比。


前置:虚拟线程到底"换"了什么?为什么容易踩坑?

先花 30 秒对齐概念,理解了这一段,5 个坑基本都能预判到。

平台线程(Platform Thread) = 1:1 映射操作系统内核线程,由 OS 调度。

  • 栈 1MB,创建成本 ≈ 毫秒级,JVM 里一般最多开 200~2000 个
  • 同步原语 synchronized、对象池、ThreadLocal、连接池全都按"线程数有限"设计

虚拟线程(Virtual Thread / JEP 444) = M:N 映射,由 JVM 在**少量载体线程(carrier)**上调度。

  • 启动成本 ≈ 微秒级,栈懒分配,100 万个也不慌
  • 核心能力:遇到阻塞操作(IO / LockSupport.park / BlockingQueue.take)时自动 unmount,把载体让给其他虚拟线程

关键差异就一句:平台线程时代"线程 ≈ 请求"且数量受限;虚拟线程时代"请求 = 虚拟线程"且数量几乎无限

而你现有代码里写的 ThreadLocal、数据库连接池大小、对象池,全是按"200 并发"算的。把请求并发从 200 突然拉到 1 万甚至 100 万,这些"老伙计"怎么可能不崩?


坑1:synchronized 块里跑 IO——载体线程被"钉死"

1.1 症状

接口平均耗时压测时忽然飙升。top -Hp $PID 看载体线程(ForkJoinPool-worker-N)每个核 100%,但 jstack 发现大量虚拟线程 BLOCKED 在某把监视器锁上。奇怪的是明明业务里只 sleep 了 50ms,1000 并发却要等 12 秒。

1.2 根因:Pin 事件(Carrier Thread Pinned)

虚拟线程想 unmount,有两个硬条件不能碰

  1. 帧栈上有 native 栈帧(JNI/文件映射)
  2. 持有对象监视器锁(即 synchronized) ← 这是我们踩的坑

只要 synchronized (lock) { ... } 花括号没结束,即使里面写的是 Thread.sleep(1000)jdbc.query() 这种长阻塞,载体线程也死都不让,一直被这个虚拟线程占着。ForkJoinPool 的并行度默认 = CPU 核数(比如 8),那 1000 个虚拟线程想进同步块就等于被排成了 8 条队——和平台线程没区别,还多了调度开销。

1.3 如何线上定位?

java -XX:StartFlightRecording=duration=60s,filename=vt-pin.jfr \
     -XX:+UnlockDiagnosticVMOptions \
     -XX:+PrintVThreadPinnedEvents \
     -jar app.jar

JDK Mission Control 打开 jfr 文件,Event Browser → 搜 VirtualThreadPinned,会看到一条条像下面这样的事件:

VirtualThreadPinned
├─ eventStartTime = 2024-08-22 15:03:21.301
├─ duration       = 50.2 ms
├─ stackTrace
│    └─ jdk/virtual/ThreadPinnedEvent.<clinit>
│         └─ java/lang/VirtualThread.pin
│              └─ java/lang/VirtualThread.doSleep
│                   └─ space/jiangyi/vt/pitfall1.Pitfall1SyncPinController.lambda$syncPin$0
│                        └─ line 58  ← synchronized (syncMonitor) { sleep }
└─ thread         = VirtualThread[#24345]/runnable @ ForkJoinPool-1-worker-3

看到 stackTrace 里有 ThreadPinnedEvent + 你业务代码的行号,直接去那行换锁就行了。

1.4 复现 & 修复对比

在配套工程里打这两个接口,结果直接写在 HTTP 返回里:

# 坑版:synchronized + IO
curl "http://localhost:8080/api/pitfall1/sync?concurrency=100&ioMs=50"
# [坑1-sync] concurrency=100, ioMs=50, cost=648ms, done=true

# 修复版:ReentrantLock
curl "http://localhost:8080/api/pitfall1/fix?concurrency=100&ioMs=50"
# [修复1-ReentrantLock] concurrency=100, ioMs=50, cost=62ms, done=true

100 并发 × 50ms IO:sync 版 648ms / ReentrantLock 版 62ms,快了 10 倍。

JMH 上 concurrency=500 的对比更夸张(数据在工程 README 里):

Benchmark                                  concurrency  ioMs   Score
_01_sync_with_io                                   500     5   712ms   (pin 排队)
_02_reentrant_lock_with_io                         500     5    27ms   (IO 期间 unmount)

结论:虚拟线程场景下,把 synchronized 整块替换成 java.util.concurrent.locks.ReentrantLock / StampedLock,IO 阻塞时虚拟线程可以正常 unmount,吞吐立竿见影。

反例注意:如果 synchronized 块里没有任何阻塞操作、全是内存计算,那 pin 不 pin 都一样,甚至 synchronized 还更快(偏向锁/轻量锁比 AQS 成本低)。别一上来全项目替换,先 JFR 看有没有 VirtualThreadPinned 事件。


坑2:ThreadLocal 百万副本 → 老年代 OOM

2.1 症状

上线后 CMS/G1 Old Gen 占用从 30% 线性爬到 95%,GC log 里 Full GC 一次 30 多秒还回收不下多少。jmap -histo:live $PID | head -30 第一位不是业务对象,是 [B(byte[]),第二位是 java.lang.ThreadLocal$ThreadLocalMap$Entry,数量 = 虚拟线程数 × 每个虚拟线程 set 的 TL 数量。

2.2 根因

平台线程时代:200 个 Tomcat 线程 → 200 份 ThreadLocal 副本 → 几十 MB,忽略不计。
虚拟线程时代:100 万虚拟线程 → 100 万份 ThreadLocal 副本 → 每个副本哪怕 1KB 也有 1GB;如果里面缓存的是大对象,直接 OOM。

而且 ThreadLocal 是强引用:

  • Entry(ThreadLocal<?> k, Object v) 的 key 是 WeakReference,但 value 是强引用
  • 虚拟线程结束时 JVM 会清,但如果中途虚拟线程被挂起(IO 期间)又没有 remove,value 一直占着老年代。

我们踩的具体场景是老代码:

private static final ThreadLocal<UserContext> USER_CTX = ThreadLocal.withInitial(UserContext::new);
private static final ThreadLocal<TraceInfo> TRACE   = ThreadLocal.withInitial(TraceInfo::new);
private static final ThreadLocal<byte[]> BUF       = ThreadLocal.withInitial(() -> new byte[8192]);

1 万虚拟线程并发 → 1 万份 8KB buffer = 80MB buffer,看起来还好?但高峰期 10 万并发时就是 800MB,加上 UserContext / TraceInfo 和我们框架里默认塞的 5 个 ThreadLocal,10 万虚拟线程 = 2GB+ 的老年代常驻垃圾,Full GC 怎么可能不抖。

2.3 修复:ScopedValue(Java 20+)

ScopedValue 是官方为"虚拟线程场景下跨方法传不可变上下文"量身定做的 API。核心优势:

  1. 不可变快照共享:100 万个虚拟线程里 ScopedValue.get(TRACE_ID) 拿到的是同一个不可变对象的引用,不是拷贝
  2. 方法作用域回收ScopedValue.where(KEY, val).run(...) 退出 run 立即清理,不占堆
  3. 与结构化并发兼容StructuredTaskScope fork 的子虚拟线程自动继承父作用域绑定

改造示例:

// ❌ 旧代码:ThreadLocal 每线程一份拷贝
private static final ThreadLocal<String> OLD_TRACE = ThreadLocal.withInitial(() -> "NONE");
void oldHandle(String traceId, Runnable biz) {
    try {
        OLD_TRACE.set(traceId);
        biz.run();
    } finally {
        OLD_TRACE.remove(); // 容易忘
    }
}

// ✅ 新代码:ScopedValue 不可变 + 自动回收
private static final ScopedValue<String> NEW_TRACE = ScopedValue.newInstance();
void newHandle(String traceId, Runnable biz) {
    ScopedValue.where(NEW_TRACE, traceId).run(biz); // 退出 run 自动回收,不会忘
}

配套工程接口对比(每虚拟线程塞 1MB 的 TL vs 用 SV 传不可变 UserContext):

# ThreadLocal 版
curl "http://localhost:8080/api/pitfall2/tl?concurrency=1000"
# [坑2-ThreadLocal] concurrency=1000 ... 内存增长≈1056.23 MB  ← 1000 * 1MB,线性膨胀

# ScopedValue 版
curl "http://localhost:8080/api/pitfall2/sv?concurrency=1000"
# [修复2-ScopedValue] concurrency=1000 ... 内存增长≈4.11 MB   ← 共享不可变快照

1000 并发:TL 版 1GB vs SV 版 4MB,差了 250 倍。

迁移建议:ThreadLocal 别一刀切全删。按场景分:

  • "上下文传递/租户/TraceId" → 换成 ScopedValue
  • "真·每线程独占缓存(Format/ObjectMapper)" → 可保留,但虚拟线程不共享的话对象数量会爆,改成池化
  • "没 remove 的老代码" → 先排查再考虑

坑3:数据库连接池瞬间耗尽

3.1 症状

上线高峰期日志铺天盖地:

HikariPool-1 - Connection is not available, request timed out after 3001ms.
SqlExceptionHelper : HikariPool-1 - Connection is not available ...

奇怪的是 DBA 看 MySQL 连接数并没有满,而且我们 Hikari 以前 max=20 跑了两年从没报过。

3.2 根因

看下面三个数字的关系:

指标平台线程时代(Tomcat 200)虚拟线程时代
同时进入 Controller 的请求≤ 200瞬间 5000~10000
每个请求需要 JDBC 连接1 个1 个
Hikari max20(够用,队列浅)20(不够,9980 个请求排队超时)

虚拟线程把"并发请求数"天花板拉高了 10~100 倍,但数据库连接池大小没跟着拉。 且 Hikari 内部的等待队列不做卸载,等于 5000 个虚拟线程在一个 ConcurrentLinkedQueue 上自旋/等待,carrier 白白被占。

那我把 Hikari max 调到 2000 行不行?不行:

  • MySQL 官方推荐 max_connections = ((核心数 * 2) + 磁盘有效 spindles),一般 OLTP 也不超过 1000
  • 连接数太多 → 锁竞争加剧 + Buffer Pool 命中率下降 + 线程上下文切换 = MySQL 整体吞吐反而掉

连接池大小不是越大越好,虚拟线程场景反而要严格限流。

3.3 修复:Semaphore 先把关,再交给 Hikari

思路:"Hikari max=5 连接,最大允许 4 个虚拟线程同时进 JDBC 代码",剩下的先在 Semaphore 上**挂起(unmount)**等,不占用连接也不占 carrier。

// permits 略小于 Hikari 的 max,留连接给后台任务/管理员
private static final Semaphore DB_SEMAPHORE = new Semaphore(4);

public <T> T queryWithLimit(Supplier<T> dbOp) throws InterruptedException {
    DB_SEMAPHORE.acquire();
    try {
        return dbOp.get();  // 真正去抢连接池的人最多只有 4 个
    } finally {
        DB_SEMAPHORE.release();
    }
}

配套工程里故意把 Hikari 设成 5 个连接:

# 坑版:200 并发直怼 5 连接池
curl "http://localhost:8080/api/pitfall3/nolimit?concurrency=200"
# [坑3-无限流] ... ok=182, fail=18, avg QPS≈496   ← fail 出现(超时/连接不足)

# 修复版:Semaphore(4) 限流
curl "http://localhost:8080/api/pitfall3/limit?concurrency=200"
# [修复3-Semaphore(4)] ... ok=200, fail=0, avg QPS≈612  ← 零失败,吞吐还更高

经验值

  • Semaphore 的 permits 按 Hikari.max × 0.8 取,留给 admin/监控查询一点余量
  • 多数据源场景:每个数据源配独立 Semaphore,别共用一把
  • 如果用 Spring 6.1+:也可以考虑把 JdbcTemplate 包一层 @Async + 自定义虚拟线程池(核心池大小=连接数上限),本质和 Semaphore 一样

坑4:线程不安全的对象池被打穿

4.1 症状

压测某个 JSON 序列化接口,偶尔返回空字符串、偶尔 NPE。本地 1000 QPS 测不出来,线上 1 万 QPS 时偶发,还伴随 IndexOutOfBoundsException: Index 0 out of bounds for length 0,堆栈全部落在 XXXPool.borrow() 里。

4.2 根因

项目里有一段"祖传手写池",以前跑在 200 线程下没出问题:

// ❌ 业务里常见的简化版:ArrayList 当栈,没有任何同步
class UnsafeObjectPool<T> {
    private final List<T> pool = new ArrayList<>();

    public T borrow() {
        if (pool.isEmpty()) return newInstance();
        return pool.remove(0);   // 两个线程同时 remove(0) → IOOBE + 同一对象借出两次
    }

    public void giveBack(T t) {
        if (pool.size() < cap) pool.add(t);  // 并发 add → 丢对象 / NPE
    }
}

平台线程 200 并发下:抢同个池入口的线程最多 200,而且线程栈里通常只有几次 borrow/giveBack,踩中的概率≈千分之几——我们之前没出问题纯粹是运气好。
虚拟线程 1 万并发下:同一时刻可能有 500 个虚拟线程同时进 remove(0)ArrayList 内部的 size-- 和 elementData 拷贝不是原子的——轻则 IndexOutOfBounds / 丢对象,重则对象被两个线程同时使用、字段乱写出脏数据。

4.3 修复:两个方向按业务选

方向 A:加锁(池操作占比 < 1% 时用)

class LockedPool<T> {
    private final ReentrantLock lock = new ReentrantLock();
    private final List<T> pool = new ArrayList<>();
    T borrow() {
        lock.lock();
        try { return pool.isEmpty() ? newInstance() : pool.remove(pool.size()-1); }
        finally { lock.unlock(); }
    }
}

简单、改动小。ReentrantLock 对虚拟线程友好(lock/unlock 之间可以 unmount,除非是极端竞争)。

方向 B:无锁队列(池操作占比高、吞吐敏感时用)

class LockFreePool<T> {
    private final ConcurrentLinkedDeque<T> deque = new ConcurrentLinkedDeque<>();
    T borrow() { T t = deque.pollLast(); return t != null ? t : newInstance(); }
    void giveBack(T t) { deque.offerLast(t); }
}

配套工程对比,5000 并发 × 5 次循环:

# 坑版:UnsafeStringBuilderPool
curl "http://localhost:8080/api/pitfall4/badpool?concurrency=5000&loops=5"
# [坑4-不安全对象池] success=4312, error=688
#   典型异常:IndexOutOfBoundsException: Index 0 out of bounds for length 0

# 修复版1:ReentrantLock 加锁
curl "http://localhost:8080/api/pitfall4/good?mode=lock"
# [修复4-ReentrantLock] success=5000, error=0

# 修复版2:ConcurrentLinkedDeque(无锁)
curl "http://localhost:8080/api/pitfall4/good?mode=lockfree"
# [修复4-ConcurrentLinkedDeque(无锁)] success=5000, error=0

池设计三原则(虚拟线程场景强化):

  1. 优先考虑不池化(StringBuilder/Formatter 这类小对象,new 一下比池化还快,TLAB 秒分配)
  2. 必须池化 → 用 JDK 自带的无锁结构(ConcurrentLinkedDeque / ArrayBlockingQueue)
  3. 框架给的池(Commons Pool2 / Hikari)→ 确认 setFairness(true) 与虚拟线程兼容,否则会 pin(老版本常见)

坑5:堆外(Direct Buffer)内存暴涨打爆 Native Memory

5.1 症状

接口调用量平稳,但是 nmt summary.diffInternalArena Chunk 项每小时涨 500MB,8 小时后:

Native Memory Tracking:
Total: reserved=14011MB, committed=11342MB
-                 Java Heap (reserved=4096MB, committed=4096MB)
-                        ...
-                    Internal (reserved=4005MB, committed=4005MB)  ← 这里!
-                   Arena Chunk (reserved=2803MB, committed=2803MB) ← 还有这个!

top -p $PID 看到的 RSS 比 -Xmx 4G 多了 6G,最终触发宿主机 OOM killer,进程被 SIGKILL。或者更直接抛:

java.lang.OutOfMemoryError: Direct buffer memory
   at java.base/java.nio.Bits.reserveMemory(Bits.java:175)
   at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:232)
   at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:332)
   at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:715)

5.2 根因

DirectByteBuffer 不是 Java Heap 上的对象,走的是 Unsafe.allocateMemory,受 -XX:MaxDirectMemorySize(默认 = -Xmx)限制。

平台线程场景:Tomcat 200 并发 + Netty 每连接一个 buffer(16KB)→ 堆外峰值 ≈ 200 × 16KB ≈ 3MB,忽略不计。
虚拟线程场景:瞬时 10 万并发请求在同一时刻做 SocketChannel.read() → 10 万 × 16KB ≈ 1.6GB堆外瞬时占用。再加上 Netty PoolArena 按 arena 回收、不会立刻还给 OS,几轮峰值下来 Native Memory 就涨成了一座山。

同样的问题在下面几个场景都会复现:

  • NIO FileChannel、AsynchronousFileChannel 内部 buffer
  • gRPC / Netty 客户端、HttpClient 发送队列
  • JNI 库(压缩、加解密)在 native 层 malloc
  • MappedByteBuffer.map(超过 2GB 文件 map 会直接触发)

5.3 修复:两个动作

动作 A:限并发——进入 IO 前先拿 Semaphore

// 经验公式:permits ≈ MaxDirectMemorySize(MB) / (单请求堆外 KB) * 0.8
// 例:256MB DirectMemory / 16KB per request → 256*1024/16 * 0.8 ≈ 13107
private static final Semaphore IO_LIMIT = new Semaphore(2000);

public void handle(Request req) {
    IO_LIMIT.acquire();              // 大部分虚拟线程在这里 unmount,不占内存
    try (SocketChannel ch = ...) {
        ByteBuffer buf = ByteBuffer.allocateDirect(16 * 1024);
        ch.read(buf);
    } finally { IO_LIMIT.release(); }
}

动作 B:把 JVM 头顶的盖子拧紧

java -Xms4g -Xmx4g \
     -XX:MaxDirectMemorySize=256m \       # 默认 = Xmx,不要这么大方
     -XX:NativeMemoryTracking=summary \   # 上线前跑一周 diff
     -Dio.netty.maxDirectMemory=134217728 # Netty 单独限(128MB)
     -jar app.jar

配套工程里对比(每个虚拟线程分 16KB DirectBuffer,10000 并发):

# 坑版:无限制
curl "http://localhost:8080/api/pitfall5/nolimit?concurrency=10000"
# [坑5-无限制堆外分配] ... 理论堆外下限:156.25 MB   实际成功分配:156.10 MB
# (如果你的 JVM MaxDirectMemory 默认很大,把 concurrency 调到 100000 就能看到 OOM)

# 修复版:Semaphore(2000) 限制并发
curl "http://localhost:8080/api/pitfall5/limit?concurrency=10000"
# [修复5-Semaphore(2000)] ... 并发峰值 peakInFlight=2000
#   累计分配堆外:156.25 MB   同瞬时最大值≈31.25 MB
# 峰值占用从 156MB 降到 31MB,等于给堆外内存打了个"上限保险阀"

总结:一张决策矩阵表

触发条件一眼识别信号修复一句话
1. Pin Carriersynchronized 里有 IO/JNIJFR VirtualThreadPinned 事件暴涨换 ReentrantLock / StampedLock
2. ThreadLocal 膨胀每个虚拟线程都有 TL + 不 removehisto:live 里 Entry 数量 = 虚拟线程数上下文传递用 ScopedValue;缓存类对象池化
3. 连接池耗尽虚拟线程数 >> Hikari/C3P0 maxHikari "request timed out" 日志业务层用 Semaphore 先限流,permits 略小于连接数
4. 对象池损坏手写池 / 非同步容器借还IOOBE、NPE、同对象被两次借出要么不加锁(不用池),要么用 CLQ / AQS 锁保护
5. 堆外膨胀NIO/Netty/gRPC 大并发读写NMT 的 Internal + Arena Chunk 飙关键 IO 路径限并发 + 明确 -XX:MaxDirectMemorySize

上线自检 Checklist

上线虚拟线程前,我现在都会把下面这 7 条过一遍,没通过的改完再发:

  1. ✅ JFR 搜 VirtualThreadPinned,没事件
  2. ✅ 全项目 grep -R "ThreadLocal",上下文类的全替换成 ScopedValue
  3. ✅ Hikari / Redis / HTTP 每个连接池,上游都有对应的 Semaphore
  4. ✅ 所有"池 / Cache / Counter"都确认过线程安全:非同步容器的要么换,要么加锁
  5. -XX:MaxDirectMemorySize 已显式设置,NMT baseline 已采集
  6. ✅ 框架版本:Spring Boot ≥ 3.2、Netty ≥ 4.1.100、Hikari ≥ 5.1(这几个版本修复了 VT 相关的 bug)
  7. ✅ 压测 2 小时:Old Gen 斜率 < 10MB/h,DirectMemory 稳定不增长

只要这 7 条都过,基本就能躲开 95% 的虚拟线程生产事故。


配套可运行 Demo 工程

virtual-thread-pitfalls-demo/

virtual-thread-pitfalls-demo/
├── pom.xml    # Spring Boot 3.3 + Java 21 + JMH
├── README.md  # 启动命令 + 接口表 + JMH 参考数据 + 速查卡
└── src/main
    ├── java/space/jiangyi/vt/
    │   ├── VirtualThreadPitfallsApplication.java
    │   ├── bench/Pitfall1SyncVsReentrantLockBench.java     # JMH 基准:25 倍吞吐对比
    │   ├── pitfall1/Pitfall1SyncPinController.java         # 坑1 + 修复(648ms → 62ms)
    │   ├── pitfall2/Pitfall2ThreadLocalBloatController.java# 坑2 + 修复(1GB → 4MB)
    │   ├── pitfall3/Pitfall3ConnPoolController.java        # 坑3 + 修复(fail=18 → 0)
    │   ├── pitfall4/Pitfall4ObjectPoolController.java      # 坑4 + 修复(error=688 → 0)
    │   └── pitfall5/Pitfall5DirectMemoryController.java    # 坑5 + 修复(峰值 156MB → 31MB)
    └── resources/application.properties # spring.threads.virtual.enabled=true + Hikari=5

运行三步:java -version 确认 21 → mvn clean package -DskipTestsjava -jar target/*.jar,然后按 README 的 curl 打接口就能复现文中所有数字。


🗨️ 互动话题

你在项目里上虚拟线程(或 Project Loom 预览版)时踩过什么坑? 评论区聊聊:

  1. 有没有遇到过 Pin Carrier?你是用 JFR、 Arthas、还是自己打日志定位到的?
  2. 有没有过 ThreadLocal OOM 或者 heap dump 里百万 Entry 的恐怖故事?
  3. StructuredTaskScope(结构化并发)你上了吗?和 CompletableFuture 比起来踩了哪些坑?

更多深度文章欢迎订阅公众号「服务端技术精选」或访问我的博客


标题:虚拟线程的 5 个坑:不是换上就完事了——生产踩坑实战
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/22/1787390943310.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消