ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ThreadLocal底层原理与线程池内存泄漏实战解析

ThreadLocal底层原理与线程池内存泄漏实战解析 搞Java并发编程的肯定绕不开ThreadLocal这个类。我最早接触它的时候还在用Struts2那时候它的ActionContext就是靠ThreadLocal做的请求上下文隔离后来自己写项目在拦截器里做用户登录态存储、在事务管理器里做Connection的线程绑定才算是真正被这个类教育过几次。说实话这玩意儿用起来只有三行代码但里面的门道够写一万字尤其是你已经踩过线程池配合使用时的内存泄漏坑之后再回头看源码才会明白为什么网上那么多人叫它“内存泄漏重灾区”。这篇文章我不打算讲那些官方文档里已经写烂的基础用法那没意思。我想从线程模型讲起一步步拆解ThreadLocal的底层实现、set和get的完整链路、ThreadLocalMap的哈希碰碰机制再延伸到线程池场景下的内存泄漏原理、父子线程传值、以及最终优化的TransmittableThreadLocal。把那些容易让人栽跟头的细节摊开聊顺便分享一套我在生产环境里排查和修正相关问题的实操经验帮你少踩几个坑。1. ThreadLocal到底解决了什么问题1.1 它要解决的是“线程内的全局变量”问题进程内的全局变量大家都懂多线程之间通过锁来互斥访问。但很多时候我们需要的变量并不是跨线程共享的而是每个线程一份独立的副本在同一个线程内部的多个方法之间传递。打个比方一个请求到达服务器从Controller到Service再到Dao这一条调用链整个链路都在同一个线程里执行。如果我想把当前登录用户的信息、请求ID、或者数据库连接传给下游每个方法最粗暴的方案就是把这些值作为参数一层层往下传代码会变得非常啰嗦。另一个方案是用普通的static静态字段但那是全局共享的高并发下会出现线程A的数据污染线程B的业务逻辑。这时候ThreadLocal就派上用场了它是线程内的全局变量对业务方法来说用起来像全局变量一样方便但底层却是每个线程各存一份互不干扰。1.2 一个简单的代码例子看清楚它的使用方式public class UserContext { private static final ThreadLocalUser USER_HOLDER new ThreadLocal(); public static void set(User user) { USER_HOLDER.set(user); } public static User get() { return USER_HOLDER.get(); } public static void remove() { USER_HOLDER.remove(); } }这段代码看起来平淡无奇但实际使用时要注意一个问题如果你在Web应用里把它当全局变量用却没有在请求结束时主动remove那么在高并发环境下可能出现脏数据。至于为什么会脏我们后面讲线程池时细聊。这里你先记住一个结论ThreadLocal不是银弹它是“线程隔离的全局变量”需要你为它的生命周期负责。1.3 常见的使用误区误以为它是为了解决并发安全问题很多初学者会把ThreadLocal理解为某种并发控制工具觉得它可以让多个线程安全地访问同一个变量。这个理解是偏的。它本质上做了变量在线程维度的“复制”或者说“副本隔离”真正被并发修改的其实是各个线程自己手里的副本所以当然不会冲突。但如果你的ThreadLocal里放的是一个共享对象例如同一个实例、同一个List那并发修改这个共享对象的内部状态时依然会出问题。举例来说private static final ThreadLocalListString LIST_HOLDER ThreadLocal.withInitial(ArrayList::new);如果你在多个线程里往同一个list里添加元素那还是线程不安全的。ThreadLocal只是隔离了引用本身并没有隔离这个引用指向的对象内容。所以正确的使用姿势是每个线程放入各自独有的对象而不是放入全局共享的同一个可变对象。这算是一个容易被忽视的基础认知但也是后面理解各种坑的前提。2. 源码层面的设计与底层实现拆解2.1 每个线程内部都内置了一个ThreadLocalMap说到原理最关键的一点是ThreadLocal本身并不存储值它是访问入口。真正存数据的是Thread类内部的一个成员变量public class Thread implements Runnable { // 每个线程都有的ThreadLocalMap ThreadLocal.ThreadLocalMap threadLocals null; // 用于InheritableThreadLocal父子线程传递 ThreadLocal.ThreadLocalMap inheritableThreadLocals null; }也就是说线程自己才是数据的真正持有者。每个Thread对象里都挂了一个ThreadLocalMap类型的哈希表ThreadLocal对象本身是作为这个Map的Key存在的而你要存的值就是Value。这样解释之后为什么一个ThreadLocal对象能被多个线程各自读取到不同值就非常清晰了并不是ThreadLocal里存了多份而是每个线程的自己Map里都有一条以这个ThreadLocal为Key的记录。你可以把ThreadLocal想象成一把钥匙每个线程用自己的ThreadLocalMap来保管钥匙对应的抽屉钥匙一样但每个线程对应的抽屉是独立的。2.2 ThreadLocalMap内部的存储结构我们来看一下ThreadLocalMap的核心字段static class ThreadLocalMap { // 初始容量必须是2的幂 private static final int INITIAL_CAPACITY 16; // 底层数组 private Entry[] table; // 存储的元素个数 private int size 0; // 扩容阈值 private int threshold; // Entry继承了WeakReferencekey是弱引用 static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); value v; } } }这个Entry是关键中的关键。它的Key是对ThreadLocal对象的弱引用但Value是强引用。具体来说当你执行threadLocal.set(value)时实际发生的事情是在当前线程的ThreadLocalMap里创建了一个Entry对象Entry的key指向ThreadLocal实例弱引用Entry的value指向你存入的业务数据强引用。为了让你彻底理解这个结构我画成大白话描述ThreadLocalMap就像是一个长度为16的数组每个位置是一个Entry桶位。当你要存值时先用ThreadLocal的哈希值算出它应该在数组的哪个下标然后把这个Entry放到那个位置上。如果多个ThreadLocal的哈希值落在同一个下标就线性往后探测找个空位放进去。这就是ThreadLocalMap与HashMap最大的区别——它没有任何链表或者红黑树结构纯粹靠开放地址法解决哈希冲突。2.3 set方法到底干了什么不管是使用ThreadLocal的set方法还是内部通过initialValue赋值最终都会走到ThreadLocalMap.set这个方法。我挑几个重点环节说一下public void set(T value) { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) { map.set(this, value); } else { createMap(t, value); } }第一步是拿到当前线程。这里有个细节很多人会忽略ThreadLocal的数据是与当前线程绑定的而不是与ThreadLocal对象本身绑定的。所以它的get/set结果取决于“你是从哪个线程发起调用的”这一点在异步场景下特别容易出问题比如你在子线程里get主线程设置的值得到的只会是null。private void set(ThreadLocal? key, Object value) { Entry[] tab table; int len tab.length; // 根据ThreadLocal的threadLocalHashCode计算下标 int i key.threadLocalHashCode (len - 1); // 从计算出的下标开始往后遍历寻找空位 for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { ThreadLocal? k e.get(); // 如果key相同直接覆盖旧值 if (k key) { e.value value; return; } // 如果key是null说明这个Entry的ThreadLocal已经被回收了需要替换掉这个脏Entry if (k null) { replaceStaleEntry(key, value, i); return; } } tab[i] new Entry(key, value); int sz size; // 清理完脏Entry之后再判断是否需要扩容 if (!cleanSomeSlots(i, sz) sz threshold) { rehash(); } }这里有两个核心设计点第一下标计算用与运算替代取模。key.threadLocalHashCode (len - 1)要求数组长度必须是2的幂这样位运算结果等同于取模但性能更高。这个写法在HashMap里也有但ThreadLocalMap在扩容时和调整桶位时都沿用了这种风格。第二探测式的哈希冲突处理。当你计算出来的下标位置已经被别的ThreadLocal占用时它不会像HashMap那样在桶位上挂链表而是直接往后找下一个空位。这个设计的代价是如果哈希碰撞比较多读写性能会退化因为每次都要线性扫描多个位置。好在ThreadLocal的使用量通常不大一般一个线程同时存的ThreadLocal数量不会超过几十个所以线性探测完全够用。2.4 get方法又是怎么走的链路public T get() { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) { ThreadLocalMap.Entry e map.getEntry(this); if (e ! null) { SuppressWarnings(unchecked) T result (T) e.value; return result; } } return setInitialValue(); }get的流程比set稍微简单先拿到当前线程的ThreadLocalMap根据当前的ThreadLocal对象去查找Entry如果能找到就返回Value如果找不到就调用setInitialValue()执行初始化并存进去。这就是为什么你用withInitial(Supplier)方式创建ThreadLocal对象时虽然首次get之前没有手动set但get出来就已经有了默认值的原因。getEntry的查找过程同样有探测逻辑private Entry getEntry(ThreadLocal? key) { int i key.threadLocalHashCode (table.length - 1); Entry e table[i]; // 找到并且key相同直接返回 if (e ! null e.get() key) { return e; } // 否则需要继续往后探测 return getEntryAfterMiss(key, i, e); }这里有个值得注意的边界条件如果get的时候遇到Entry的key为null也就是ThreadLocal被回收了它并不会直接跳过而是会调用expungeStaleEntry把它清理掉再继续找。这个机制是ThreadLocal对抗内存泄漏的重要防线。2.5 ThreadLocal的哈希值是怎么生成的源码里定义了这样一个成员变量private final int threadLocalHashCode nextHashCode(); private static AtomicInteger nextHashCode new AtomicInteger(); private static final int HASH_INCREMENT 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }这个0x61c88647是黄金分割数的魔数。它的来历要牵扯到斐波那契哈希算法简单来说这个魔数能保证生成的哈希值序列在长度为2的幂的数组里分布非常均匀从而有效减少哈希碰撞。这个设计思想与ThreadLocalMap使用线性探测法密切相关——既然碰撞处理成本高那就从源头尽量降低碰撞概率。所以每次new一个ThreadLocal对象它的threadLocalHashCode都在前一个的基础上加上那个魔数这样多个ThreadLocal放进同一个线程的Map里时下标会比较分散不会挤在一起。3. 内存泄漏问题从弱引用到强引用链3.1 网上争论的核心疑点关于ThreadLocal内存泄漏网上有很多说法其中一些明显是错误的。最常见的错误说法是“因为ThreadLocal的Key是弱引用所以ThreadLocal会被GC回收导致Key变成null然后Value就拿不到了这就是内存泄漏。”这个说法有一定道理但不准确。准确的理解应该是这样假设你在一个方法里new了一个ThreadLocal对象并set了一个很大的对象进去。然后方法执行完毕栈帧销毁这个ThreadLocal对象理论上变成了不可达对象GC应该回收它。但由于当前线程的ThreadLocalMap里有一条Entry引用着它——虽然是弱引用——弱引用并不会阻止GC回收所以ThreadLocal确实会被回收。回收之后这条Entry就变成了keynull的脏条目但Entry的value仍然强引用着那个大对象。问题的关键在于这条脏Entry什么时候被清理。如果当前线程还活着尤其是在线程池场景下线程不会销毁而且你之后再也没有往这个ThreadLocal里set/get过值这条脏Entry就不会被主动清理那么Value对象就会一直被强引用无法被GC回收。时间一长累积的脏Entry越来越多就形成了内存泄漏。所以你可以这样总结弱引用Key的设计其实是为了方便回收ThreadLocal对象本身但Value并不会自动回收。它不是泄在Key上而是泄在Value上。3.2 ThreadLocal是怎么做一个“带策略的清理”的既然存在脏EntryThreadLocal就内置了一套清理机制来处理它。主要动作是三个expungeStaleEntry(int staleSlot)把指定下标的脏Entry清理掉顺便从下一个位置继续探测把后续因哈希冲突后移的Entry重新规整位置如果遇到其他脏Entry也一起清理。cleanSomeSlots(int i, int n)启发式扫描从指定位置出发对数次探测过程中遇到的脏Entry进行清理。replaceStaleEntry在set过程中发现脏Entry时把新值直接放到这个位置同时向前后方向扫描清理其他脏Entry。这些清理策略的设计初衷是在常规的get/set操作中顺带做一些清理避免每次都必须全量扫描数组。但这里有一个很现实的问题如果某个ThreadLocal自set之后再也没有被访问过那么这些清理代码根本没机会执行脏Entry就会一直留在内存里。3.3 生产环境里真正的内存泄漏场景线程池这是我个人踩过最深的坑也是后来排查了一整天才定位到的。先说一下现场情况线上服务在压测跑了一段时间之后堆内存曲线一路飙升用jmap dump完堆之后用MAT分析发现大量的com.example.pojo.OrderInfo对象被ThreadLocal$ThreadLocalMap$Entry引用。当时第一反应是有人把大对象存进了ThreadLocal然后没有及时remove。顺着引用链找下去发现这批对象的祖父引用是ThreadPoolExecutor$Worker再往上就是线程池的核心线程对象。为什么核心线程会一直持有这些Entry因为核心线程池里的核心线程理论上不会被回收它们长驻内存。只要这些线程的ThreadLocalMap里留着脏EntryValue对象就永远不会被释放。而我们在业务代码里为了传递一些上下文信息在任务开始时往ThreadLocal里塞了订单数据任务结束时没有删下一批任务来了又覆盖新值。表面上看每次都是覆盖似乎没关系但如果任务是分批处理的某些批次的订单对象又大就会有大量历史Entry的Value残留。解决思路也简单try { // 业务处理 } finally { userContext.remove(); }对就是这句话finally里执行remove()。说起来轻巧但踩过坑之后才明白为什么那么多规范里面反复强调这件事。线程复用场景下不主动remove就是给GC挖坑。3.4 remove方法的机制public void remove() { ThreadLocalMap m getMap(Thread.currentThread()); if (m ! null) { m.remove(this); } }ThreadLocalMap的remove方法同样要处理哈希冲突它不只是把对应下标置为null而是需要将被移除Entry后面的那些冲突后移的Entry重新排列确保后续查找逻辑的正确性private void remove(ThreadLocal? key) { 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)]) { if (e.get() key) { // 清除弱引用 e.clear(); // 重新排列后面的Entry expungeStaleEntry(i); return; } } }注意这里有个细节remove之后Entry对象的引用关系被切断但Entry本身还留在数组里只不过它被标记成了stale脏状态后续的expunge操作会把它彻底清掉。也就是说remove并不保证内存立即被释放但至少它断开了引用链让GC在下一轮垃圾回收时可以回收Value对象。4. 线程池场景下的“上下文传递”与InheritableThreadLocal4.1 主线程与子线程之间的数据隔离ThreadLocal的语义是“仅对当前线程可见”。如果我们在主线程里set了一个值然后new了一个线程去执行任务子线程里get这个ThreadLocal得到的是null。原因是子线程在创建时默认情况下它的threadLocals是空的并没有继承主线程的任何Entry。public class InheritableThreadLocalDemo { private static final ThreadLocalString TL new ThreadLocal(); public static void main(String[] args) throws Exception { TL.set(main-thread-value); Thread subThread new Thread(() - { System.out.println(子线程获取值: TL.get()); // 输出null }); subThread.start(); subThread.join(); } }运行结果预期就是子线程输出null。这个行为在多线程环境下是合理的因为父子线程的任务边界本来就不应该共享上下文一旦共享了就可能出现子线程修改了主线程的值导致主线程后续逻辑错乱的问题。4.2 InheritableThreadLocal的实现原理但有些场景就是需要父子线程传值。比如主线程生成了一个TraceId希望在子线程里能继续用或者主线程登录了用户希望通过异步任务里传递用户信息。JDK提供了一个InheritableThreadLocalpublic class InheritableThreadLocalT extends ThreadLocalT { Override protected T childValue(T parentValue) { return parentValue; } Override ThreadLocalMap getMap(Thread t) { return t.inheritableThreadLocals; } Override void createMap(Thread t, T firstValue) { t.inheritableThreadLocals new ThreadLocalMap(this, firstValue); } }它的作用机制是在Thread类的构造方法里有一段逻辑如果当前创建子线程的主线程有inheritableThreadLocals那么就把主线程的这些Entry浅拷贝一份放到子线程的inheritableThreadLocals里。这样子线程在首次调用get时就能从自己的inheritableThreadLocals里拿到父线程塞进InheritableThreadLocal的值。这段逻辑在Thread.init方法里if (inheritThreadLocals parent.inheritableThreadLocals ! null) { this.inheritableThreadLocals ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); }createInheritedMap会遍历父线程的inheritableThreadLocals对每个Entry调用childValue方法生成子线程的值。所以我们可以通过重写InheritableThreadLocal的childValue方法来实现父值到子值的转换逻辑。4.3 它不能用于线程池InheritableThreadLocal虽然能解决一次性创建线程的传值问题但遇到线程池就废了。线程池的核心线程是复用的不会每次任务都新建线程。也就是说第一次提交任务时线程池创建了一条核心线程这条线程在创建那一刻继承了当时主线程的inheritableThreadLocals值。但后续你再往同一个线程池提交新任务时这条核心线程并不会因为主线程的inheritableThreadLocals发生了变化而更新自己的inheritableThreadLocals。经典的场景是你在接口里给某个InheritableThreadLocal塞了一个traceId希望通过线程池异步执行但第二次请求时主线程更新了traceId然而复用线程里拿到的仍然是第一次请求的traceId。这是非常隐蔽的串号问题很难排查。阿里开源的TransmittableThreadLocal弥补了这个缺陷。它的核心思想是在线程池执行任务之前快照当前线程主线程的TTL值然后把这个快照传递给任务包装器在任务真正执行时替换到工作线程的ThreadLocalMap中任务执行完毕再恢复工作线程原本的上下文。这样就能解决线程池场景下上下文传递的准确性问题。如果你在做全链路追踪、或者使用线程池异步处理业务推荐直接用它替代原生的InheritableThreadLocal。5. 实际项目中我踩过的坑和排查心得5.1 第一个坑忘记remove导致的内存泄漏这个问题前面已经展开过原理我想再补一个实操级别的经验。使用ThreadLocal的最佳实践是所有set和get必须处于同一个结构化代码块内并且用try-finally包裹。不管业务逻辑多么复杂我建议在代码风格上强制约束public void doSomething() { UserContext.set(user); try { // 业务逻辑 } finally { UserContext.remove(); } }如果你用的是Spring MVC还可以通过拦截器的方式统一处理public class ContextCleanupInterceptor extends HandlerInterceptorAdapter { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 这里在请求开始时由框架自动初始化上下文 return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.remove(); } }关键不在于用什么框架而在于你得有一套自动化的清理机制而不是靠每个开发者手动记住。只要有一次漏掉就可能造成内存泄漏。5.2 第二个坑把大对象直接放进ThreadLocal有一段时间我在缓存用户权限信息时直接把一个包含权限树的大对象放进了ThreadLocal。用户量一上来每个请求都持有一个大型权限树对象虽然请求结束后线程回到Tomcat线程池ThreadLocal里的值还在。下次请求来了虽然会覆盖但旧值已经占用的内存直到覆盖前一直悬着峰值压力非常大。后面我改成了只放userId权限信息按需查询并且放在堆外缓存里ThreadLocal只做轻量的“线程内唯一标识”不放重的业务数据。这是一个很重要的设计原则ThreadLocal里的Value应该尽量小最好是基本类型、短小的不可变对象、或者一个标识符。如果你非要在里面放大的集合一定要保证用完立即remove。5.3 第三个坑getMap的误解还记得标题里的热搜词threadlocal getmap吗源码里确实有这个方法的ThreadLocalMap getMap(Thread t) { return t.threadLocals; }它的作用就是从指定线程对象里取出threadLocals字段。这个方法在ThreadLocal类内部是一个包私有方法我们业务代码一般用不到。但理解它是理解整个ThreadLocal工作原理的钥匙ThreadLocal拿到当前线程的ThreadLocalMap然后在这个Map上进行增删改查ThreadLocal对象本身只相当于一个查找用的Key。如果你去看源码会发现还有一段双层的类加载器类初始化逻辑某些情况下会导致ThreadLocal的类初始化顺序问题。不过实际上大多数出现问题的场景都是因为ThreadLocal使用不正确导致的业务异常而不是JDK自身的bug。排查时最重要的还是顺着引用链找根因。5.4 排查工具与技巧如果你发现线上有疑似ThreadLocal内存泄漏我推荐这样一条排查路径确认现象观察堆内存占用是否不断上升而不是在GC后回落到健康水位。抓取堆快照用jmap -dump:formatb,fileheap.hprof pid把堆dump下来或者用jcmd GC.heap_dump命令。分析引用链用MAT的Leak Suspects或者Dominator Tree筛选出占用最大的对象看它的引用链里是否有ThreadLocalMap$Entry或者Thread对象。定位代码位置从引用链里的类名找到对应的业务代码检查哪儿set进去却忘了remove。还有一个土办法在代码里临时加日志把每次ThreadLocal的set和remove都打出来比对数量级。如果set次数远大于remove次数基本可以断定就是忘记清理了。6. 深度扩展TransmittableThreadLocal与全链路追踪6.1 为什么原生ThreadLocal撑不住全链路追踪在微服务或者分布式系统里全链路追踪Tracing有个核心需求一个TraceId必须贯穿整个请求的完整生命周期包括异步调用、线程池执行、消息发送等所有环节。原生ThreadLocal做不到跨线程传递InheritableThreadLocal只能沿“父子线程创建”这个方向传播一次面对线程池复用就无能为力。所以我个人在写基础框架的时候直接使用了阿里开源的TransmittableThreadLocal简称TTL。这个组件在GitHub上维护得挺好用法几乎和ThreadLocal一样TransmittableThreadLocalString context new TransmittableThreadLocal();不同之处在于当你用线程池时需要把Runnable包装一下ExecutorService executor TtlExecutors.getTtlExecutorService(threadPoolExecutor); executor.submit(() - { // 依然能拿到主线程设置的TTL值 String traceId context.get(); });TTL的底层原理其实是在提交任务的时候抓取当前线程的TTL快照然后包装成装饰器Runnable在任务真正开始执行时把快照设置到工作线程的ThreadLocal里等任务跑完再把工作线程原来的值恢复回来。这就保证了任务执行期间看到的上下文是提交那一刻主线程的上下文既不串号也不污染工作线程的长期状态。6.2 使用TTL时的一个小细节我能确定的一点是TTL的性能开销很小但它只在提交任务那一瞬间做了快照如果任务执行过程中主线程又改了TTL的值任务内部看不到这次更新。这是符合预期的行为因为快照语义本来就是“提交时定格”。如果你需要在任务内部跟随主线程的实时变化那就得换别的方案了但一般业务场景用不到那么极端的能力。另外如果项目中使用了Spring的Async注解配合TTL时建议用TtlRunnable.get(runnable)包装一下异步任务或者直接用TtlExecutors包装线程池Bean否则不一定生效。具体要看你的框架版本但思路是一致的异步边界必须显式处理上下文不能指望框架自动感知TTL。7. 一套我自己沉淀下来的ThreadLocal使用规范写了这么多原理和坑最后我还是想分享一套在团队内推行的使用规范。这些规范未必适用所有项目但对绝大多数主流Java后端工程是有参考价值的存储内容最小化ThreadLocal里只放轻量标识不要放大对象、大集合。职责单一一个ThreadLocal只服务于一个明确的语义不要一个Map塞一堆字段。统一入口对外暴露一个上下文工具类set/get/remove都在工具类里封装业务代码不要直接调用ThreadLocal的API。强制清理要么用拦截器要么用AOP要么在finally里清理禁止裸奔set后不管。团队Code Review把“是否在请求结束后remove”列为一个重点检查项。涉及线程池优先考虑TransmittableThreadLocal不要依赖InheritableThreadLocal。我个人在实际开发里最大的体会是ThreadLocal的本质是一个“使用简单但约束苛刻”的工具。它很简单简单到你不需要理解Map就能用起来它的约束也很苛刻苛刻到你在线程池复用、异步任务、多类加载器等场景下稍有不慎就可能行为异常。但只要弄明白了它底层那套线程内Map、弱引用Entry、开放地址寻址这些设计你再看那些坑就会觉得一切都有迹可循——每个坑背后都是底层机制的一种自然结果。希望这篇文章能帮你把ThreadLocal的来龙去脉理顺等哪天生产环境出问题的时候你能比我当年更快地定位到根因。
RELATED READING

延伸阅读

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