虚拟线程的 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,有两个硬条件不能碰:
- 帧栈上有 native 栈帧(JNI/文件映射)
- 持有对象监视器锁(即 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。核心优势:
- 不可变快照共享:100 万个虚拟线程里
ScopedValue.get(TRACE_ID)拿到的是同一个不可变对象的引用,不是拷贝 - 方法作用域回收:
ScopedValue.where(KEY, val).run(...)退出 run 立即清理,不占堆 - 与结构化并发兼容:
StructuredTaskScopefork 的子虚拟线程自动继承父作用域绑定
改造示例:
// ❌ 旧代码: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 max | 20(够用,队列浅) | 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
池设计三原则(虚拟线程场景强化):
- 优先考虑不池化(StringBuilder/Formatter 这类小对象,new 一下比池化还快,TLAB 秒分配)
- 必须池化 → 用 JDK 自带的无锁结构(ConcurrentLinkedDeque / ArrayBlockingQueue)
- 框架给的池(Commons Pool2 / Hikari)→ 确认
setFairness(true)与虚拟线程兼容,否则会 pin(老版本常见)
坑5:堆外(Direct Buffer)内存暴涨打爆 Native Memory
5.1 症状
接口调用量平稳,但是 nmt summary.diff 里 Internal 和 Arena 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 Carrier | synchronized 里有 IO/JNI | JFR VirtualThreadPinned 事件暴涨 | 换 ReentrantLock / StampedLock |
| 2. ThreadLocal 膨胀 | 每个虚拟线程都有 TL + 不 remove | histo:live 里 Entry 数量 = 虚拟线程数 | 上下文传递用 ScopedValue;缓存类对象池化 |
| 3. 连接池耗尽 | 虚拟线程数 >> Hikari/C3P0 max | Hikari "request timed out" 日志 | 业务层用 Semaphore 先限流,permits 略小于连接数 |
| 4. 对象池损坏 | 手写池 / 非同步容器借还 | IOOBE、NPE、同对象被两次借出 | 要么不加锁(不用池),要么用 CLQ / AQS 锁保护 |
| 5. 堆外膨胀 | NIO/Netty/gRPC 大并发读写 | NMT 的 Internal + Arena Chunk 飙 | 关键 IO 路径限并发 + 明确 -XX:MaxDirectMemorySize |
上线自检 Checklist
上线虚拟线程前,我现在都会把下面这 7 条过一遍,没通过的改完再发:
- ✅ JFR 搜
VirtualThreadPinned,没事件 - ✅ 全项目
grep -R "ThreadLocal",上下文类的全替换成 ScopedValue - ✅ Hikari / Redis / HTTP 每个连接池,上游都有对应的 Semaphore
- ✅ 所有"池 / Cache / Counter"都确认过线程安全:非同步容器的要么换,要么加锁
- ✅
-XX:MaxDirectMemorySize已显式设置,NMT baseline 已采集 - ✅ 框架版本:Spring Boot ≥ 3.2、Netty ≥ 4.1.100、Hikari ≥ 5.1(这几个版本修复了 VT 相关的 bug)
- ✅ 压测 2 小时:Old Gen 斜率 < 10MB/h,DirectMemory 稳定不增长
只要这 7 条都过,基本就能躲开 95% 的虚拟线程生产事故。
配套可运行 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 -DskipTests → java -jar target/*.jar,然后按 README 的 curl 打接口就能复现文中所有数字。
🗨️ 互动话题
你在项目里上虚拟线程(或 Project Loom 预览版)时踩过什么坑? 评论区聊聊:
- 有没有遇到过 Pin Carrier?你是用 JFR、 Arthas、还是自己打日志定位到的?
- 有没有过 ThreadLocal OOM 或者 heap dump 里百万 Entry 的恐怖故事?
StructuredTaskScope(结构化并发)你上了吗?和 CompletableFuture 比起来踩了哪些坑?
更多深度文章欢迎订阅公众号「服务端技术精选」或访问我的博客
标题:虚拟线程的 5 个坑:不是换上就完事了——生产踩坑实战
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/22/1787390943310.html
公众号:服务端技术精选
- 前置:虚拟线程到底"换"了什么?为什么容易踩坑?
- 坑1:synchronized 块里跑 IO——载体线程被"钉死"
- 1.1 症状
- 1.2 根因:Pin 事件(Carrier Thread Pinned)
- 1.3 如何线上定位?
- 1.4 复现 & 修复对比
- 坑2:ThreadLocal 百万副本 → 老年代 OOM
- 2.1 症状
- 2.2 根因
- 2.3 修复:ScopedValue(Java 20+)
- 坑3:数据库连接池瞬间耗尽
- 3.1 症状
- 3.2 根因
- 3.3 修复:Semaphore 先把关,再交给 Hikari
- 坑4:线程不安全的对象池被打穿
- 4.1 症状
- 4.2 根因
- 4.3 修复:两个方向按业务选
- 坑5:堆外(Direct Buffer)内存暴涨打爆 Native Memory
- 5.1 症状
- 5.2 根因
- 5.3 修复:两个动作
- 总结:一张决策矩阵表
- 上线自检 Checklist
- 配套可运行 Demo 工程
- 🗨️ 互动话题
评论