ThreadLocal底层原理解析:从哈希冲突到内存泄漏的完整链路
1. 先搞清楚ThreadLocal到底解决什么问题很多同学第一次接触ThreadLocal是在面试题里看到ThreadLocal会造成内存泄漏这句话。但如果你直接拿这句话去背基本等于没学。先忘掉内存泄漏我们从一个最简单的场景出发。假设你在写一个Web服务每个请求进来都要经过拦截器、Controller、Service、DAO这一条链路。现在你想要在整个请求链路里共享同一个用户的ID同时不同请求之间的用户ID要完全隔离不能被串。这时候你会怎么做最简单的办法是每个方法都加一个参数一层层往下传。但这样会污染方法签名而且如果链路很深中间某个方法不关心这个参数还得被迫接收它。另一种办法是搞一个全局静态变量但这个方案在并发环境下直接挂了——请求A设置了userID100还没处理完请求B进来把userID改成了200A后面读到的就全是200了。ThreadLocal就是为这个场景设计的。它本质上是每个线程独立的变量副本。同一个ThreadLocal对象在A线程里设置值只会存储在A线程专属的空间里B线程去读是nullA也读不到B的值。你可以把它理解成车站的储物柜每个线程有一把自己专属的柜子钥匙存进去的东西只有自己能取出来别人碰不到你的柜子。这个每线程一份独立空间的能力是解决线程安全问题、线程间数据隔离、链路传参问题的关键基础设施。Java里很多重量级框架底层都在用它Spring里的事务管理、MyBatis里的SqlSession管理、日志框架里的MDC全都依赖ThreadLocal。所以搞懂它不只是为了过面试而是真的能让你在写并发代码、看框架源码的时候少走很多弯路。这篇是史上最全系列的第一篇我会从基础用法讲到底层原理再把哈希冲突、弱引用、内存泄漏这些重难点逐个拆开最后梳理实际项目中能落地的应用场景。内容比较多但每一步都会有代码和类比跟着走别急。2. 核心用法和API不只有set和get2.1 从最基本的set/get开始先看一段最人门的代码public class ThreadLocalBasicDemo { private static final ThreadLocalString USER_ID new ThreadLocal(); public static void main(String[] args) throws InterruptedException { Thread threadA new Thread(() - { USER_ID.set(user-1001); System.out.println(A线程读取: USER_ID.get()); // A线程用完后清理 USER_ID.remove(); }, thread-A); Thread threadB new Thread(() - { System.out.println(B线程读取: USER_ID.get()); // 注意B线程这里没有set过所以是null }, thread-B); threadA.start(); threadB.start(); threadA.join(); threadB.join(); // 主线程读取也是null System.out.println(主线程读取: USER_ID.get()); } }执行结果基本是A线程读取: user-1001 B线程读取: null 主线程读取: null这几十行代码里其实包含了三个关键信息。第一ThreadLocal变量是线程共享的引用但每个线程set进去的值彼此不可见。第二不同线程get到的结果互不干扰B线程和主线程没有set过所以拿到的是null。第三使用完之后必须手动调用remove()清理这一点我在后面讲内存泄漏的时候会重点说。2.2 初始值的设置方式initialValue与withInitial上面的代码里有个问题如果我想让每个线程在没set之前get到一个默认值而不是空指针该怎么做到JDK提供了两种方式。第一种是重写initialValue()方法private static final ThreadLocalSimpleDateFormat DATE_FORMAT new ThreadLocalSimpleDateFormat() { Override protected SimpleDateFormat initialValue() { return new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); } };第二种是JDK 8之后推荐的函数式写法private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss));用withInitial的好处是代码更简洁而且只要看清了lambda表达式里的逻辑就知道每个线程在首次get()时执行这段初始化逻辑工厂方法会被线程自己调用一次。注意这个withInitial对每个线程只会执行一次初始化不会重复创建对象。这里有一个非常典型的应用组合SimpleDateFormat ThreadLocal。SimpleDateFormat不是线程安全的多线程共用同一个实例解析日期会出各种诡异问题比如解析结果错乱、抛NumberFormatException。传统的做法是每次用的时候new一个但频繁创建对象在高并发下又浪费性能。用ThreadLocal给每个线程挂一个SimpleDateFormat实例既线程安全又只需要在线程生命周期内创建一次。2.3 为什么用静态final修饰ThreadLocal变量你观察上面的示例代码几乎都是private static final ThreadLocal。这个写法不是规范要求而是实践下来最不容易出错的写法。原因是ThreadLocal对象本身属于进程级别共享的索引角色它本身不存业务数据数据存在线程的ThreadLocalMap里。如果每个请求都去new一个ThreadLocal那这个ThreadLocal的哈希值每次都不同线程里的Map就会堆积大量用不到的Entry反而加速内存膨胀。另外如果你把ThreadLocal定义为某个类的普通实例字段那这个ThreadLocal的生命周期就绑定在类实例上。类实例被回收了ThreadLocal理论上也该被回收但如果线程还存活着它的ThreadLocalMap里还挂着这个ThreadLocal的弱引用Entry又得依赖后续清理逻辑去回收白白增加复杂度。用static final让ThreadLocal对象的生命周期和应用进程一致是边界最清楚的方案。2.4 用remove清理是铁的纪律前面反复提到remove这里先记住一句话调用remove(), 是ThreadLocal使用者的责任不是框架的责任。ThreadLocal自己不知道你什么时候用完它只能在你下一次操作的时候顺带清理一些垃圾做不到精准地用完全自动擦除。所以凡是在代码里set过ThreadLocal后续逻辑跑完请在finally块里remove掉。try { USER_ID.set(userId); // 执行业务逻辑 } finally { USER_ID.remove(); }这段代码值得写进每一个使用ThreadLocal的项目里。不要嫌烦你省了remove等到线上出现内存不断增长、GC越来越频繁的时候再回来看这段代码一定会感慨当初怎么没写。3. 底层原理ThreadLocalMap到底长什么样3.1 每个Thread对象里都藏着一个Map先看一段关键代码这是Thread类的内部定义简化到只看关键字段public class Thread implements Runnable { // 每个线程对象内部都持有这样一个ThreadLocalMap ThreadLocal.ThreadLocalMap threadLocals null; }当我们调用threadLocal.set(value)时实际发生的事情是先获取当前线程的threadLocals如果为null则创建并赋值如果已经有就把value按当前threadLocal对象为keyput进去。get的时候从当前线程的threadLocals里以当前threadLocal对象为key取出value。这个设计很巧妙。ThreadLocal本身不持有数据数据全部在Thread内部的Map里。这样一来线程死亡时线程对象被回收它的ThreadLocalMap也会随之消失天然做到了线程作用域隔离。3.2 Entry用弱引用当key这是防泄漏的第一道防线ThreadLocalMap的内部定义了一个Entry它是这样声明的static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); value v; } }注意一个细节Entry继承了WeakReference并且它的key就是ThreadLocal本身是作为弱引用被持有的。为什么要用弱引用我用一个反向的假设来说明。假设Entry用强引用持有ThreadLocal。现在你的ThreadLocal是用static修饰的那还好生命周期长。但如果不是static而是一个局部变量或者某个临时对象的字段栈帧结束后ThreadLocal理论上应该被回收。但这时线程的ThreadLocalMap里还强引用着这个ThreadLocal导致ThreadLocal无法被GC回收进而整个Entry也无法被回收。线程池里的线程通常生命周期很长这种泄漏就会持续累积。换成弱引用之后ThreadLocal除了弱引用Entry外没有其他强引用了那GC在回收ThreadLocal时就不会被Entry拦住。至少key这一侧是可以被回收的value那一侧的问题我们下一节继续说。3.3 哈希冲突处理不是拉链是线性探测ThreadLocalMap这个内部Map不像HashMap那样通过链表或红黑树解决冲突它用了开放地址法具体来说是线性探测。简单说ThreadLocalMap底层是一个Entry数组table通过key的哈希值定位到数组下标。如果这个位置已经有Entry了就继续向下一个位置找找到空位就放进去取的时候也是先定位到数组下标如果key不匹配就继续往下找直到找到key匹配为止。有人可能会问为什么不用链地址法原因主要有两个。一是ThreadLocalMap的Entry数量通常很少无非就是每线程里那几个ThreadLocal变量用链表反而浪费内存和维护成本。二是ThreadLocalMap定位是靠ThreadLocal对象的哈希值配合threadLocalHashCode这个被设计好的递增哈希能均匀分布在数组上。虽然线性探测在冲突严重时可能退化成O(n)但在每线程几个变量这种场景下性能压力完全不是问题。3.4 扩容阈值和初始容量ThreadLocalMap的初始容量是16扩容阈值是数组长度的2/3。当size超过阈值时会执行rehash把table扩容到原来的两倍。这个2/3的阈值比HashMap的0.75小一些主要是为了给线性探测留出足够多的空位减少冲突概率。另外ThreadLocalMap在每次扩容前还有一个很关键的动作会先清理掉所有key为null的过期Entrystale entry把容量先瘦身再判断是否需要扩容。这个清理逻辑我们下节详细讲它是ThreadLocal防泄漏的一个重要机制。4. 内存泄漏的完整链路为什么必须remove4.1 弱引用只解决了keyvalue还在回到最关键的问题ThreadLocal为什么会产生内存泄漏我画一条完整的链路来说。假设线程T存活时间很长比如线程池里的核心线程在T里你执行了这么一段代码public void handle() { ThreadLocalBigObject local new ThreadLocal(); local.set(new BigObject()); // 方法结束local这个局部变量失效了 }方法执行完局部变量local没有任何强引用了但线程T的ThreadLocalMap里Entry持有这个ThreadLocal的弱引用Entry的value字段强引用着那个BigObject。key由于是弱引用在gc时被回收key变成null。但value呢它的引用链是Thread对象 - ThreadLocalMap - Entry - value线程T还活着这条链就完全断开不了所以BigObject永远无法被GC回收。一个BigObject无所谓如果这是在循环里执行一万次且线程池里的线程永远不死那一万个无法回收的BigObject就累积在内存里了最终导致内存暴涨甚至OOM。这就是完整的内存泄漏链路。所以我说弱引用只是第一道防线它只能保证ThreadLocal对象本身能被回收不能保证ThreadLocal里填的value能被回收。4.2 ThreadLocalMap自带的补偿清理机制针对key为null的EntryThreadLocalMap在set、get、remove时是有一部分顺手清理的逻辑的。具体来说在调用set()向某个槽位插入时如果发现插入位置的Entry key为null会执行replaceStaleEntry把这个过期Entry清理掉并替换为新值。每次get()时如果命中的Entry正好是个过期Entry会触发expungeStaleEntry删除这个过期Entry并顺带清理后面一段连续区域的过期Entry。扩容前的rehash会调用expungeStaleEntries把整个table扫一遍清除所有过期Entry。这套机制能在一定程度上自愈但注意它有一个前提你必须再次操作这个ThreadLocal。如果你set完大对象之后长时间不读写这个ThreadLocal那这个过期的Entry就只能安安静静躺在内存里没人给它收拾残局。4.3 规范操作try-finally remove讲到这里结论就很清楚了。想从根本上规避ThreadLocal内存泄漏唯一可靠的办法就是业务代码自己负责清理。我建议的规范写法是private static final ThreadLocalString USER_CONTEXT new ThreadLocal(); public void processRequest(String userId) { try { USER_CONTEXT.set(userId); // 中间业务逻辑可能还会调用其他方法都会通过USER_CONTEXT.get()读取 doSomething(); doOtherThing(); } finally { USER_CONTEXT.remove(); } }把set和remove放在同一个try-finally块里保证业务逻辑无论正常返回还是抛异常都能清理ThreadLocal。这个习惯一旦养成了能帮你避开很多线上问题。4.4 关于弱引用的面试追问面试的时候面试官经常会再追问一句为什么ThreadLocalMap的Entry要继承WeakReference直接把ThreadLocal作为强引用不行吗你应该分两层回答。第一层如果Entry强引用ThreadLocal那么即使业务代码里ThreadLocal已经没有外部引用了线程的ThreadLocalMap仍然持有它ThreadLocal永远无法回收Entry永远存在。第二层采用弱引用之后ThreadLocal可以被GC回收Entry的key变成null后续的set/get/remove操作就有机会把这些过期Entry清理掉。虽然value的泄漏仍然存在需要靠业务侧remove来配合但至少设计上已经给了系统自清理的机会。这就是弱引用的意义。5. InheritableThreadLocal从父线程传到子线程5.1 默认情况下子线程拿不到父线程的ThreadLocal前面的代码里已经体现了子线程通过new Thread()创建它的ThreadLocalMap是全新的里面什么都没有。所以即使父线程里set了值子线程去get也是null。这在有些场景下很困扰。比如一个请求进来了主线程把traceId放进了ThreadLocal然后异步去new了一个子线程去执行耗时任务。子线程里也想输出同一个traceId方便日志串联。这个时候ThreadLocal就帮不上忙了你需要InheritableThreadLocal。5.2 InheritableThreadLocal的实现原理先看一个简单的用法private static final InheritableThreadLocalString TRACE_ID new InheritableThreadLocal(); public static void main(String[] args) { TRACE_ID.set(trace-001); Thread child new Thread(() - { // 子线程里能读到父线程设置的traceId System.out.println(子线程读取: TRACE_ID.get()); }); child.start(); }这个输出就是子线程读取: trace-001。原理是Thread类里除了threadLocals还有一个inheritableThreadLocals字段。在创建子线程时Thread的init方法会做一步操作如果父线程的inheritableThreadLocals不为空就把它整体复制一份到子线程的inheritableThreadLocals里。注意这个过程发生在子线程start()之前是线程创建这个时间点一次性完成的快照拷贝。也就是说如果在创建子线程之后父线程又修改了InheritableThreadLocal的值子线程里不会感知到变化。5.3 线程池场景下InheritableThreadLocal会失效InheritableThreadLocal看起来解决了父线程传子线程的问题但一碰到线程池就尴尬了。线程池里的worker线程是复用的不是每次任务新建线程。也就是说第一次往线程池提交任务时工人线程创建这时候手中的上下文中会复制到父线程的InheritableThreadLocal值。但第二次提交任务时还是同一个工人线程此时不会重新复制所以如果父线程此后的traceId变了worker线程里还是旧的traceId。更严重的是一旦工人线程已经被复用它的inheritableThreadLocals里存的是前一次任务的脏数据下一次任务去读可能读到其他任务的上下文。所以遇到线程池场景InheritableThreadLocal不够用。业界一般会引入阿里的TransmittableThreadLocal或者自己做一个基于ThreadPoolExecutor装饰器的上下文传递方案这些内容我计划在系列后续的篇目里专门讲。这里先记住结论线程池下传参不能用InheritableThreadLocal需要专门方案。6. 实战案例拆解三四行代码解决一把问题6.1 请求链路上下文信息传递最典型的实战场景是保存一次HTTP请求过程中的用户身份、traceId、租户ID等上下文信息。比如我习惯写一个简单的UserContext工具类public class UserContext { private static final ThreadLocalString USER_ID new ThreadLocal(); private static final ThreadLocalString TENANT_ID new ThreadLocal(); public static void set(String userId, String tenantId) { USER_ID.set(userId); TENANT_ID.set(tenantId); } public static String getUserId() { return USER_ID.get(); } public static String getTenantId() { return TENANT_ID.get(); } public static void clear() { USER_ID.remove(); TENANT_ID.remove(); } }在过滤器或拦截器里这样调用public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从请求头或token里解析出用户ID和租户ID String userId parseUserId(request); String tenantId parseTenantId(request); UserContext.set(userId, tenantId); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这样Controller和Service层就可以随时随地通过UserContext.getUserId()拿当前用户不需要在每个方法签名里都传userId参数。请求结束afterCompletion自动清理完全不用操心泄漏问题。6.2 SimpleDateFormat的线程安全改造这个例子我在2.2提过一次这里再补充一个常见的坑。很多同学会这样写private static final SimpleDateFormat SDF new SimpleDateFormat(yyyy-MM-dd HH:mm:ss);在高并发下这个格式化器是线程不安全的。SimpleDateFormat内部维护了一个Calendar对象多个线程共享同一个实例的时候一个线程正在parse另一个线程改了Calendar里的状态就会导致前一个线程解析出错误的结果有时候还会抛NumberFormatException。用ThreadLocal改造后private static final ThreadLocalSimpleDateFormat SDF ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss));每个线程持有自己独立的SimpleDateFormat实例互不干扰而且初始化成本只承担一次。这个方案比synchronized锁住parse方法要高效得多因为它把竞争完全消除了。6.3 日志链路traceId的传递很多团队用日志追踪请求链路会在入口生成一个traceId希望后续所有日志都能打上这个traceId。常见做法是基于日志框架的MDC机制。而在Logback/Log4j2里MDC的底层实际上就用了类似ThreadLocal的实现Log4j2里是ThreadLocalMapLogback里是基于ThreadLocal的。在拦截器里MDC.put(traceId, UUID.randomUUID().toString().replace(-, ));在日志pattern里配置%X{traceId}这条链路下的每一行日志都会自动带上traceId。请求结束后调MDC.remove(traceId)。这个用法可以说是ThreadLocal最容易上手、见效最快的场景。6.4 框架源码里的ThreadLocal影子看Spring源码的时候你会发现很多核心组件都在用ThreadLocal。最典型的是Spring事务管理器它需要把当前线程的数据库连接Connection绑定在ThreadLocal上保证一个事务内的多个DAO操作共用同一个连接。通过DataSourceUtils.getConnection()能拿到当前线程绑定的连接事务提交/回滚后释放连接并清理ThreadLocal。再看MyBatis的SqlSessionTemplate它也把SqlSession放到ThreadLocal里这样同一线程内的多次数据库操作使用同一个SqlSession到事务提交时统一关闭。这些底层设计都有一个共同点通过ThreadLocal把线程维度的资源或者状态绑定起来让调用链上的任何一层都能访问又不会跨线程串数据。7. ThreadLocal使用中的常见坑和排查经验7.1 常见问题速查表问题现象根因解决方案线程池里任务A的数据出现在任务B线程复用ThreadLocal没有在任务结束后清理每任务try-finally remove或参考TTL方案内存持续增长GC频繁堆占用越来越大ThreadLocal set了大对象后未remove线程池线程常驻严格remove并检查是否有循环中set的代码子线程拿不到父线程的ThreadLocalThreadLocal默认没有跨线程能力换成InheritableThreadLocal或自己传参同一个线程第二次取initialValue时还是初始化逻辑的返回值理解正确initialValue每次get前若没有set值就会触发初始化如需要保存修改先set再get使用FastThreadLocal时数据异常不同框架的ThreadLocal设计不同不能混用按框架对应的规则清理和传递7.2 线程池下最容易被坑的一次经历了我之前在一个线上系统里排查过一个内存问题现象是堆内存慢慢涨升级带宽到4G还是每天按时被顶满GC日志里老年代回收不掉。用jmap dump堆之后发现大量byte[]对象被某个线程池的worker线程引用顺着引用链一路找过去发现是ThreadLocalMap里的一个Entryvalue是一个缓存用的JSON字符串key已经为null了。再查代码原来是一个定时任务每次执行都往ThreadLocal里放一个很长的JSON字符串处理完直接返回完全没remove。因为定时任务跑在线程池里那几个worker线程一直活着于是每次执行堆里就多出一份残留JSON日积月累内存就爆了。修复就一行代码finally里remove。但这一行代码当时可是花了团队整整一个下午才定位到。这个案例我每次给团队培训都会讲。ThreadLocal用好了是神器用不好就是内存炸弹。关键在于谁set谁清理这条铁律。7.3 工具类设计时的命名规范与可见性最后分享一个工程讲究。ThreadLocal变量建议都封装在独立的上下文工具类里面用static final修饰方法提供set/get/clear三种能力。这样业务代码根本不需要直接碰ThreadLocal也不会出现线程池里忘清理、散落各处set的场景。对外只暴露语义清晰的方法内部实现细节隐藏起来后续要替换成TransmittableThreadLocal或者接入异步上下文方案改一个类就够了。我自己在项目里比较习惯用XxxContext命名比如UserContext、TraceContext、TenantContext每个Context只负责一组相关变量不要什么都往一个Context里塞。职责越单一越不容易漏清理也越容易排查问题。8. 从实战经验看ThreadLocal的正确使用姿势这一篇到现在ThreadLocal的核心用法、底层原理、内存泄漏机制、跨线程问题和应用场景基本都覆盖了。最后再写总结没什么必要我直接分享几个自己长期沉淀下来的使用建议。第一个建议入口出口成对出现。无论是拦截器也好、AOP也好、任务执行的包装方法也好set和remove一定要在同一层级的代码块里配对。这样即使后面有人往业务代码里加了新的return路径或者抛了新异常也不容易漏掉清理。第二个建议别在子线程、异步任务里隐式依赖父线程的ThreadLocal。绝大多数情况下异步任务的上下文应该通过显式参数传递或者使用成熟的TTL框架而不是寄希望于InheritableThreadLocal。后者在线程池场景下不但不好使还会造成脏数据污染。第三个建议如果不确定当前框架是否会自动清理ThreadLocal可以自己在关键节点打点检查。比如在RPC调用的返回过滤器里打印一下当前线程的ThreadLocalMap size看看任务处理后是增还是减。这种小工具在排查问题时特别管用。第四个建议ThreadLocal不适合存放连接、会话这类生命周期很长的重资源。就算有finally remove中断异常、守护线程退出等情况都可能让清理逻辑走过又没走完。真需要连接复用用连接池去管理而不是挂在线程上。这篇文章里我尽量把每个为什么都讲透了尤其是弱引用和内存泄漏这两个容易混的概念希望你现在能完整说出一条引用链出来。下一篇我打算写ThreadLocal在实际异步链路中的完整解决方案包括TTL的原理、线程池装饰器的写法、跨进程traceId的透传思路建议你把这一篇的基础打好再看那边不然很多设计会接不上。我在实际项目里踩过的和看到别人踩过的那些坑也都会在后续篇目里逐一展开。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →