Java ThreadLocal 不 remove 引发的数据串号事故复盘:5 小时定位 + 5 种修法 + 8 条上下文纪律

Tomcat 线程池下 ThreadLocal 不 remove 导致用户上下文被复用,VIP 客户看到了别人的订单和手机号,P0 事故 5 小时停服、9 个客户数据合规事件。这篇完整复盘事故时间线、ThreadLocal 真实内存语义(value 强引用 + 线程池复用)、heap dump 分析、5 种修法(手动 remove / Scope try-with-resources / TTL / Java 21 ScopedValue / CI 泄漏检测)、Arthas 在线诊断、虚拟线程下 ThreadLocal 新坑、外部沟通 SOP。

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 上下文与内存纪律

  1. 禁止裸 ThreadLocal.set/get/remove 三件套:所有 ThreadLocal 必须封装成 Scope 模式(try-with-resources),违反的 PR 被 ArchUnit 拦截。
  2. 异步线程池必须 TTL 装饰:Spring @Async、CompletableFuture、自定义 Executor,只要会被多个请求复用,都必须用 TTL 装饰。
  3. InheritableThreadLocal 禁用:在线程池场景下行为不符合直觉,统一用 TTL 替代,新代码不允许新增 InheritableThreadLocal。
  4. 新项目首选 ScopedValue:Java 21+ 项目优先使用 ScopedValue,享受虚拟线程红利。
  5. CI 必跑 ThreadLocal 泄漏检测:集成测试结束时扫描所有工作线程,有残留 entry 就 fail。
  6. Heap 分析常态化:每月用 jmap/jcmd 抓一次生产 heap,看 top 50 类的实例数,异常突增立刻查。
  7. 老年代占用监控阈值:稳态占用的 1.5 倍设为 P1 告警,2 倍设为 P0,这次事故就是因为只配了 P2 阈值才错过早期。
  8. 异常路径必须验证清理:测试覆盖 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 ThreadLocalextends 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Go GC STW 在 180GB 堆下飙到 800ms 导致风控漏判的复盘:5 种修法从指针消灭到 mmap

2026-5-25 17:42:40

技术教程

Redis 大 key 阻塞主线程导致集群级雪崩的复盘:18 分钟反复切换 + 5 种修法 + 9 条治理纪律

2026-5-25 18:19:16

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索