ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

ThreadLocal源码级拆解:线程隔离、内存泄漏与线程池实战规范

ThreadLocal源码级拆解:线程隔离、内存泄漏与线程池实战规范 ThreadLocal这个东西我一开始接触的时候觉得它不过就是个线程隔离的变量盒子直到线上出现了用户数据串号的故障我才把这玩意儿从源码到实战彻底翻了个底朝天。排查那次故障时我点开线程栈去看ThreadLocal.getMap到底返回了什么才发现问题远比想象中隐蔽。如果你在做Java后端开发尤其是写完接口要处理并发、线程池、链路追踪这些场景ThreadLocal绝对值得你花一天时间把它吃透。这篇文章我会把getMap背后的设计、内存泄漏的坑、线程池下的实战规范全部拆开讲全部来自我真实排查和重构代码时积累的经验。1. ThreadLocal到底是什么从一次线上问题说起1.1 共享变量为什么出问题先说那次线上事故。我们有一个报表服务每个用户请求进来之后拦截器会往ThreadLocal里塞当前用户的信息方便后续Service层随时取用。上线一周后客服反馈用户A登录后看到了用户B的订单数据。这种情况属于典型的用户数据串号是并发场景下共享变量引发的隐性故障。裸用static变量肯定出事因为所有线程共享同一份内存。局部变量又没法在调用链里往下传总不能每个方法都加一个参数带上userId。那时候我们用的是ThreadLocal.set(userId)看起来隔离了但为什么还是串号呢因为服务器用线程池处理请求线程在处理完用户A请求后不会销毁而是被放回池里复用。如果下一次用户B的请求被分配到同一个线程而ThreadLocal里的userId没有清理B拿到的是A的残留数据。这个案例引出了ThreadLocal的两面性它确实能做线程隔离但如果不理解它的生命周期反而会制造地狱级Bug。1.2 ThreadLocal的解决思路与生活类比ThreadLocal的名字有点误导直译是“线程本地”但它本身不是线程它是存放在线程对象上的一个盒子。每个线程内部都有一个独立的Map叫ThreadLocalMapThreadLocal对象只是这个Map的key。当你调用threadLocal.set(value)实际动作是把value放到当前线程的Map里调用threadLocal.get()就是从当前线程的Map里取出对应key的value。我习惯用一个类比公司有一排储物柜每个柜子对应一个员工。员工往柜子里放私人物品别人看不到也拿不到。ThreadLocal就是那把刻着编号的钥匙每个员工线程用自己的钥匙打开自己的柜子存取东西。你绝不会因为拿错了钥匙而打开别人的柜子因为柜子本身就锁在员工的工位号上。这个设计解决的核心痛点只有一个让同一份数据在同一个线程的整个执行链路里可以被随时访问同时不被其他线程污染。注意“同一个线程”这句话它是后续所有原理和坑的基石。1.3 核心API与基础用法ThreadLocal的API非常少就四个常用方法set(T value)把值写入当前线程的ThreadLocalMap中。get()从当前线程的ThreadLocalMap读取值如果key不存在或已过期会返回初始值。remove()把当前线程ThreadLocalMap中对应的key删掉。withInitial(Supplier? extends S supplier)创建时指定初始值用lambda比重写initialValue()更简洁。// 最简单的用法 public class UserContext { private static final ThreadLocalLong CURRENT_USER_ID new ThreadLocal(); public static void setUserId(Long userId) { CURRENT_USER_ID.set(userId); } public static Long getUserId() { return CURRENT_USER_ID.get(); } public static void clear() { CURRENT_USER_ID.remove(); } }注意set和get是实例方法但所有线程共享同一个ThreadLocal实例。同一个实例在不同线程里会映射到各自的ThreadLocalMap的独立条目所以天然隔离。新手容易误以为new ThreadLocal()就自动隔离了实际上隔离的是“访问行为”不是“对象本身”。2. 源码级拆解getMap方法背后的秘密2.1 ThreadLocalMap的数据结构很多看源码的人一开始都会卡在ThreadLocalMap上因为它不是一个普通的HashMap而是一个自定义的静态内部类。它内部维护了一个Entry数组Entry的key就是ThreadLocal对象value就是线程需要的值。关键代码在Thread类里// Thread类内部 ThreadLocal.ThreadLocalMap threadLocals null;每个Thread对象都持有这样一个Map字段。而ThreadLocal.getMap(Thread t)就是把这个字段取出来// ThreadLocal类内部 ThreadLocalMap getMap(Thread t) { return t.threadLocals; }这个方法就一行但它是理解ThreadLocal的钥匙。因为set、get、remove所有操作的第一步都是先拿到getMap(currentThread)如果Map为空就创建并初始化。所以当你使用ThreadLocal时实际上是在操作当前线程对象里的一个Map字段而不是在操作ThreadLocal本身。ThreadLocalMap内部为什么不直接复用HashMap因为它要做一件事探测式清理过期Entry并且key需要专门用一个继承WeakReference的内部类来包装。这两个机制是专门为内存生命周期设计的后面展开讲。2.2 get() 与 set() 的完整链路看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(); }流程很清楚拿到当前线程。通过getMap(t)获取当前线程的ThreadLocalMap。如果Map不为空用当前ThreadLocal实例作为key调用getEntry(this)查Entry。如果Entry存在直接返回value。如果Map为空或Entry不存在调用setInitialValue()设置初始值。getEntry(this)不是简单的数组按下标取值它先根据ThreadLocal对象的哈希值定位到数组下标如果该下标上的Entry是当前ThreadLocal直接返回否则会从该下标继续向后遍历寻找相同的key遇到Entry的key为null表示已过期时会调用expungeStaleEntry做清理。这是get操作会触发清理的原因之一。set方法同样先拿到getMap(t)public void set(T value) { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) { map.set(this, value); } else { createMap(t, value); } }如果当前线程从没用过ThreadLocalgetMap返回null就会走createMap。createMap其实就是new ThreadLocalMap(this, firstValue)这个构造器会初始化一个默认大小16的Entry数组并设置阈值。注意一点set方法找到相同key时只覆盖value如果key相同但value是null也会正常覆盖不会触发清理。只有key已经是null的过期Entry才在set时被探测清理。2.3 为什么ThreadLocal的key是弱引用Entry的声明长这样static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); value v; } }key被包装在WeakReference里ThreadLocal对象的强引用链断掉后key就不会阻止GC回收。这是设计上最重要的决策。我打个比方ThreadLocalMap的Entry钥匙是弱引用锁柜子里的物品是强引用。如果ThreadLocal这个钥匙在外面没人拿着了Java随手会把钥匙收走但柜子里的物品还在这就产生了“钥匙空位但值占位”的过期Entry。如果不清理这个物品就一直占着线程里的内存。弱引用本身不是目的真正的目的是配合清理机制避免更严重的泄漏。但弱引用也让过期Entry的识别变得容易key为null的Entry就是已回收钥匙的残留物。如果没有弱引用而用强引用哪怕业务代码里已经把ThreadLocal对象设成null了Map里还牢牢握着key线程不被回收那它就一直在清理机制也无从下手。2.4 初始容量与阈值计算ThreadLocalMap的初始容量是16阈值是容量乘2除以3。这个0.66的负载因子比HashMap的0.75小是因为ThreadLocalMap的probe探测是线性寻址越早扩容越能减少冲突和过期Entry堆积。在set过程中当数组的size超过阈值时会执行rehash()。rehash会先做一轮全量清理把所有过期Entry删掉如果删完之后size仍然超过阈值减阈值/4就扩容。扩容时容量翻倍并对所有Entry重新计算下标。这里下标不是普通的hash取模而是根据ThreadLocal的threadLocalHashCode位运算得到的private final int threadLocalHashCode nextHashCode(); private static AtomicInteger nextHashCode new AtomicInteger(); private static final int HASH_INCREMENT 0x61c88647;0x61c88647是斐波那契散列的黄金分割数它能让ThreadLocal哈希码在数组长度取模后的分布比较均匀避免rehash和探测链过长。这个细节知道即可面试会加分但实战中不需要重写。3. 内存泄漏与ThreadLocalMap的探针清理机制3.1 内存泄漏场景重现很多人一提ThreadLocal就说“会内存泄漏”这话只说对了一半。严格说ThreadLocal本身不会必然泄漏关键在于“Thread对象长命而ThreadLocal对象短命”的组合。出一个最经典的场景你写了一个静态的ThreadLocal变量在线程里set了一个大对象比如几十MB的List但用完后既没有remove也没法让ThreadLocal对象被回收因为静态变量一直持有着它的强引用。线程回收到线程池后这个线程继续生存于是它Map里的过期Entry就牢牢拽着哪个大对象。线程池里几百个线程每个都残留几十MBOOM分分钟的事。更隐蔽的是Tomcat这类Servlet容器它们默认也会用线程池处理请求而且会根据并发量调整线程数量。线程不消亡ThreadLocalMap里的残留值就一直占用堆内存。3.2 expungeStaleEntry探测清理JDK在设计时已经考虑了这种风险所以提供了“探测式清理”的兜底。核心方法就是expungeStaleEntry(int staleSlot)。它做三件事把staleSlot位置上的Entry的value置为null并把这个位置清空。从staleSlot的下一个位置开始遍历数组直到遇到null槽位。在遍历过程中如果遇到Entry的key为null同样释放value并清空位置这叫连续清理如果Entry的key不为null会重新计算它的哈希下标如果下标和当前槽位不一致就挪到更近的、更合适的空位这本质是一次就地rehash。这个清理动作不是全表扫描整个Map而是从过期位置开始向后探测到第一个空槽为止。这样在元素不多时清理成本可控。get和set方法里遇到过期Entry时都会触发这个清理。还有一处叫cleanSomeSlots在每次插入或者替换value时对数级别的遍历一些槽位来扫描过期Entry。这些机制合起来就是尽量降低过期Entry对内存的长期占用。但它们都不是交易性的替代品只是“尽力而为”的清理。3.3 线程池复用场景下的致命坑线程池复用时线程本体存活期可能覆盖几十上百个业务请求。假如业务代码里每个请求都set了一个新对象但不remove下一次请求复用这条线程时如果ThreadLocal实例是同一个静态变量set会覆盖旧value不会累积但如果你每次动态new ThreadLocal()只在一个请求里使用那这个ThreadLocal对象没有外部强引用key变成nullvalue就残留了。即使不new新ThreadLocal如果value是那种重型的、带有连接资源或缓存内容的引用旧的value被新value覆盖前依然活着直到下一次set触发覆盖和清理路径覆盖本身其实没问题问题在于生命周期被无谓拉长。我见过一个最典型的错误写法是在RPC框架里用ThreadLocal存储请求上下文代码长这样RequestContext context new RequestContext(); context.setTraceId(traceId); CONTEXT_THREAD_LOCAL.set(context); // 调服务 Object result rpcService.call(); // 忘记remove这个RPC线程如果来自公共线程池traceId和整个请求上下文就会泄漏给下一个被复用同一个线程的请求。轻则日志串traceId重则权限上下文套用用户数据串号。线程池场景下必须在finally里remove。正确模板try { contextHolder.set(userContext); doBiz(); } finally { contextHolder.remove(); }永远不要把清理动作放在try之外也不要放在if分支里。finally是唯一可靠的位置。4. 实战业务系统中的ThreadLocal使用规范4.1 上下文信息透传userId、traceId最常见的场景就是登录态和链路追踪。我在项目里通常封装两个独立的上下文工具类一个存用户身份一个存链路日志信息。用户身份上下文的实现要格外谨慎因为它涉及权限。我不建议直接把整个用户对象塞进去更建议只塞userId或userIdtenantId。对象塞进去虽然方便但也会带来两个问题一是对象体积大残留内存更明显二是如果对象里还挂着一堆懒加载的数据库代理对象线程池场景下可能在读的时候抛异常。链路追踪的traceId也推荐用ThreadLocal因为一个请求在同一个线程内的所有日志关键字段都可以通过MDC切面自动注入。Spring Boot下用logback的MDC时底层就是ThreadLocalMap。如果不用MDC而是自己写traceId透传同样要小心线程池场景下的隔离问题。4.2 线程池场景下必须remove吗遇到“必须”两个字我的回答是并不是所有情况下都必须要remove只要你的ThreadLocal的key是全局静态的并且value可以被覆盖不remove在大多数业务场景不会产生串号。比如只存一个当前线程生成的随机批次号下一个请求进来set覆盖了旧值旧值在覆盖时被回收从功能层面看不出问题。但这样写等于把正确性押注在了“下一次一定会有set”这个假设上。如果代码里有些分支根本不set,直接get那就会读到上一个请求留下的值。这也是很多莫名其妙的偶发Bug的来源。所以我在团队里的编码规范强制要求凡是在业务入口往ThreadLocal set了内容必须在当前处理流程结束时在finally里remove。不要依赖覆盖不要依赖清理机制。理由很简单remove的代价几乎为零而不remove的风险是串号和泄漏。4.3 InheritableThreadLocal和TransmittableThreadLocal的选型父线程想把自己的ThreadLocal传给子线程最简单的是InheritableThreadLocal。它在线程创建时用父线程Map里的值初始化子线程的Map。这在一个请求里创建少量子线程、且子线程不会复用时会比较有用。但InheritableThreadLocal有两个缺陷第一它只能在线程创建的那一瞬间拷贝一次线程池里的线程早就创建好了不会动态获取父线程最新值第二拷贝是浅拷贝如果value是可变对象子线程改了会影响父线程反之亦然。真正应对异步和线程池场景的是阿里开源的TransmittableThreadLocalTTL。它给线程池的每个任务包装了一层装饰器任务提交时捕获当前线程ThreadLocal快照任务执行前把快照放到执行任务的线程里任务结束再恢复。我做过一次选型对比结论是纯串行执行直接用ThreadLocal。简单创建一次性子线程InheritableThreadLocal够用。线程池异步执行、需要透传traceId或用户上下文必须用TTL包装线程池。TTL的使用很简单比如TtlExecutors.getTtlExecutorService(executorService)或者用TtlRunnable.get(runnable)。这是目前解决线程池透传最成熟的方案没有之一。4.4 参数配置与性能影响ThreadLocal的读写性能非常高因为它是纯内存数组操作没有锁。在单线程持续访问场景下要比ConcurrentHashMap快得多。但有些人误以为ThreadLocal支持跨线程共享这是不准确的。对性能影响最大的其实不是读写本身而是清理和扩容。如果一个线程里使用的ThreadLocal数量极多比如超过16个Map就会反复触发rehash和cleanSomeSlots每次插入都可能做额外的探测扫描。不过一般的业务场景里线程的ThreadLocal数量都在个位数完全不用焦虑。值得注意的倒是JVM参数和线程数量。如果你的线程池线程数很大又有大量大对象放在ThreadLocal里不清理老年代会快速膨胀。排查时可以堆dump看吧看到大量ThreadLocalMap$Entry带着大对象指向业务类八成就是没remove了。5. 常见问题与排查技巧实录5.1 为什么ThreadLocal.get()返回nullget返回null常见有四种原因第一次调用get之前没有调用过set并且初始值也没有设置。当前线程不是之前set的那个线程。比如你用异步线程执行任务子线程里get不到父线程set的值。ThreadLocal对象不是同一个实例。比如每次方法里都new一个ThreadLocal后续自然读不到。已经执行了remove后续又去get。其中第三点最隐蔽。尤其在一些工具类里写成了静态方法但方法内部用new ThreadLocal()来存取那每次调用都是全新的key完全无法产生隔离效果。5.2 链路追踪中ThreadLocal不生效我们遇到过的一个问题在主线程设置的traceId子线程打印时丢失。那时候根本没用InheritableThreadLocal用的是普通ThreadLocal所以子线程的Map是空的自然get不到。后来用异步HttpClient或者MQ生产者时也有类似问题。MQ生产者发送消息时消息体里需要携带traceId但如果traceId放在ThreadLocal且发送回调再另一个线程回调里get就是null。正确做法是在发送前的入口线程把traceId取出来显式塞到消息头不要等到发送回调再去ThreadLocal里找。排查这类问题可以在关键入口和出口打点打印线程名和Thread.currentThread().getId()立刻能看出是不是线程发生了切换。5.3 如何用arthas检查ThreadLocalMap内容线上排查ThreadLocal残留我喜欢用arthas。用thread命令列出所有线程找到目标线程ID后通过getstatic或者ognl表达式访问Thread实例的threadLocals字段。示例thread -n 1 # 找到目标线程的id之后 ognl -x 3 #tjava.lang.ThreadgetAllStackTraces().keySet()[0], #t.getThreadLocals()不过我实际中更常用的思路是直接heap dump后用MAT或jhat搜索ThreadLocalMap$Entry看它的value是否有业务对象残留。arthas适合快速确认某个线程里有没有值但要把整个池子的残留清点一遍还是dump更直观。5.4 热点排查速查表症状可能原因快速解法线程池复用时出现上一个用户的数据ThreadLocal未remove在finally里remove日志traceId乱串线程池线程复用时traceId未清理用TTL或每次任务执行前set老年代涨得快内存泄漏ThreadLocal里放了大对象Dumpheap确认并remove异步子线程get不到值线程切换用TransmittableThreadLocal或显式传参两个类各自读取的ThreadLocal相互覆盖使用了同名静态变量或非静态实例确保ThreadLocal是final static且序号唯一最后再分享一个小细节我后来把所有ThreadLocal变量都封装成独立的Context类提供静态方法对上层只暴露set/get/clear。初期多花一点建模时间能避免后面排查时满世界找不到ThreadLocal的坑。每次清完用户上下文记得在clear里顺便把可能嵌套的其他ThreadLocal一并remove防住那种一个请求里同时用了多个上下文工具类却只清理一个的场景。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表