MDC 的 ThreadLocal 泄漏:为什么虚拟线程下需要 ScopedValue
一句话结论(30s)
MDC 泄漏的根因是底层 ThreadLocal<Map> 的 Entry.value 是强引用,因为虚拟线程数百万级创建且被 Carrier Thread 池化复用,Thread 对象长期存活导致 Map 无法释放。关键设计是双层 ThreadLocal 缓存——写时失效只读缓存、读时深拷贝一次后缓存命中,把单请求 50 次深拷贝降到 1 次。权衡是缓存换来了读性能、却加重了强引用 value 的泄漏面,ScopedValue 以”作用域绑定 + 不可变 + 自动释放”成为正解。
核心原理(2min)
Logback 用 readWriteThreadLocalMap(读写目标)和 readOnlyThreadLocalMap(只读缓存)两个 ThreadLocal:MDC.put 写前者并置后者 null 标记失效,getCopyOfContextMap 首次深拷贝建缓存、后续直接命中。虚拟线程下 ThreadLocalMap 的 value 强引用使 Map 随 Thread 长期存活。ScopedValue 把值绑定到作用域而非线程,作用域结束自动释放、子线程自动继承,天然避免线程池复用的脏数据残留。
底层深入(5-10min)
MDC 底层:双层 ThreadLocal 缓存
Logback 的 LogbackMDCAdapter 用两个 ThreadLocal:
final ThreadLocal<Map<String, String>> readWriteThreadLocalMap; // 读写目标
final ThreadLocal<Map<String, String>> readOnlyThreadLocalMap; // 只读缓存
写路径:MDC.put("traceId", "abc123") → 写入 readWriteThreadLocalMap,同时将 readOnlyThreadLocalMap 置 null(标记缓存失效)。
想一想:为什么写 MDC 时要顺手把只读缓存置 null?因为 MDC 一旦变了,旧缓存就脏了;置 null 是”标记失效”,下次读才重新深拷贝,否则会一直命中旧值。
读路径:日志打印时调用 MDC.getCopyOfContextMap() → 检查 readOnlyThreadLocalMap 是否为 null → 是则从前者深拷贝创建不可变副本缓存 → 不是则直接返回缓存。
性能:为什么需要双层缓存
一次请求写 3 次 MDC(traceId、userId、requestId),但可能打印 50+ 条日志。如果没有缓存,每次打印都 copyOfContextMap() 深拷贝一次:
5000 QPS × 50 条日志 × 1 次深拷贝 = 25 万次/秒 Map 深拷贝
→ 大量短生命周期对象 → Young GC 频繁
双层缓存:写入后第一次打印时触发深拷贝并缓存,后 49 次直接命中缓存。拷贝次数从 50 降至 1。
想一想:为什么双层缓存能把 50 次深拷贝降到 1 次?因为第一次打印深拷贝并缓存,后 49 次直接命中缓存返回;深拷贝产生的短生命周期对象大幅减少,Young GC 压力骤降。
问题:虚拟线程下 ThreadLocal 的内存泄漏
MDC 底层是 ThreadLocal<Map<String, String>>,数据存在 Thread.threadLocals(ThreadLocalMap)里。
// ThreadLocalMap.Entry
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // ← 这个 Map 是强引用!
}
虚拟线程按需创建数百万个,每个都持有自己的 ThreadLocalMap。虚拟线程生命周期结束后:
- Key(WeakRef<ThreadLocal>)会被 GC 回收
- Value(Map<String,String>)是强引用,只要 Thread 对象没被 GC,整个 Map 都释放不了
- 虚拟线程被池化的 Carrier Thread 复用,Thread 对象长期存活 → 内存持续增长
想一想:为什么普通线程不泄漏、虚拟线程就泄漏?因为普通线程用完就死、Thread 对象可被 GC,Map 随之释放;虚拟线程百万级创建且被 Carrier Thread 池化复用,Thread 长期存活,强引用 value 的 Map 就永远释放不了。
ScopedValue:作用域绑定而非线程绑定
JEP 429 引入的 ScopedValue 设计哲学完全不同——值绑定到”执行作用域”而非线程:
// 旧方式(ThreadLocal)—— 手动 clean
MDC.put("traceId", traceId);
try {
doWork();
} finally {
MDC.clear(); // 忘了这行 = 内存泄漏
}
// 新方式(ScopedValue)—— 自动释放
ScopedValue.where(TRACE_ID, traceId).run(() -> {
doWork();
// 任何地方可以 TRACE_ID.get()
// 子虚拟线程自动继承
});
// 作用域结束,自动释放,GC 回收
ScopedValue 的优势
| ThreadLocal | ScopedValue | |
|---|---|---|
| 绑定目标 | 当前线程 | 当前作用域 |
| 子线程继承 | InheritableThreadLocal(需手动) | 自动继承 |
| 释放方式 | finally { remove() } | 作用域结束自动释放 |
| 可修改 | ✅ set() | ❌ 不可变(immutable) |
| 虚拟线程友好 | ❌ 池化线程复用导致脏数据 | ✅ 作用域外自动清理 |
不可变(immutable)是关键——子线程无法修改父线程的 ScopedValue,天然避免了”线程池复用导致上一个请求的 MDC 残留到下一个请求”这种经典 bug。
想一想:为什么”不可变 + 自动释放”能避免脏数据残留?因为子线程无法修改父线程的 ScopedValue,且作用域结束值自动清理;线程池复用线程时,上一个请求的值随作用域退出而消失,不会漏给下一个请求。
章末提问
追问 1:为什么虚拟线程下 ThreadLocal 会内存泄漏,普通线程不会?
回答思路:结论先行——因为 ThreadLocalMap 的 Entry.value 是强引用,而虚拟线程百万级创建且被 Carrier Thread 池化复用、Thread 对象长期存活。因为普通线程用完就死、Thread 可被 GC,Map 随之释放;虚拟线程的 Thread 被复用不销毁,只要 Thread 活着,强引用的 value(Map)就永远释放不了,内存持续增长。
追问 2:Logback 的 MDC 为什么用双层 ThreadLocal 缓存?
回答思路:结论先行——为了把单请求 50 次深拷贝降到 1 次,减少短生命周期对象和 Young GC 压力。因为一次请求写 3 次 MDC 却可能打 50+ 条日志,若每次打印都深拷贝,5000 QPS 下就是 25 万次/秒 Map 深拷贝;双层缓存让第一次打印深拷贝建缓存、后 49 次直接命中。
追问 3:ScopedValue 和 ThreadLocal 的本质区别?
回答思路:结论先行——ThreadLocal 绑定”线程”、需手动 remove;ScopedValue 绑定”作用域”、不可变且自动释放。因为 ScopedValue 作用域结束值自动清理、子线程自动继承、不可变导致子线程无法改父线程值,天然规避线程池复用导致的脏数据残留;ThreadLocal 则要 finally { remove() } 手动清理,忘了就泄漏。