title: 客服后台弹出了别人的订单:ThreadLocal 漏 remove 引发的用户串号与 380MB 内存泄漏
tags: [Java, ThreadLocal, 内存泄漏, 线程池, 源码解析]
一条客诉把问题捅了出来
我们的客服工作台有个「快速查单」功能,客服输入订单号,系统校验这个订单是否属于当前客服所在的服务小组,属于才放行。权限上下文用的是很常见的ThreadLocal方案:拦截器里从 token 解析出客服身份塞进去,service 层直接取。
2026 年 5 月,一个客服反馈说她点开查单页,弹出来的是另一个同事正在处理的订单详情。这在客服系统里是很严重的问题,涉及用户隐私数据越权。
第一轮排查方向完全错了。我们去查了 Redis 会话、查了前端缓存、查了 Nginx 的 proxy_cache,折腾了一整天没结果。转折点是运维提了一句:「你们那台机器昨天做了压测吧?」
对上了。前一天下午我们在预发环境跑了压测,Tomcat 线程池被拉满到 200 个线程并长时间复用。而串号只在压测那一小时后出现,之后随着线程慢慢空闲又消失了——这是典型的线程复用带出脏数据。
出事的代码长什么样
public class AgentContextHolder { private static final ThreadLocal<AgentContext> CTX = new ThreadLocal<>(); public static void set(AgentContext c) { CTX.set(c); } public static AgentContext get() { return CTX.get(); } public static void clear() { CTX.remove(); } } @Component public class AgentAuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object h) { String token = req.getHeader("X-Agent-Token"); if (token == null) { resp.setStatus(401); return false; // 直接 return,没 set,也没走到 after } AgentContext ctx = tokenService.parse(token); // 这里可能抛异常 AgentContextHolder.set(ctx); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object h, Exception ex) { AgentContextHolder.clear(); // 看起来清了 } }看起来是标准写法,afterCompletion里清理了。但有两条路径会跳过清理:
tokenService.parse(token)抛异常时,preHandle抛出去了,Spring MVC 的DispatcherServlet只会对已经成功返回 true 的拦截器调用afterCompletion。这个拦截器自己抛的异常,它自己的afterCompletion不会被调用。这条路径不会残留(因为还没 set),但下面这条会。- 真正的元凶在另一处。有位同事为了做异步导出,在 service 里起了子线程,并且写了一个「上下文传递」的工具类:
public void asyncExport(ExportReq req) { AgentContext parent = AgentContextHolder.get(); exportPool.submit(() -> { AgentContextHolder.set(parent); // 传进子线程 try { doExport(req); } finally { // 这里漏了 clear() } }); }exportPool是一个固定 8 线程的池,长期存活。子线程set之后从不remove,于是这 8 个线程各自持有一份客服上下文,永久残留。而doExport里某个分支会调用一个公共的查单方法,那个方法内部取AgentContextHolder.get()做权限校验——拿到的是上一次导出任务留下的客服身份。
导出任务是异步的,客服 A 触发导出后,客服 B 恰好也点了查单,如果 B 的请求被路由到某个正在跑导出的线程池线程(这里还有一层设计问题:导出内部又复用了同一个查单 service,而这个 service 是无状态单例),就会读到 A 的上下文。
ThreadLocalMap 的弱引用到底保护了什么
很多文章会说「ThreadLocal 用了弱引用所以不会内存泄漏」,这个说法只对了一半。看 JDK 17 的Entry:
static class Entry extends WeakReference<ThreadLocal<?>> { Object value; // 注意:value 是强引用 Entry(ThreadLocal<?> k, Object v) { super(k); // key 是弱引用 value = v; } }弱引用挂在key(ThreadLocal 对象本身)上,value是实打实的强引用。引用链是这样的:
Thread→ThreadLocalMap→Entry→value(强)
而Entry→key是弱引用。所以当ThreadLocal对象没有其他强引用时,key 会被 GC 回收变成 null,Entry 变成「stale entry」,但value仍然被Entry.value强引用着,只要线程还活着就回收不掉。
那 JDK 有清理机制吗?有,但是被动的、探测式的:
private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len-1); for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { // 撞到 stale entry replaceStaleEntry(key, value, i); // 顺手清理一段 return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) && sz >= threshold) // 启发式清理 rehash(); }关键点:清理只在set、get、remove被调用时碰巧触发,而且cleanSomeSlots只扫log2(n)个槽位。如果一个线程 set 完就再也不碰这个 map,那些 stale entry 会一直躺着。线程池里长期空闲的线程正是这种情况。
expungeStaleEntry是真正干活的那个:
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; tab[staleSlot].value = null; // 断开 value 强引用,这一行是关键 tab[staleSlot] = null; size--; // 后面对同一 hash 段做 rehash,把因线性探测偏移的 entry 挪回去 Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; }tab[staleSlot].value = null这一行才是解除泄漏的动作。而只有remove()能确定性地走到这里(remove→e.clear()→expungeStaleEntry)。这就是为什么「必须显式 remove」不是最佳实践建议,而是硬性约束。
那次泄漏的实际数据
我用jmap -histo:live和 MAT 各看了一遍:
exportPool8 个线程,每个的ThreadLocalMap里有 4 个 entry(客服上下文、MDC、TransmittableThreadLocal 的一个内部对象、Hibernate 的一个)。- 客服上下文对象本身不大(约 400 字节),但它持有一个
List<ServiceGroup>,而ServiceGroup里又缓存了组内所有客服的简要信息。一个大组有 2000 多人。 - 单个上下文实际保留的对象图大小 47MB,8 个线程加上 Tomcat 那边残留的部分,合计 381MB。堆是 4G,所以没 OOM,只是老年代占用长期偏高,Full GC 频率从每天 1 次涨到每小时 2 次。
顺带发现第二个泄漏:本地开发用热部署,每次 reload 都会创建新的WebappClassLoader。因为 Tomcat 线程池的线程是复用的,它们的ThreadLocalMap里残留着旧 ClassLoader 加载的类的实例,导致旧 ClassLoader 无法回收。启动日志里那句The web application appears to have started a thread named ... but has failed to stop it和created a ThreadLocal with key of type ... but failed to remove it就是 Tomcat 的WebappClassLoaderBase.checkThreadLocalsForLeaks()在告警——我们看了两年,一直当成噪音。
三种上下文传递方案对比
| 方案 | 跨线程池传递 | 清理成本 | 侵入性 | 我的评价 |
|---|---|---|---|---|
裸ThreadLocal+ 手动 remove | 不支持 | 每处都要写 finally | 低 | 只适合单线程链路,异步一律出事 |
InheritableThreadLocal | 只在new Thread时继承,线程池无效 | 同上 | 低 | 线程池场景下是个陷阱,容易给人错误的安全感 |
TransmittableThreadLocal(TTL 2.14.5) | 支持,需包装 Runnable 或用 agent | 由框架在afterExecute回收 | 中,需引依赖或挂 agent | 线程池场景我推荐这个 |
我们最后的选择是 TTL + 一条硬性规范。TTL 的核心是在任务提交时抓一次快照,执行前replay、执行后restore,所以清理是框架保证的,不依赖业务代码写 finally。用-javaagent方式接入可以做到业务代码零改动,我们试过,但线上最终选了显式包装TtlRunnable.get(task)——agent 方式在启动参数被运维脚本覆盖时会静默失效,出问题很难查,显式包装至少能在代码里看到。
规范这块加了三条,都写进了 CI 检查:
// 规范 1:所有 ThreadLocal 必须通过统一 Holder 访问,禁止在业务类里直接 new ThreadLocal // 规范 2:Holder 必须提供 try-with-resources 风格的 scope 方法 public final class AgentContextHolder { private static final TransmittableThreadLocal<AgentContext> CTX = new TransmittableThreadLocal<>(); public static Scope open(AgentContext c) { CTX.set(c); return CTX::remove; // Scope 是个 AutoCloseable } public static AgentContext get() { AgentContext c = CTX.get(); if (c == null) { // 规范 3:取不到上下文直接抛,不允许返回 null 让调用方猜 throw new IllegalStateException("agent context not initialized"); } return c; } public interface Scope extends AutoCloseable { @Override void close(); // 去掉受检异常,调用方不用 catch } }调用方变成:
try (AgentContextHolder.Scope ignored = AgentContextHolder.open(ctx)) { doBusiness(); } // 编译器保证 remove 一定执行这个写法的价值在于把「必须 remove」从人的自觉变成了编译器和语法结构的保证。Scope::close无参数无返回,用方法引用CTX::remove一行就实现了。上线后我们在预发跑了同样的压测场景,串号复现不了了。
规范 3 那条「取不到就抛异常」争议最大,有同事担心会把一些原本能跑的边缘路径打挂。我坚持加了,理由是:权限上下文取到 null 的时候,业务代码要么走了「默认放行」(越权),要么 NPE(500)。前者是安全事故,后者是可见故障,而抛明确异常至少能定位问题。上线后确实打出来 3 个之前没人注意到的调用链——都是定时任务直接调 service 层、根本没有客服身份的场景,正好该修。
复盘数字
- 隐私越权持续时间:约 1 小时 40 分(压测窗口 + 线程慢慢回收的尾巴),实际触发 3 次,其中 1 次被客服上报。
- 定位耗时 1 天半,最大的弯路是一直往「缓存」方向查,没想到线程复用。
- 泄漏内存 381MB,Full GC 频率从每天 1 次到每小时 2 次,改完之后回到每天 1 次以内。
- 顺带清掉了 Tomcat 启动日志里 6 条 ThreadLocal 泄漏告警,其中 2 条是第三方 SDK 的,提了 issue 后对方在 3 周内修了。
我的几个判断
ThreadLocal的问题从来不是内存泄漏,而是数据串号。内存泄漏最多让你多加点内存、多做几次 Full GC;串号是数据正确性和安全问题,一旦涉及权限或金额就是事故。所以我评审代码时看到ThreadLocal,第一个问题永远是「谁负责 remove」,第二个是「会不会跨线程」。
InheritableThreadLocal我不建议在任何有线程池的项目里用。它只在Thread构造时复制父线程的 map,线程池的线程是提前创建、反复复用的,绝大多数情况下继承到的是「创建线程池那一刻」的上下文,比拿不到更危险——因为它有时候能拿到值,让人误以为方案是对的。
不要为了「省一次数据库查询」把大对象塞进 ThreadLocal。我们那个上下文里带 2000 人的组信息,本来是为了避免每次查权限都打 DB。这属于用内存和风险换性能,而实际收益是省了一次 3ms 的 Redis 查询。改造后我们只在上下文里存客服 ID 和组 ID 两个 long,其余按需查带本地缓存,内存占用从 47MB 降到不到 1KB。
Tomcat 那句 ThreadLocal 泄漏告警不该被忽略。它是WebappClassLoaderBase在 stop 时主动扫描每个线程的ThreadLocalMap得出的,误报率很低。我们把它加进了发布后的日志检查项,出现即拦截发布。
留个问题
如果一个ThreadLocal被声明为static final(大多数写法都是这样),那么它作为 key 的弱引用其实永远不会被回收——因为类的静态字段持有它的强引用。在这种情况下,ThreadLocalMap里的 entry 永远不是 stale entry,那么 JDK 的探测式清理机制对它完全无效。这是不是意味着「弱引用设计」在最常见的用法下根本没起作用?你怎么看这个设计?欢迎在评论区讨论。