并发编程系列写到这一篇,前面的内容基本都在围绕一个词打转:共享。锁、原子类、并发容器,本质上都是想让多个线程更安全、更高效地协作同一份数据。而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 ThreadLocal<SimpleDateFormat> 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()方法(默认返回null,withInitial就是重写这个方法),把初始值塞进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 WeakReference<ThreadLocal<?>> { 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) { ThreadLocal<byte[]> 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 ThreadLocal<User> 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 ThreadLocal<String> 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 AutoThreadLocal<T> extends ThreadLocal<T> 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类,里面定义了一个实例字段ThreadLocal<String> holder。Helper对象在请求结束时失去引用,ThreadLocal对象也失去强引用,GC把这key回收后,Map里就剩一个value还在。如果这个请求跑在线程池里,下次任务又new一个新的Helper、新的ThreadLocal,Map里的过期Entry持续累积。这种写法是我在代码Review里看到最多的隐性雷。
所以我的建议是:ThreadLocal实例能定义为static final就优先static final。它不会让你少写remove,但能避免"ThreadLocal对象本身被GC回收导致value失联"这条更隐蔽的泄漏路径。
4.3 统一封装上下文工具类,把ThreadLocal关进"笼子"
业务代码里散落使用ThreadLocal,最大的问题不是语法错误,而是管理混乱。今天你在A服务里set了个用户ID,明天B服务也想用,又不好意思改A的代码,于是自己又new了一个ThreadLocal。线程Map里的key越来越多,清理也越来越不彻底。
我建议每个项目针对"线程级上下文"做统一封装。比如这样一个TraceContext:
public final class TraceContext { private static final ThreadLocal<String> 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输出里重点观察FGC(Full GC次数)和O(老年代使用率)。如果YGC很频繁、FGC也在持续增长,但老年代使用率始终处于高位,说明堆里有大量对象无法被回收。这时候就要考虑:是不是有对象被长生命周期对象(比如线程、类加载器、缓存)持有,形成了事实上的泄漏。
值得提醒的是,不要一看内存高就无脑调-Xmx。调大堆只会推迟OOM时间,不会解决问题。正确步骤是把堆dump下来看对象构成。
5.2 用jmap导出堆快照,再用MAT定位可疑对象
低峰期用jmap导出堆快照:
jmap -dump:live,format=b,file=heap.bin <pid>注意live参数会先触发一次Full GC,生产环境尽量在业务低谷操作,或者改用jcmd <pid> GC.heap_dump heap.bin。dump文件通常很大,本地用MAT(Memory 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 ThreadLocal<String> 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这个"线程私有的小笼子",在你手里就不再是黑盒了。