ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入拆解ThreadLocal:线程隔离、弱引用、内存泄漏与OOM排查

深入拆解ThreadLocal:线程隔离、弱引用、内存泄漏与OOM排查 并发编程系列写到这一篇前面的内容基本都在围绕一个词打转共享。锁、原子类、并发容器本质上都是想让多个线程更安全、更高效地协作同一份数据。而ThreadLocal的思路是反着来的——既然共享这么容易出问题那干脆每个线程各存一份谁也别碰谁的。这个思路听着简单但背后牵扯到弱引用、内存泄漏、线程池上下文传递一系列坑值得用一整篇来拆。ThreadLocal解决的是线程隔离问题不是并发原子性问题搞清楚这一点后面所有原理和实战姿势才立得住。这一篇我打算从最经典的SimpleDateFormat翻车现场切入先让你明白ThreadLocal到底解决了什么问题然后进源码拆一遍set/get/remove的完整链路重点讲ThreadLocalMap的哈希设计和弱引用机制接着把内存泄漏的形成链路彻底还原说清楚为什么线程池是重灾区再给出实战层面的使用规范包括remove的几种姿势和上下文封装思路最后记录一次线上OOM排查实录手把手走一遍jmap加MAT的定位流程。适合刚学并发编程的同学建立正确认知也适合被ThreadLocal泄漏或脏数据坑过的人对照着排查。1. 从SimpleDateFormat翻车现场切入ThreadLocal到底解决了什么问题1.1 SimpleDateFormat在多线程下的崩溃如果你在Java后端写过日期格式化大概率见过这个经典事故一个SimpleDateFormat实例被多个线程同时调用parse()或format()结果日期串错位、数字乱掉甚至直接抛NumberFormatException。private static final SimpleDateFormat SDF new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); public String formatDate(Date date) { return SDF.format(date); // 并发调用时偶发错乱 }根因不复杂SimpleDateFormat内部维护了一个Calendar对象format()和parse()过程中要反复读写这个共享的Calendar。多个线程同时操作同一份可变状态又没有同步数据被互相覆盖自然就乱了。你单独跑单测永远复现不出来一压测就现原形。1.2 加锁、每次新建和ThreadLocal三条路的取舍解决这个线程安全问题通常有三条路。第一条是给format()加synchronized或使用ReentrantLock。这能保证正确性但等于把并发的日期格式化全部串行化。想象一个支付系统每秒钟几千笔订单都要格式化时间所有请求挤在同一把锁上性能损耗肉眼可见。第二条是每次调用都new SimpleDateFormat()。这条路避免了共享可变状态但频繁创建对象会增加GC压力。虽然JIT的逃逸分析在部分场景能把对象优化到栈上但依赖编译器优化本身不可靠尤其在对象构造较重、调用频繁时效果并不稳定。第三条就是用ThreadLocal给每个线程缓存一个SimpleDateFormat实例private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public String formatDate(Date date) { return DATE_FORMAT.get().format(date); }每个线程第一次get()时通过withInitial创建自己的实例之后一直复用。线程之间互不干扰不需要加锁也不存在频繁创建对象的开销。这也直接点出了ThreadLocal的核心定位给每个线程一份独立的私有副本把共享问题转化成隔离问题。方案是否线程安全并发度对象创建开销代码侵入synchronized加锁安全低串行无额外对象低每次new实例安全高高依赖GC兜底低ThreadLocal缓存安全高每个线程仅一次中需注意清理1.3 ThreadLocal解决的是线程隔离不是并发修改不少初学者把ThreadLocal当成线程安全的Map来用这是个危险的误解。ThreadLocal并不保证你对某个对象内部状态的修改是原子的它只是让每个线程看到不同的对象实例。你往ThreadLocal里放一个共享的ArrayList再让100个线程同时往这个List里add那该出事还是出事因为List本身还是同一个。所以在实践中ThreadLocal的典型场景是那种每个线程天然该有一份但又不方便作为参数层层传递的东西事务连接、用户登录上下文、traceId、请求级别的缓存、框架层面的上下文对象。Spring的RequestContextHolder、MyBatis的SqlSessionTemplate在底层都用ThreadLocal绑定当前线程的资源。理解这点你才不会被后续的内存泄漏问题带偏思路——它存储的本质是线程级上下文而不是并发安全容器。2. ThreadLocal的源码级拆解线程本地变量是怎么存进去、取出来的2.1 每个线程都自带了两个Map字段很多人以为ThreadLocal是把数据存在ThreadLocal对象里这是错的。真正存储数据的地方是Thread类内部的两个字段threadLocals和inheritableThreadLocals。看JDK源码里Thread.java的字段定义一目了然ThreadLocal.ThreadLocalMap threadLocals null; ThreadLocal.ThreadLocalMap inheritableThreadLocals null;每个线程对象自带一个ThreadLocalMap。当你调用ThreadLocal.set(value)的时候本质是把当前线程的threadLocals这个Map取出来往里面塞了一条记录key是ThreadLocal对象自身value是你传入的数据。不同ThreadLocal实例就是同一个线程Map里不同的key。所以线程隔离的准确含义是数据分散存储在各个线程自己的Map里而不是存在ThreadLocal对象上。inheritableThreadLocals则是给子线程用的后面讲子线程传递时再细说。这里先记住getMap(t)方法本身没做什么高深的事它就是返回t.threadLocals这个字段。很多人在看源码时卡在这个方法上其实它就是个访问器热词里那句threadlocal getmap指的就是这一步。ThreadLocalMap getMap(Thread t) { return t.threadLocals; }2.2 set、get、remove的完整调用链先看set()在JDK 8里的实现public void set(T value) { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) map.set(this, value); else createMap(t, value); }流程很直白拿到当前线程取出它的threadLocals如果Map已经存在就直接往里放不存在就创建一个新Map并塞入第一条记录。createMap内部会new一个初始容量16的ThreadLocalMap并把当前ThreadLocal和value作为第一个Entry放进去。再看get()public T get() { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) { ThreadLocalMap.Entry e map.getEntry(this); if (e ! null) return (T)e.value; } return setInitialValue(); }线程的Map存在并且能找到以当前ThreadLocal为key的Entry就返回里面的value找不到就调用setInitialValue()——它会执行initialValue()方法默认返回nullwithInitial就是重写这个方法把初始值塞进Map再返回。remove()更直接public void remove() { ThreadLocalMap m getMap(Thread.currentThread()); if (m ! null) m.remove(this); }看到这里你应该已经发现一个关键点ThreadLocal的线程隔离能力完全建立在每个Thread对象内部的Map之上。这意味着只要线程还活着它Map里所有的value都不会被自动释放。这条结论是理解内存泄漏的起点。2.3 哈希散列为什么ThreadLocal敢用线性探测硬扛冲突ThreadLocalMap底层是个Entry数组初始容量16负载因子是2/3。每个ThreadLocal实例在创建时都会通过一个全局的AtomicInteger累加得到一个threadLocalHashCode增量是那个著名的魔数0x61c88647源码里叫HASH_INCREMENT。private final int threadLocalHashCode nextHashCode(); private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }为什么要定这个增量它和黄金分割比例有关对应的是斐波那契散列。简单说0x61c88647能保证ThreadLocal的哈希值在数组长度是2的幂时均匀地散布在槽位上最大程度避免多个ThreadLocal挤在同一条探测序列上。你不需要深挖数学推导只要记住结论ThreadLocalMap的钥匙分布是经过精心设计的所以在ThreadLocal数量不多的前提下用开放地址法线性探测解决冲突就够了不需要像HashMap那样挂链表、转红黑树。定位槽位的语句是这样的int i key.threadLocalHashCode (table.length - 1);table.length永远是2的幂所以按位与等价于取模而且比取模快。如果槽位被占就往后找空位找的时候如果遇到key为null的过期Entry还会顺手做清理。这套机制让ThreadLocalMap在低冲突场景下性能非常好代价是它不适合存储大量key——如果你在一个线程里new了几百个ThreadLocal线性探测的性能就会明显劣化。这也是为什么实战规范里强调能复用的ThreadLocal尽量复用。3. 弱引用不等于安全ThreadLocal内存泄漏的完整链路分析3.1 Entry的引用链key是弱引用value是强引用先看ThreadLocalMap.Entry的定义static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); value v; } }注意Entry继承自WeakReference也就是说Entry本身是个弱引用引用的是key也就是ThreadLocal对象。而value字段是一个普通强引用。整条引用链画出来是这样Thread 对象 └─ ThreadLocalMap └─ Entry[] └─ Entry (弱引用 - ThreadLocal key) └─ value (强引用 - 你塞进去的数据)弱引用的语义是当GC发生时如果一个对象只被弱引用指向没有任何强引用它就会被回收。也就是说ThreadLocal对象一旦在业务代码里失去外部强引用比如方法局部变量用完了GC就有资格回收它Entry的key就变成null。这看起来是个保护机制框架不知道业务什么时候不再需要ThreadLocal所以用弱引用保证ThreadLocal实例本身可以被回收。但value没有这层保护。value是强引用只要Entry还在value就一直在。而Entry被线程的ThreadLocalMap持有ThreadLocalMap又被线程对象持有。只要线程还活着这条链就断不开。3.2 泄漏的完整形成条件我见过很多人把ThreadLocal内存泄漏简单归结为用了弱引用这其实是误解。弱引用恰恰是为了避免ThreadLocal对象本身泄漏真正的问题出在value的强引用链上。一个完整的泄漏需要同时满足三个条件ThreadLocal对象失去外部强引用。最常见的是在方法内部直接new ThreadLocal()使用方法执行完局部变量没了ThreadLocal实例只剩Entry里的弱引用GC一发生就被回收。线程是长生命周期的。线程池里的worker线程、Tomcat的请求处理线程都是长期存活的只要线程不死它的ThreadLocalMap就一直在。没有后续操作触发清理。如果后面再也不碰这个ThreadLocalMap底层的expungeStaleEntry()清理逻辑永远不会执行value就变成永远无法访问但一直被强引用的垃圾。典型的业务场景长这样public void handleRequest(Request req) { ThreadLocalbyte[] holder new ThreadLocal(); holder.set(new byte[1024 * 1024]); // 1MB // 业务处理... // 忘记remove方法结束后holder失去外部引用 }如果这个handleRequest被丢进一个线程池执行每个请求都new一个ThreadLocal并set入大对象那么每次请求都会在线程的Map里留下一个key为null、value为1MB数组的过期Entry。线程池线程不死这些Entry就永远躺在那里。QPS稍微高一点内存涨起来非常快。3.3 线程池既是泄漏放大器也是脏数据制造机线程池把线程长生命周期这个条件放大了。普通线程执行完一个任务就结束整个ThreadLocalMap随着线程销毁被回收根本谈不上泄漏。但线程池的worker线程是复用的它们一直在等新任务threadLocals这个Map也跟着一直存活。线程池还会带来第二个问题脏数据串线。假设你写了一个登录用户信息上下文private static ThreadLocalUser currentUser new ThreadLocal();任务A里执行了currentUser.set(userA)但忘了remove任务A跑完worker线程回到池子里待命。任务B被分配到同一个worker线程如果任务B的代码路径在某个分支没有主动set用户信息它currentUser.get()读到的就是用户A的信息。轻则业务数据错乱重则出现越权访问。这类问题在代码Review里很难发现因为它不是必现的完全取决于线程池把哪个任务分配给哪个worker。所以在线程池场景下ThreadLocal的正确用法不是用完等GC而是用后必须remove甚至要在任务最外层做防御性清理。3.4 ThreadLocalMap的兜底清理机制能救命但不能依赖JDK的设计者当然知道这个坑所以ThreadLocalMap在几个关键操作里内置了清理逻辑set()时如果发现相同key的Entry会用新值覆盖并对探测路径上的过期Entry做清理get()未直接命中时会在getEntryAfterMiss()里线性向后找遇到key为null的Entry会调用expungeStaleEntry()把value置null、槽位置空rehash()时也会先全面清理再扩容。这套机制确实能在很多情况下兜底比如你反复set同一个ThreadLocal旧的过期Entry大概率会被顺带清掉。它的问题是一切清理都必须由后续的set/get/remove操作触发。如果线程执行完任务后长时间闲置没有任何关于这个Map的操作过期Entry就一直静止在内存里。你指望GC救你但GC根本碰不到value——它有一条完整的强引用链。结论很明确底层清理是优化不是保障业务侧的remove()才是唯一靠得住的释放手段。4. 实战守则如何正确使用ThreadLocal而不埋雷4.1 remove是底线三种清理姿势先说最基础的姿势也是我要求团队必须遵守的用完之后在finally里remove无论正常返回还是抛出异常都必须执行。private static final ThreadLocalString TRACE_ID new ThreadLocal(); public void process() { try { TRACE_ID.set(generateTraceId()); doSomething(); } finally { TRACE_ID.remove(); } }为什么必须在finally而不是在方法末尾因为方法中间抛了异常末尾的remove根本执行不到然后残留值就留在线程里了。线上抛异常是常态不是意外。很多泄漏就是在某个异常分支里漏掉了清理。第二种姿势是在框架的拦截器或过滤器中统一清理。以Spring Web应用为例如果你需要请求级的上下文更推荐用HandlerInterceptor的afterCompletion方法public class TraceIdInterceptor implements HandlerInterceptor { Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TraceContext.clear(); // 内部调用ThreadLocal.remove() } }Filter或者Interceptor的好处是入口和出口都在框架层面业务代码不需要在每个方法里try-finally漏清理的概率大大降低。第三种姿势是封装成AutoCloseable配合try-with-resources使用public class AutoThreadLocalT extends ThreadLocalT implements AutoCloseable { Override public void close() { remove(); } }这种写法适合那种只在单个方法里临时用的场景代码会更紧凑但我个人更推荐前两种——因为try-with-resources要求每个用到的代码块都正确写语法而Filter/Interceptor是集中式治理对团队更友好。4.2 static修饰符到底该不该加这是一个经常被问到的点。结论分两种情况。如果ThreadLocal是Spring单例Bean的成员变量那实例只有一份ThreadLocal对象长期存活相当于static。这种情况下用不用static修饰影响不大但为了语义清晰统一用private static final。真正危险的是在短生命周期对象里持有ThreadLocal。比如一个每次请求都new的Helper类里面定义了一个实例字段ThreadLocalString holder。Helper对象在请求结束时失去引用ThreadLocal对象也失去强引用GC把这key回收后Map里就剩一个value还在。如果这个请求跑在线程池里下次任务又new一个新的Helper、新的ThreadLocalMap里的过期Entry持续累积。这种写法是我在代码Review里看到最多的隐性雷。所以我的建议是ThreadLocal实例能定义为static final就优先static final。它不会让你少写remove但能避免ThreadLocal对象本身被GC回收导致value失联这条更隐蔽的泄漏路径。4.3 统一封装上下文工具类把ThreadLocal关进笼子业务代码里散落使用ThreadLocal最大的问题不是语法错误而是管理混乱。今天你在A服务里set了个用户ID明天B服务也想用又不好意思改A的代码于是自己又new了一个ThreadLocal。线程Map里的key越来越多清理也越来越不彻底。我建议每个项目针对线程级上下文做统一封装。比如这样一个TraceContextpublic final class TraceContext { private static final ThreadLocalString TRACE_ID new ThreadLocal(); private TraceContext() { } public static void setTraceId(String traceId) { TRACE_ID.set(traceId); } public static String getTraceId() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } }然后在整个调用链的最外层——Filter、Interceptor、或者异步任务的入口——统一调用setTraceId统一在finally里调用clear。业务代码只通过静态方法读写不需要知道底层的ThreadLocal长什么样。这样做还有一个好处以后想换成TransmittableThreadLocal只需要改这一个类不用满项目找散落的ThreadLocal.set/get。4.4 子线程与线程池的变量传递InheritableThreadLocal的局限和TTL的解法很多场景需要把父线程的上下文传到子线程比如异步任务里记录traceId。JDK提供的原生方案是InheritableThreadLocal。它的实现原理是父线程创建子线程时把父线程的inheritableThreadLocals里的Entry复制一份给子线程。注意是创建线程那一刻的快照而且这个复制是浅拷贝——如果value是可变对象父子线程持有的是同一个引用并发修改照样有竞争问题。InheritableThreadLocal最大的局限在于线程池。线程池的worker线程不是每次任务都新建的它早在提交任务之前就创建好了。父线程想传值给worker线程根本不触发线程创建过程InheritableThreadLocal完全无效。而且即使你第一次提交任务时值传过去了下次提交新值也不会更新因为worker线程不会再走创建线程复制这条路径。这个场景下业界更常用的方案是阿里开源的TransmittableThreadLocal简称TTL。它的思路是在任务提交时捕获当前线程TTL值的快照任务真正执行前把快照回放到执行线程上执行结束后恢复执行线程原有的值。使用方式很简单ExecutorService executor TtlExecutors.getTtlExecutorService(executorService); executor.submit(() - { // 这里能正确读到提交任务时的上下文 });不引入依赖包的情况下你也可以自己包装Runnable在run()前后手动set/remove实现思路和TTL一致提交时快照执行前回放执行后清理。只不过TTL把这个逻辑封装好了还支持Java Agent方式自动透传。如果项目里大量使用线程池且需要传递traceId、用户身份这类上下文建议直接把TTL纳入基础设施。5. 一次线上OOM排查实录如何定位到ThreadLocal泄漏5.1 现象老年代持续上涨但GC后回不去之前接手过一个异步处理服务现象很典型JVM老年代使用率从启动后一路爬升Full GC之后也只是从95%降到80%很快又涨回去。接口响应时间越来越长最后每天固定OOM一次只能靠重启续命。第一反应肯定是先看GC日志和内存曲线。用jstat看一眼jstat -gcutil pid 1000输出里重点观察FGCFull GC次数和O老年代使用率。如果YGC很频繁、FGC也在持续增长但老年代使用率始终处于高位说明堆里有大量对象无法被回收。这时候就要考虑是不是有对象被长生命周期对象比如线程、类加载器、缓存持有形成了事实上的泄漏。值得提醒的是不要一看内存高就无脑调-Xmx。调大堆只会推迟OOM时间不会解决问题。正确步骤是把堆dump下来看对象构成。5.2 用jmap导出堆快照再用MAT定位可疑对象低峰期用jmap导出堆快照jmap -dump:live,formatb,fileheap.bin pid注意live参数会先触发一次Full GC生产环境尽量在业务低谷操作或者改用jcmd pid GC.heap_dump heap.bin。dump文件通常很大本地用MATMemory Analyzer打开。打开后先看Histogram直方图按retained heap排序。在这个服务里我很快就看到了一个熟悉的自定义类RequestContext有几万个实例retained heap占了差不多1GB。这个类为什么会单例持有那么多实例肯定是被某个容器类缓存了。接下来右键这个类选择List objects - with incoming references看看引用它的是什么。你会看到大量引用来自java.lang.ThreadLocal$ThreadLocalMap$Entry这就基本锁定方向了这些对象都被ThreadLocalMap里的Entry强引用着。5.3 顺着GC Roots路径确认是ThreadLocal链确认这一步需要右键对象选Path to GC Roots - exclude weak references。如果之前看过一遍ThreadLocal的引用链此刻再看这条路径会非常清晰Thread (worker线程) └─ ThreadLocalMap └─ Entry[ ] └─ Entry └─ value (RequestContext实例)路径里能看到Thread对象是GC Roots因为线程池里的worker线程都活着正等着新任务。顺着路径往下还会发现一个关键细节很多Entry的key已经是null了。这说明ThreadLocal对象本身已经被GC回收但value还躺在Entry里。这就是典型的key弱引用被回收、value强引用残留的泄漏形态。如果只看Entry数量还不足以定位到代码就再切到线程栈视图把目标Thread的线程栈打出来看看这个线程最近在执行什么业务然后回代码里找这个业务链路上的set()调用。我当时就是从worker线程绑定的任务名一路追到一个公共的异步切面发现在切面里为了记录traceId每次请求都new ThreadLocal()set完之后没有remove方法结束ThreadLocal失去强引用剩下的value就全留在线程池线程的Map里了。5.4 修复与验证一行remove解决几百MB内存修复方案很简单把那个切面里的new ThreadLocal改成静态常量并在finally块里调用remove()。public class AsyncTraceAspect { private static final ThreadLocalString TRACE_ID new ThreadLocal(); public Object around(ProceedingJoinPoint point) throws Throwable { try { TRACE_ID.set(buildTraceId()); return point.proceed(); } finally { TRACE_ID.remove(); } } }改完之后再用jmap导一次堆用MAT对比修复前后的对象数量。最直观的验证指标有两个一是RequestContext的实例数从几万降到了和线程数同一量级二是老年代使用率在几次Full GC后稳定在30%左右不再持续爬坡。这个效果不是靠调参数调出来的是真正把引用链断了。排查过程中还有一个容易忽略的点如果你在MAT里用exclude weak references查不到GC Roots路径不要慌。key为null的Entry本身已经断开了对ThreadLocal的引用MAT的弱引用排除规则可能导致路径不显示。这时候换include all references再查或者直接在Dominator Tree里找ThreadLocalMap通常能看到完整的引用链。这次排查给我留下的最深印象是ThreadLocal泄漏很少是单个大对象的问题更多是业务对象被线程池线程长期持有的组合问题。你单看每个对象都不算大但线程池有几百个线程每个线程攒几百个过期Entry就是几百MB甚至上GB的垃圾。它在代码Review阶段极难发现因为所有set/remove分散在各个方法里没有一个集中的审视点。这也是为什么我会在前面的实战部分反复强调统一封装和拦截器清理——线上少踩一个坑比事后排查轻松太多。最后分享一个我个人的习惯线上服务里如果需要排查ThreadLocal相关的问题除了jmap和MAT也可以用Arthas的watch命令观察某个上下文类的set和remove调用次数。如果发现set被疯狂触发但remove几乎不触发那基本不用dump堆也能判断问题出在哪了。这套组合拳下来ThreadLocal这个线程私有的小笼子在你手里就不再是黑盒了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进