2025 年八月一个周五晚上 10 点,我们的客服系统突然爆出一个让人头皮发麻的客诉:一个用户在自己账号里看到了别人的订单详情和手机号。这种"数据串号"事故等级是 P0 中的 P0,涉及数据安全,我们立刻把整个服务从入口流量摘掉,SRE + 后端 + 安全联合作战,5 个小时定位到根因:一段使用 ThreadLocal 缓存"当前请求用户上下文"的代码,在 Tomcat 线程池复用线程时,旧的 ThreadLocal 没有被 remove,新请求拿到了上一个请求遗留的用户对象。事故的二级影响是 JVM 老年代被 ThreadLocal 中的强引用撑爆,Full GC 频繁、堆从 6GB 涨到 14GB(配置的上限)、最终 OOM。这篇把整个事故的排查、ThreadLocal 的内部实现、线程池下的内存语义、5 种修法、以及我们立的 Java 内存与上下文纪律完整记录。
服务背景和事故的发现过程
事故服务是我们 SaaS 客服系统的核心 API,Java 17 + Spring Boot 3.x + Tomcat 10 + MySQL + Redis,部署在 K8s,8 个副本,每个副本 4 vCPU + 4GB 堆。日常 QPS 约 800,P99 80ms,堆使用稳定在 1.5GB 左右。事故是周五晚上 22:07 一个 VIP 客户的工单升级上来的,客户描述非常清楚:"我刚才点开'我的订单',第一条不是我的订单,是另一个公司(显示了对方公司名)的"。这种描述让我们立刻意识到不是普通 bug,是数据隔离失效。
| 时刻 | 事件 |
|---|---|
| 21:50 | JVM 老年代占用从平时 1.5GB 飙到 3.8GB(只触发 P2 告警, SRE 未介入) |
| 22:00 | 第一次 Full GC, 耗时 6 秒, 部分请求超时 |
| 22:07 | VIP 客户报告"看到别人订单", P0 工单升级 |
| 22:15 | SRE + 后端 + 安全联合作战, 流量摘除 |
| 22:30 | 第一轮排查: SQL 注入 / 缓存击穿 / 权限漏洞 (排除) |
| 23:10 | 抓 heap dump, 发现 ThreadLocalMap 里 8000+ 个 UserContext |
| 00:30 | 定位代码: UserContextHolder.set() 后没有 remove |
| 01:15 | 临时修复 + 灰度发布, 全量上线 |
| 03:00 | 事故关闭, 开始详细复盘和数据扫描 |
第一轮排查走了哪些弯路
事故初期最容易往"安全漏洞"方向想:是不是 SQL 注入读到了别人数据?是不是缓存 key 设计有问题导致击穿?是不是权限校验失效?我们一开始花了一个小时排查这些方向,全都排除:SQL 都是参数化的、Redis key 都带 tenantId、Spring Security 的拦截器也工作正常。这种排查走弯路是因为"数据串号"本能联想到安全漏洞,但实际原因经常是线程级别的状态污染。
转机来自 SRE 同事的一个观察:"晚 9:50 老年代占用突然飙了,但没人发版,这个不太正常。"我们立刻调出 JVM 监控,看到老年代占用曲线非常陡峭——平时 1.5GB 稳定一整天,21:50 突然开始线性增长,30 分钟涨到 3.8GB。这个增长曲线指向一个明显的方向:有什么对象在不断累积,而且没有被回收。我们立刻在一个副本上抓了 heap dump 准备分析。
# 抓 heap dump (jmap, 但生产慎用, 会暂停 JVM 几秒)
$ jmap -dump:live,format=b,file=/tmp/heap.hprof
# 更推荐: 启动参数加 HeapDumpOnOutOfMemoryError, OOM 时自动 dump
# -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heap-dumps/
# 在线分析 (不停 JVM)
$ jcmd GC.class_histogram | head -20
# 输出 top 20 类的实例数和占用, 快速定位异常
# 我们看到 com.example.UserContext 实例数 8324 (平时 < 100)
用 jcmd 跑的 class_histogram 一下就锁定了凶手:UserContext 类实例数从平时不到 100 个飙到了 8324 个,占用 312MB。一个用户上下文应该是请求级别的,响应返回后就该被 GC,怎么会累积这么多?这个时候我们就基本知道是 ThreadLocal 出问题了,因为这个项目里只有 ThreadLocal 会"附着到线程"。
问题本质:ThreadLocal 的真实内存语义
很多 Java 工程师对 ThreadLocal 的理解停留在"每个线程一份独立数据",但很少有人深究它的内存结构。ThreadLocal 内部其实是每个 Thread 对象持有一个 ThreadLocalMap,key 是 ThreadLocal 实例的弱引用,value 是你 set 进去的对象(强引用)。这个设计的关键点在两个细节:
第一,value 是强引用,即使 ThreadLocal 实例本身被 GC 了,value 也不会自动被回收,要等下一次 get/set/remove 触发"清理 stale entry"才会清。第二,线程池场景下线程不会销毁,Tomcat 默认会保留 200 个工作线程,这些线程的 ThreadLocalMap 会一直存活,里面的 entry 也跟着存活。这两个特性叠加,就形成了经典的"线程池 + ThreadLocal 不 remove = 内存泄漏 + 数据污染"。
事故代码长这样,典型的"set 了不 remove":
// 事故代码片段 (简化版)
public class UserContextHolder {
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
public static void set(UserContext ctx) {
CTX.set(ctx);
}
public static UserContext get() {
return CTX.get();
}
// 注意: 没有 remove() 方法!
}
// 业务拦截器
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object h) {
String token = req.getHeader("Authorization");
UserContext ctx = parseToken(token); // 解析得到当前用户
UserContextHolder.set(ctx);
return true;
}
// 致命: 没有 afterCompletion 清理 ThreadLocal!
}
// 业务代码
@GetMapping("/api/orders")
public List<Order> myOrders() {
UserContext ctx = UserContextHolder.get();
if (ctx == null) {
throw new UnauthorizedException();
}
return orderService.findByUserId(ctx.getUserId());
}
问题路径非常清晰:请求 A 进来,AuthInterceptor 给当前线程 set 了 userA;请求 A 处理完,线程归还到 Tomcat 线程池(但 ThreadLocal 里 userA 还在);请求 B 复用了这个线程,如果 B 的请求路径里有任何一步在 AuthInterceptor 之前调用了 UserContextHolder.get()(比如某个 @PostConstruct、某个异步 fallback),就会拿到 userA。这就是事故里 VIP 客户看到别人订单的根本原因。
修法 1:配套 remove,最直接但容易漏
最直接的修法是在拦截器的 afterCompletion 里 remove ThreadLocal,这也是教科书答案:
// 修法 1: set 必配对 remove
public class UserContextHolder {
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
public static void set(UserContext ctx) { CTX.set(ctx); }
public static UserContext get() { return CTX.get(); }
public static void remove() { CTX.remove(); } // 新增
}
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object h) {
UserContext ctx = parseToken(req.getHeader("Authorization"));
UserContextHolder.set(ctx);
return true;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
Object h, Exception ex) {
UserContextHolder.remove(); // 关键: 请求结束必清
}
}
这个修法解决了问题,但太依赖工程师自觉。我们项目里 ThreadLocal 总共有 17 个,新人不知道这个潜规则、复杂业务路径下 afterCompletion 可能不会被调用(比如 servlet 直接 sendError)、async 处理时线程切换更复杂——任何一个环节出错,事故就会再来一次。所以我们没有止步于这个方案。
修法 2:用 try-finally 把 remove 强制配对
更鲁棒一点的做法是提供"with" 风格的 API,把 remove 强制和 set 绑死,工程师不可能忘:
// 修法 2: 强制配对的 API
public class UserContextHolder {
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
public static UserContext get() { return CTX.get(); }
// 强制 try-with-resources 模式
public static Scope set(UserContext ctx) {
UserContext prev = CTX.get();
CTX.set(ctx);
return new Scope(prev);
}
public static class Scope implements AutoCloseable {
private final UserContext prev;
Scope(UserContext prev) { this.prev = prev; }
@Override
public void close() {
if (prev == null) CTX.remove();
else CTX.set(prev);
}
}
}
// 业务代码使用 (编译器强制 try-with-resources)
try (var scope = UserContextHolder.set(ctx)) {
// 业务处理
return orderService.findByUserId(ctx.getUserId());
} // 这里自动 close, 一定 remove
这种"Scope"模式来自 OpenTelemetry / Brave 等 tracing 库,经过大量生产验证,几乎不可能出错。我们最终把所有 ThreadLocal 都封装成了这种模式,旧的 set/get/remove 标记 @Deprecated,新代码必须用 Scope。
修法 3:用 InheritableThreadLocal 和 TTL 处理异步
修了主线路径之后还有个隐藏问题:异步任务(CompletableFuture / @Async)拿不到 ThreadLocal。因为异步任务跑在另一个线程,那个线程的 ThreadLocalMap 是空的。Java 原生提供了 InheritableThreadLocal,但只在线程创建时复制,线程池场景下不工作(线程是池化复用的,不会重新创建)。阿里开源的 TransmittableThreadLocal (TTL) 解决了这个问题:
// 修法 3: 用 TTL 在线程池间传递
import com.alibaba.ttl.TransmittableThreadLocal;
import com.alibaba.ttl.threadpool.TtlExecutors;
public class UserContextHolder {
// 换成 TTL
private static final TransmittableThreadLocal<UserContext> CTX
= new TransmittableThreadLocal<>();
public static UserContext get() { return CTX.get(); }
public static Scope set(UserContext ctx) {
UserContext prev = CTX.get();
CTX.set(ctx);
return new Scope(prev);
}
// ... Scope 同上
}
// 异步线程池必须用 TTL 装饰过的
@Bean
public Executor asyncExecutor() {
ThreadPoolExecutor raw = new ThreadPoolExecutor(
4, 16, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
return TtlExecutors.getTtlExecutor(raw); // 关键: TTL 装饰
}
// 业务代码
@Async
public CompletableFuture<Result> processAsync() {
UserContext ctx = UserContextHolder.get(); // 现在能拿到调用方的 ctx
// ...
}
TTL 的原理是在 Runnable/Callable 提交时记录当前 ThreadLocal 的快照,执行前在新线程里 set,执行后 restore,精确模拟了"上下文跟着任务走"的语义。注意所有自定义线程池都必须 TTL 装饰,这个比较容易遗漏,我们最后写了个 Spring 的 BeanPostProcessor 自动包装。
修法 4:Java 21+ 用 ScopedValue 彻底替代
Java 21 引入了 ScopedValue(JEP 446,目前 preview),从语言层面解决了 ThreadLocal 的所有痛点。ScopedValue 是不可变的(set 之后不能改)、强制作用域绑定(用 callWhere/runWhere 包裹)、虚拟线程友好。我们一部分新服务已经迁移到了这个新 API:
// 修法 4: Java 21+ ScopedValue (preview)
public class UserContextHolder {
public static final ScopedValue<UserContext> CTX = ScopedValue.newInstance();
}
// 入口
ScopedValue.where(UserContextHolder.CTX, userCtx).run(() -> {
// 在这个 lambda 里 CTX.get() 才能拿到 userCtx
handleRequest(req);
}); // 自动清理, 不可能泄漏
// 取值
UserContext ctx = UserContextHolder.CTX.get(); // 不在作用域内会抛 NoSuchElement
// 配合虚拟线程 (Java 21 GA)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
ScopedValue.where(UserContextHolder.CTX, userCtx).run(() -> {
executor.submit(() -> {
// 虚拟线程里 CTX 自动可见
UserContext c = UserContextHolder.CTX.get();
});
});
}
ScopedValue 相比 ThreadLocal 的核心优势是编译期就能验证生命周期:不在 where().run() 里的代码根本访问不到 ScopedValue 的值,从根上杜绝了"线程池复用拿到上次的值"这种问题。新项目我强烈推荐直接用 ScopedValue,老项目可以分阶段迁移。
修法 5:在测试和 CI 里加 ThreadLocal 泄漏检测
除了代码层面的修复,我们还在 CI 加了一层防御:每个 PR 必须跑一次集成测试,跑完之后扫描线程池里的所有线程,确认 ThreadLocalMap 为空。这个检查能拦截"代码评审没看出来"的 ThreadLocal 泄漏。
// 修法 5: CI 里跑的泄漏检测 (用反射读 ThreadLocalMap)
public class ThreadLocalLeakDetector {
public static List<String> scanLeaks() throws Exception {
Field threadLocalsField = Thread.class.getDeclaredField("threadLocals");
threadLocalsField.setAccessible(true);
Set<Thread> threads = Thread.getAllStackTraces().keySet();
List<String> leaks = new ArrayList<>();
for (Thread t : threads) {
if (!t.getName().contains("worker")) continue; // 只查工作线程
Object map = threadLocalsField.get(t);
if (map == null) continue;
Field tableField = map.getClass().getDeclaredField("table");
tableField.setAccessible(true);
Object[] table = (Object[]) tableField.get(map);
if (table == null) continue;
for (Object entry : table) {
if (entry == null) continue;
Field valueField = entry.getClass().getDeclaredField("value");
valueField.setAccessible(true);
Object value = valueField.get(entry);
if (value != null) {
leaks.add(t.getName() + " -> " + value.getClass().getName());
}
}
}
return leaks;
}
}
// 集成测试结束后调用
@AfterAll
static void verifyNoLeak() throws Exception {
Thread.sleep(1000); // 等异步任务完成
List<String> leaks = ThreadLocalLeakDetector.scanLeaks();
assertTrue(leaks.isEmpty(),
"ThreadLocal leak detected: " + leaks);
}
这个检测器跑起来比较慢(反射 + 全线程扫描),所以我们只在 CI 跑,不在生产跑。但有它在 CI,几乎所有 ThreadLocal 不 remove 的代码都会在 PR 阶段被拦截,事故几乎不可能再发生。
横向对比:5 种修法的取舍
| 修法 | 实现成本 | 防漏能力 | 性能开销 | 异步支持 | 推荐场景 |
|---|---|---|---|---|---|
| 手动 remove | 低 | 差(依赖自觉) | 0 | 需另写代码 | 遗留代码紧急修 |
| Scope try-with-resources | 中 | 好(编译期约束) | 极低 | 需另写代码 | 主流推荐 |
| TTL | 中 | 好 | 每次提交 5-10μs | 原生支持 | 大量异步场景 |
| ScopedValue (Java 21) | 低 | 极好(语言级) | 比 TL 略低 | 虚拟线程完美 | 新项目首选 |
| CI 泄漏检测 | 低 | 兜底 | 仅测试时 | 不限 | 所有项目必加 |
决策树:遇到类似场景选哪种
团队立的 8 条 Java 上下文与内存纪律
- 禁止裸 ThreadLocal.set/get/remove 三件套:所有 ThreadLocal 必须封装成 Scope 模式(try-with-resources),违反的 PR 被 ArchUnit 拦截。
- 异步线程池必须 TTL 装饰:Spring @Async、CompletableFuture、自定义 Executor,只要会被多个请求复用,都必须用 TTL 装饰。
- InheritableThreadLocal 禁用:在线程池场景下行为不符合直觉,统一用 TTL 替代,新代码不允许新增 InheritableThreadLocal。
- 新项目首选 ScopedValue:Java 21+ 项目优先使用 ScopedValue,享受虚拟线程红利。
- CI 必跑 ThreadLocal 泄漏检测:集成测试结束时扫描所有工作线程,有残留 entry 就 fail。
- Heap 分析常态化:每月用 jmap/jcmd 抓一次生产 heap,看 top 50 类的实例数,异常突增立刻查。
- 老年代占用监控阈值:稳态占用的 1.5 倍设为 P1 告警,2 倍设为 P0,这次事故就是因为只配了 P2 阈值才错过早期。
- 异常路径必须验证清理:测试覆盖 normal/exception/timeout 三种路径,每种都要验证 ThreadLocal 已清理。
常见误诊和易混淆点
事故复盘期间梳理了几个 ThreadLocal 相关的常见误解,这里列出来给大家参考。第一个误解:"ThreadLocal 的 key 是弱引用,所以会自动清理"。这个理解只对一半:key(ThreadLocal 实例)的弱引用确实会被 GC 回收,但 value 是强引用,必须等 ThreadLocalMap 的 set/get 触发 expungeStaleEntry 才会清,而且 entry 本身的 key 变 null 后 value 还在,只是变成了"unreachable entry"。在我们这种"ThreadLocal 是 static 永远不死"的场景下,弱引用一点用都没有。
第二个误解:"Tomcat 8 之后会自动清理 ThreadLocal"。Tomcat 确实在 WebappClassLoader 卸载时会扫一下 ThreadLocal,但那是为了避免 webapp 热部署时的 classloader 泄漏,完全不会在请求结束时清理 value。这个机制对我们这种场景没有任何帮助。
第三个误解:"Spring 的 RequestContextHolder 解决了这个问题"。RequestContextHolder 内部用的就是 ThreadLocal/InheritableThreadLocal,只是 Spring 的 FrameworkServlet 在 doService 的 finally 里调用 resetRequestAttributes() 来 remove,所以"看起来"自动清理。但如果你自己 set 别的 ThreadLocal,Spring 不会帮你清,得自己负责。
事故后做了哪些数据扫描
除了代码修复,事故后我们还做了一轮数据影响范围扫描,这是数据安全事故的必备流程。具体做法是把事故时间段(21:50-22:30)的所有 access log 拉出来,根据 traceId 关联业务日志,看哪些请求可能拿到了别人的数据。最终结果是 18 个请求受影响、涉及 9 个客户,我们逐个联系客户做了说明和补偿,并在系统里给这些请求打了"潜在数据泄露"标签便于审计。
# 用 jq 解析 access log 找出可疑请求
$ cat access.log | jq -r 'select(.timestamp > "2025-08-15T21:50:00")
| select(.timestamp < "2025-08-15T22:30:00")
| "\(.traceId) \(.userId) \(.path)"' \
> suspect_requests.txt
# 关联业务日志, 看实际返回的数据 userId 是否和请求 userId 一致
$ python compare_user_id.py suspect_requests.txt business.log
# 发现 18 条不一致, 写入泄露事件库
Java 上下文传递的几种主流方案
这次事故让我们系统性地思考了 Java 服务里"请求级上下文传递"的几种方案,做了一个横向对比。除了 ThreadLocal 系列,还有几种常见做法各有适用场景。
| 方案 | 原理 | 线程池支持 | 侵入性 | 性能 | 推荐场景 |
|---|---|---|---|---|---|
| ThreadLocal + Scope | 线程本地存储 | 需手动 remove | 低 | 极高 | 同步请求路径 |
| TTL | 提交时快照, 执行时 restore | 原生支持 | 低 | 高 | 大量 async |
| ScopedValue (Java 21) | 语言级作用域绑定 | 原生支持 | 低 | 极高 | 新项目 |
| 参数显式传递 | 每个方法签名加 ctx 参数 | 不需要 | 极高 | 极高 | 核心库代码 |
| Reactor Context | Mono/Flux 上下文链 | 原生支持 | 中 | 高 | 响应式编程 |
| MDC (logging) | SLF4J 内置 ThreadLocal | 需手动清理 | 低 | 极高 | 日志 traceId |
不同的项目应该根据架构特征选不同方案。我们的客服系统是传统 Spring MVC,选了 TTL + Scope 模式;另一个新项目用 Spring WebFlux 响应式,直接用 Reactor Context;还有一个基础库为了零依赖,直接显式传递 context 参数。没有银弹,关键是团队对所选方案的语义和坑非常清楚,并且有机制兜底。
给所有 Java 工程师的几点建议
最后想给所有 Java 工程师几条建议,都是这次 5 小时事故换来的真实教训。第一,不要轻易引入 static ThreadLocal,它是几乎所有 Java 经典内存泄漏的根源,引入前先问自己"能不能用方法参数传递"。第二,线程池的"线程会被复用"是上下文设计的第一原则,任何依附在 Thread 上的状态都必须考虑复用语义。第三,P0 告警要预设而不是事后追加,稳态指标 1.5-2 倍就该告警,等到 OOM 才告警就晚了。第四,定期演练事故处理流程,这次能 5 小时解决靠的是大家平时演练过 heap dump 分析,新人遇到完全没头绪。
升级 Java 21 后的额外收益
事故后我们顺势把这个服务从 Java 17 升到了 Java 21(走灰度发布,两周完成),除了拿到 ScopedValue 之外,还有几个意外的收益值得分享。第一,虚拟线程(Project Loom)让 IO 密集的请求处理吞吐提升了 2-3 倍,Tomcat 线程池配置也简单了(以前要根据 Little's Law 算最优线程数,现在直接 virtual thread per task)。第二,分代 ZGC在 Java 21 GA,GC 停顿稳定在 1ms 以内,比 G1 好很多。第三,新的 String 模板(STR."Hello \{name}")让动态 SQL 拼接更安全。
升级过程也踩了一些坑,主要是第三方库的兼容性:Lombok 必须升级到 1.18.30+、MyBatis 3.5.13 之前的版本和 Java 21 不兼容、部分 ASM 字节码增强库需要新版本。建议升级前用一个独立 staging 环境跑一遍完整回归,问题集中暴露之后再上灰度。
JVM 调参在 ThreadLocal 泄漏场景的取舍
事故复盘还顺便审视了 JVM 启动参数,发现几个值得优化的地方。在 ThreadLocal 泄漏这种场景下,光靠 JVM 调参没法根治问题(对象都还被引用着,GC 拿它们没办法),但合理的参数能让"灾难来得慢一点、监控早一点告警"。我们事故前的启动参数其实有几个不合理的设置,事故后做了系统性整改。
# 事故前的启动参数 (有问题)
java -Xmx4g -Xms4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-jar app.jar
# 问题:
# 1. 没开 HeapDumpOnOutOfMemoryError, OOM 时拿不到现场
# 2. 没限制老年代到一定比例触发 CMS, G1 默认策略对内存泄漏不友好
# 3. 没开 GC log, 事后没法回溯 GC 行为
# 事故后的标准参数
java \
-Xmx4g -Xms4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap-dumps/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=64M \
-XX:+UnlockDiagnosticVMOptions \
-XX:+PrintFlagsFinal \
-XX:NativeMemoryTracking=summary \
-jar app.jar
这套参数里几个关键点:HeapDumpOnOutOfMemoryError 让 OOM 自动 dump 堆,我们专门挂了个 PVC 给 /var/log/heap-dumps/,确保 OOM 后还能拿到现场;ExitOnOutOfMemoryError 让 JVM OOM 后直接退出而不是僵尸状态,配合 K8s 自动重启更可靠;InitiatingHeapOccupancyPercent=45 让 G1 老年代占用 45% 就开始并发标记,事故前用的默认值 45 也是这个值,这里只是显式声明便于审计;详细 GC log 滚动保留 640MB,事后排查必备。
用 Arthas 在线诊断 ThreadLocal 内容
事故现场我们用的是抓 heap dump 离线分析,但其实Arthas(阿里开源的在线诊断工具)能直接在不停服的情况下查看 ThreadLocal 内容,这次事故后我们把 Arthas 设为所有生产服务的标配。Arthas 比 jmap 强的地方在于它能直接 attach 到 JVM、用 OGNL 表达式查询任意对象图,不需要 dump 几 GB 文件再用 MAT 慢慢分析。
# 启动 Arthas (在容器里就能跑)
$ java -jar arthas-boot.jar
# attach 到目标 JVM
$ [arthas]$ attach 12345
# 查看所有线程的 ThreadLocalMap 大小
$ [arthas]$ ognl 'Thread.getAllStackTraces().keySet()
.stream()
.filter(t -> t.getName().contains("http"))
.map(t -> t.getName() + " size=" + @java.lang.Thread@threadLocalsField.get(t))
.collect(@java.util.stream.Collectors@toList())'
# 更直接: 用 vmtool 查看 ThreadLocal 实例
$ [arthas]$ vmtool --action getInstances \
--className com.example.UserContextHolder\$\$Lambda \
--limit 100
# watch 命令实时监控 ThreadLocal.set 调用
$ [arthas]$ watch java.lang.ThreadLocal set \
'{params, target, returnObj}' \
-x 3 -n 10
# -x 3: 展开 3 层; -n 10: 只看 10 次
Arthas 在生产可用是个巨大的优势,但必须设置访问权限,默认监听 127.0.0.1:3658,我们用了 SSH 端口转发上去,不能直接对公网暴露。另外 Arthas 的 watch 命令会修改字节码做埋点,有一定性能开销(每次方法调用多 1-5μs),只在排查问题时用,排查完记得 reset。这次事故之后我们专门为团队做了一次 Arthas 培训,几乎所有疑难杂症都能用它快速定位。
同样的坑在其他 Java 框架里的变种
事故复盘期间我们还梳理了一遍 Java 生态里"线程绑定状态"的常见 API,几乎每一个都有类似的坑。这里列出来让大家警惕,新项目选型时心里有数。
| API/框架 | 线程绑定方式 | 线程池下的坑 | 推荐替代 |
|---|---|---|---|
| SLF4J MDC | ThreadLocal<Map> | 不 clear 串号 | 手动 MDC.clear() 或 logback %X 配合 filter |
| Spring SecurityContextHolder | ThreadLocal<SecurityContext> | 异步任务丢身份 | DelegatingSecurityContextExecutor |
| Spring RequestContextHolder | InheritableThreadLocal | 线程池下行为诡异 | 显式传递 HttpServletRequest |
| OpenTelemetry Context | ContextStorage (默认 TL) | 需要 Scope 配对 | 用官方 wrap 异步 |
| Hibernate Session | ThreadLocal | 事务跨方法时丢 | OpenSessionInView 或显式传 EntityManager |
| JDBC TransactionSynchronizationManager | ThreadLocal 大量字段 | Spring 已封装好, 别自己用 | 用 @Transactional, 别裸操作 |
这张表里最坑的是 MDC,几乎所有 Java 项目都在用 SLF4J,几乎所有项目都有 MDC.put 操作(比如 traceId),但很少有项目记得在请求结束 MDC.clear()。结果就是日志里偶尔会看到"用户 A 的请求里出现了用户 B 的 traceId",但因为日志只是"看着奇怪"不影响功能,大家就当没看见。这次事故后我们专门搜了一遍代码,发现 17 处 MDC.put 都没配对 clear,统一加上之后日志变干净了很多。
OOM 之外的内存问题:Metaspace 和 Direct Memory
事故主要影响的是堆内存,但 Java 服务还有两个常被忽略的内存区域:Metaspace(存类元数据)和 Direct Memory(NIO 用的堆外内存)。这两个区域出问题的症状和堆 OOM 不一样,但同样会导致服务挂。我们事故后做了一次全面内存审计,发现还有几个小坑。
# Metaspace 监控 (动态生成大量类导致 OOM)
$ jstat -gcmetacapacity <PID> 1000
MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC FGCT GCT
0.0 1048576.0 250624.0 0.0 1048576.0 35840.0 123 5 1.234 12.345
# 关注 MC (当前 metaspace 使用), 接近 MCMX (max) 就要查动态类生成
# Direct Memory 监控 (Netty / nio 大量用)
$ jcmd <PID> VM.native_memory summary
Native Memory Tracking:
Total: reserved=8GB, committed=4.5GB
- Java Heap (reserved=4GB, committed=4GB)
(mmap: reserved=4GB, committed=4GB)
- Class (reserved=1GB, committed=256MB)
(classes #45678)
(mmap: reserved=1GB, committed=256MB)
- Thread (reserved=512MB, committed=512MB)
(thread #512)
(stack: reserved=512MB, committed=512MB)
- Direct Buffer (reserved=2GB, committed=1.5GB)
# 关注 Direct Buffer, 如果远超 -XX:MaxDirectMemorySize 就有泄漏
我们这次顺手检查的时候发现 Direct Memory 用了 1.5GB(配置上限 2GB),离危险不远。深入查发现是Netty 的 PooledByteBufAllocator 没有正确 release,有一段代码在异常路径里没调 buffer.release(),导致 buffer 一直被 retain。修复后 Direct Memory 稳定在 200MB 左右。这种"另一个问题被顺带发现并修复"在事故复盘里非常常见,我管它叫"事故的副作用红利"——团队对系统的关注度被强迫拉满,平时不会查的角落都被翻一遍。
Java 21 虚拟线程下 ThreadLocal 的新坑
升级 Java 21 后我们也踩了一个新坑:虚拟线程不应该共享池化的 ThreadLocal。虚拟线程是一种轻量级线程,每个任务一个线程,生命周期短,理论上 ThreadLocal 不会泄漏。但如果你的 ThreadLocal value 持有了大对象(比如缓存了一个 5MB 的反序列化结果),虚拟线程每个都拷贝一份,N 个虚拟线程就是 N * 5MB,内存会爆。
// 虚拟线程下的 ThreadLocal 反模式
public class CacheHolder {
// 错误: 虚拟线程下每个任务都持有 5MB
private static final ThreadLocal<BigCache> CACHE
= ThreadLocal.withInitial(() -> new BigCache(5 * 1024 * 1024));
public static BigCache get() { return CACHE.get(); }
}
// 用 10000 个虚拟线程跑
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
exec.submit(() -> {
BigCache c = CacheHolder.get(); // 每个虚拟线程新建一个 5MB
// 用完虚拟线程结束, GC 回收
});
}
}
// 高峰时同时存活的虚拟线程可能上万, 内存瞬间被吃光
// 正确做法: 用 ScopedValue 或者共享池
public class CacheHolder {
private static final ScopedValue<BigCache> CACHE = ScopedValue.newInstance();
public static void within(BigCache cache, Runnable task) {
ScopedValue.where(CACHE, cache).run(task);
}
}
// 或者用一个对象池, 几十个虚拟线程共享几个 BigCache 实例
Java 21 官方文档专门提到了这一点:"ThreadLocal in virtual threads is supported but discouraged for large objects"。这个 nuance 很容易被新手忽略,因为 ThreadLocal 在平台线程里用了十年都没问题,迁移到虚拟线程时大家以为"逻辑不变",其实内存语义已经完全变了。
事故沟通和外部披露的实践
因为涉及数据安全,这次事故除了技术修复还有外部沟通的环节,这部分经验对很多 Java 团队也有借鉴价值。我们的流程是:1) 技术团队 1 小时内同步给 PM 和 PR;2) PR 在 2 小时内拟好对外说明的初稿;3) 技术 + 法务 + PR 三方确认对外口径,既要承担责任也不能过度暴露技术细节;4) 受影响客户逐个电话 + 邮件双重通知;5) 在产品状态页(status.example.com)发布公开声明。整个外部沟通在事故发生后 12 小时内完成。
这次事故让我们后来专门写了一份"数据安全事故处置 SOP",从技术修复到客户通知到监管报备(GDPR/等保2.0)全流程都写清楚了。事故时人会慌、容易漏步骤、容易说错话,提前把流程固化下来,真的事故时按图索骥就行,极大降低了二次失误的概率。
给老项目改造 ThreadLocal 的步骤
如果你手上有一个已经在生产跑的 Java 项目,担心也有类似的 ThreadLocal 隐患,我建议按下面四步推进改造,不要急着大改一通。第一步,盘点:用 IDEA 全局搜 new ThreadLocal 和 extends ThreadLocal,列出所有定义点,统计数量和上下文,心里有个底。第二步,风险评分:每个 ThreadLocal 看三个维度——是否 static、value 大不大、是否每次请求 set。三个都中的就是高危,优先改;只中一两个的可以延后。
第三步,试点改造:挑一个高危的先改成 Scope 模式,跑一周观察是否有副作用(比如某个角落代码依赖了 ThreadLocal 跨方法存活,改成 Scope 后就抓不到了)。试点通过之后再批量改其他点。第四步,CI 兜底:无论改没改完,先把 ThreadLocal 泄漏检测加到 CI,让所有新代码遵守纪律,老代码慢慢迁移。我们整个改造耗时约 6 周,涉及 17 个 ThreadLocal,期间没有出过新的串号事故,这套节奏是经过实战验证的。
总结
这次 5 小时的数据串号事故的核心教训是:"线程池 + ThreadLocal 不 remove = 数据泄露 + 内存泄漏"。这个组合是 Java 服务里最经典也最容易踩的坑,但很少有团队在新人培训时认真讲,导致每个 Java 工程师在职业生涯里都可能踩一次。我们这次事故的代价是 5 小时停服 + 9 个客户的数据合规事件 + 一次完整的事故复盘,但收获是团队从此对线程上下文这件事有了"敬畏感",所有 ThreadLocal 都改成了 Scope 模式,CI 也加了泄漏检测兜底。
给所有 Java 团队一个建议:不要等数据串号事故发生才去改 ThreadLocal。今天就花半天时间审计一下项目里所有的 ThreadLocal 使用、看看是否都有配对的 remove、CI 加上泄漏检测,把这种类别的事故从根上消灭掉。事故是最贵的老师,但有些课其实可以不上,只要愿意提前花时间投入到代码审计、自动化检测和团队培训。希望这篇复盘能帮到每个正在使用 ThreadLocal 的 Java 团队,提前躲过这个坑。
最后一句感悟,送给所有 SRE 和后端工程师:"数据串号"这类事故的可怕之处不在于难修,而在于一旦发生就是信任崩塌。我们这次涉及 9 个客户,虽然及时通知和补偿了,但客户对系统的信任度肯定打了折扣,VIP 客户后来还专门要求了一份"数据安全审计报告"。这种代价是技术修复换不回来的,所以越是涉及多租户的系统,数据隔离的红线越要严防死守,宁可在上线前多花一周做隔离性测试,也不要在事故后花一年修复客户关系。
—— 别看了 · 2026