JDK17下ThreadLocal:线程池复用脏值、内存泄漏与异步透传实践
去年年中我排查过一个很有意思的线上问题大促期间日志里同一个请求ID一会儿能在日志里出现一会儿又被另一个请求的ID覆盖把几拨工程师都看懵了。一开始怀疑是日志框架的Bug后来才发现源头在一个大家都认识的类——ThreadLocal。更具体地说是线程池复用了线程而我们没有在任务结束时清掉ThreadLocal里的脏值。这个问题在JDK 17项目里尤其有代表性。很多团队从JDK 8升到17之后业务代码、线程模型、异步框架全混在一起ThreadLocal既承担着参数隐式传递的重任又是线程池复用时最容易埋雷的地方。这篇文章不打算只讲API怎么用而是把ThreadLocal在JDK 17环境下的原理、场景、泄漏、异步透传这几个维度完整过一遍顺便把我排查过程中踩过的坑和验证过的方案写清楚。无论你是刚接触ThreadLocal的新人还是正在升级JDK版本的老兵这篇都值得花十分钟认真看。1. ThreadLocal 到底在解决什么问题共享变量困境与线程副本1.1 一个让日志串线的 TraceId先说那个事故。我们当时用的是自研的链路追踪组件网关会生成一个traceId通过HTTP头往下游传服务内部则通过一个静态的TraceIdHolder来暂存当前请求的链路ID下游打印日志时统一从Holder里取。大促压测时发现同一个服务里一次请求打出的日志traceId会跳来跳去前半段还是A-123后半段突然变成B-456再往下又跳回A-123。排除了日志框架的异步Appender问题后我们把怀疑点集中在TraceIdHolder上——它的底层实现就是ThreadLocal。查了一圈发现业务代码在一个ExecutorService里提交了异步任务异步任务内部打印日志时没有重新设置traceId而是直接读取Holder里的旧值。线程池的核心线程在执行第一个任务时把请求A的traceId写进了ThreadLocal任务结束后没清。第二个任务恰好被同一个线程执行拿到的自然还是请求A的残留值于是日志里出现了串线。这个场景非常典型ThreadLocal本身没有错错的是在线程池环境下我们没有处理好值的生命周期。理解这一点就理解了ThreadLocal一半的使用精髓。1.2 线程封闭把共享变成每人一份ThreadLocal到底是什么简单说它提供了一种线程封闭机制——让每个线程拥有自己的变量副本线程之间互不干扰。它的核心价值不是传参而是隔离。你可以把ThreadLocal理解成每个人桌上都有的一张草稿纸谁写的内容只有自己能看别人碰不到也看不到。而synchronized或ReentrantLock更像是会议室里的共享白板大家一起用但要排队写写完还要擦干净不然下一个人看到上一批内容的残留。传统解决线程安全问题的思路是让多个线程安全地访问同一个对象比如加锁、用原子类、用并发容器。ThreadLocal提供的是另一条路干脆不共享。既然多个线程并发访问会出问题那就让每个线程都持有一份独立的副本修改只影响副本不波及他人。对于线程隔离这个需求来说它比加锁更轻量因为根本没有竞争也不需要排队。这里有个概念需要区分清楚ThreadLocal不是用来解决多线程同时修改同一个对象的问题而是用来解决每个线程需要独立持有某个值的问题。比如每个请求的traceId、每个用户会话的登录态、每个线程自己的数据库连接这类数据天然属于单个执行流不该和别的线程混在一起。1.3 基本 API 与最小可运行示例ThreadLocal的API非常少核心就四个set、get、remove以及初始化方法initialValue或withInitial。我用一个最简示例说明public class ThreadLocalDemo { private static final ThreadLocalString REQUEST_ID new ThreadLocal(); public static void main(String[] args) throws InterruptedException { Thread threadA new Thread(() - { REQUEST_ID.set(request-A); System.out.println(Thread.currentThread().getName() - REQUEST_ID.get()); // 注意这里没有 remove }, 线程A); Thread threadB new Thread(() - { System.out.println(Thread.currentThread().getName() - REQUEST_ID.get()); }, 线程B); threadA.start(); threadA.join(); threadB.start(); threadB.join(); } }输出结果是线程A - request-A 线程B - null线程A设置了值线程B去读取时得到null说明两个线程的值是相互隔离的。这个例子足够说明ThreadLocal的最基础行为值存在当前线程自己的空间里其他线程拿不到。withInitial是Java 8以后推荐用的初始化方式特别适合每个线程都需要的默认对象private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss));SimpleDateFormat不是线程安全的因为它的内部Calendar状态会在parse和format过程中被打乱。用ThreadLocal包装后每个线程持有自己的SimpleDateFormat实例既避免了加锁的性能损耗又保证了线程安全。这是ThreadLocal非常经典的使用场景。关于remove线程用完值之后一定要主动清理这点我会在第五章重点讲。现在先记住一个结论——remove不是可选项而是必须项尤其在线程池环境里。2. JDK 17 里的 ThreadLocal 结构ThreadLocalMap 与黄金分割哈希2.1 值存在线程自己身上不在 ThreadLocal 对象里很多初学者有一个误区以为ThreadLocal对象本身存储了值。真实的存储结构恰恰相反值存在Thread对象自己身上。看JDK 17的Thread源码里面有两个关键字段ThreadLocal.ThreadLocalMap threadLocals null; ThreadLocal.ThreadLocalMap inheritableThreadLocals null;第一个threadLocals存的是当前线程的所有ThreadLocal值第二个inheritableThreadLocals存的是可继承的ThreadLocal值用于子线程创建时继承父线程的值。每个Thread对象内部维护着一个ThreadLocalMap这个Map以ThreadLocal实例为key以你set进去的对象为value。严格说ThreadLocal本身只是钥匙——它不存数据它负责指引你到当前线程的Map里找到自己的那条记录。调用set(value)时本质上是在当前线程的Map里写入(this - value)调用get()时是到Map里取this对应的value。这就是为什么不同线程之间的ThreadLocal值互不影响因为它们的Map实例根本不是一个对象。ThreadLocalMap并不是普通的HashMap它没有链表也没有红黑树用的是开放地址法。所谓开放地址法就是当计算出的数组下标位置已经被占用时不是挂一条链表而是顺着数组往后找下一个空位直到找到空槽或者找到key相同的Entry。这种结构对哈希函数的随机性要求很高否则一旦发生大量碰撞查询效率会退化成线性扫描。2.2 为什么哈希增量是 0x61c88647如果你读过ThreadLocal源码一定会注意到一个魔法数字private static final int HASH_INCREMENT 0x61c88647;每个ThreadLocal对象在创建时都会通过一个全局的AtomicInteger加上这个增量来生成自己的threadLocalHashCode。为什么偏偏是0x61c88647这个数字是有数学背景的。0x61c88647对应的十进制是1640531527约等于2^32 * (√5 - 1) / 2也就是黄金分割比例约0.618相关的常数。使用黄金分割数作为哈希增量得到的哈希值在2的幂次长度的数组中分布得极其均匀——后面的哈希码几乎不可能和前一个哈希码落在相邻的位置从而能有效减少开放地址法中的碰撞。再配合上ThreadLocalMap的扩容策略默认初始容量16负载因子是2/3当size超过threshold容量的2/3时触发扩容容量翻倍。扩容时会重新计算所有Entry的位置并顺带清理掉一批key为null的过期Entry。这套机制从JDK 1.2引入ThreadLocal以来一直延续到JDK 17稳定性经过了二十多年的验证。顺带说一句不要试图自己实现一套更高效的ThreadLocal存储结构。JDK这套黄金分割哈希开放地址法的组合在真实负载下表现非常好我见过有团队为了优化而去直接反射操作ThreadLocalMap最后把线程池搞得一团糟。没必要也不值得。2.3 JDK 8 到 JDK 17核心机制没变升级注意别处很多人在升级JDK时有一个疑问JDK 17的ThreadLocal是不是和JDK 8不一样了查了JDK 17源码之后可以明确回答核心机制几乎没变仍然是ThreadLocalMap 弱引用key 黄金分割哈希这套组合。JDK 17作为LTS版本在底层基础设施上保持了高度的稳定性ThreadLocal没有经历重构级的变动。那升级到底要关注什么真正值得注意的是JDK 9以后的模块化强封装。从JDK 17开始--illegal-access选项默认不再允许反射访问JDK内部API很多老项目里通过反射调用sun.misc.Unsafe或直接访问ThreadLocalMap私有字段的黑科技代码在JDK 17下会直接抛InaccessibleObjectException。如果你在升级时遇到这类问题正确做法是把代码改成正规的ThreadLocal用法而不是去调整JVM启动参数绕过强封装。另一个升级相关的问题是从JDK 8直接跳到17项目里引用的中间件、ORM框架、异步框架可能还在用老一套线程模型。如果这些框架内部对ThreadLocal的使用不规范升级后同样会暴露出串值、丢值的问题。升级JDK是在放大既有代码的问题而不是取代它。ThreadLocal本身不背这个锅但ThreadLocal的规范使用在前置阶段就要抓好。3. 从一次日志错乱排查说起线程池传参的真实场景3.1 三个最典型的应用场景ThreadLocal在实际项目中最常被用在下面三个地方场景典型对象说明链路追踪请求ID、traceId、spanId一次请求内多次方法调用、异步分支都要能拿到同一个ID方便日志聚合用户上下文登录用户ID、用户角色、租户ID请求进入后把用户信息写入上下文业务方法直接取省去层层传参线程安全工具SimpleDateFormat、Random每个线程持有独立实例既安全又不需要加锁链路追踪场景最典型。一次请求从网关进来经过Controller、Service、DAO再打到下游服务中间任意一个环节打印的日志如果不带traceId排查问题时会疯掉。有了ThreadLocal在请求入口统一设置traceId请求结束统一清理所有日志都能自动带上这条链路ID。还有一个我最近在JDK 17项目里多次看到的场景多租户系统。每个请求进来时网关层会根据token解析出租户ID写入ThreadLocal。后面所有数据库查询、缓存Key拼接、甚至动态数据源路由都要依赖这个租户ID。如果不做线程隔离一个租户A的请求读到租户B的ThreadLocal值轻则数据串租户重则产生越权查询这是非常严重的事故。3.2 线程池复用时 ThreadLocal 的串味线程池和ThreadLocal的组合是生产环境事故率最高的搭配之一。问题根源在于线程池中的核心线程会长期存活并被多个任务复用。如果任务A在ThreadLocal里设置了值却没有清理下一个被同一线程执行的任务B就会读到任务A的残留值。我用一段最小代码复现这个现象import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadPoolDirtyReadDemo { private static final ThreadLocalString HOLDER new ThreadLocal(); public static void main(String[] args) throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(1); pool.execute(() - { HOLDER.set(请求A); System.out.println(任务A设置值: 请求A); }); Thread.sleep(100); pool.execute(() - { System.out.println(任务B读取值: HOLDER.get()); }); pool.shutdown(); } }输出结果任务A设置值: 请求A 任务B读取值: 请求A任务B的代码里根本没有set任何值却读到了请求A。这就是线程池复用时最经典的串味问题。线程B的读写操作本身没有BugBug在于线程的ThreadLocal状态没有随任务结束而重置。这个现象带来的危害是隐性的。短日志串线还能排查如果ThreadLocal里存的是用户ID、租户ID在异步任务中被错误复用轻则业务数据张冠李戴重则产生数据泄露。所以凡是线程池异步执行的场景ThreadLocal的清理工作必须和任务的finally块绑定而不是指望下一次任务来擦屁股。3.3 规范姿势finally 里 remove而不是 set 时清理正确用法其实不复杂核心就一条哪里set哪里负责清而且要保证在异常路径上也执行清理。所以必须放在finally里try { HOLDER.set(traceId); // 业务逻辑 doSomething(); } finally { HOLDER.remove(); }为什么不是set完就清理或者放在try块末尾清理因为业务代码可能会抛异常一旦抛异常后面的清理代码就不会执行ThreadLocal里的值就会残留在线程上。放在finally里无论正常结束还是异常退出线程状态都能被还原干净。有人可能会觉得每次都在finally里remove太啰嗦能不能在set的时候先清一下旧值没用的。线程池的执行顺序是任务A set值任务A结束不清理任务B set新值任务B结束不清理任务B的旧值被任务B自己set的新值覆盖了看起来没问题。可如果任务C根本不set值只是被动读取它就会读到任务B的残留值。所以set时覆盖并不能解决只读任务拿到脏值的问题。唯一的根治办法就是每个执行链路结束前都把自己的ThreadLocal状态真正清理干净。阿里Java开发手册里特意强调过这一点必须回收自定义的ThreadLocal变量尤其在线程池场景下每次请求结束都要调用remove。我用过那么多规矩这条是在生产环境真正救过命的。4. 异步线程共享 ThreadLocal 的三种方案与代价4.1 新线程能拿到 InheritableThreadLocal但线程池不行上一章讲的是线程池复用时残留值串线这一章聊的是另一个高频问题主线程设置了ThreadLocal值子线程里怎么拿到很多人的第一反应是用InheritableThreadLocal。private static final InheritableThreadLocalString INHERITABLE new InheritableThreadLocal(); public static void main(String[] args) { INHERITABLE.set(父线程值); new Thread(() - System.out.println(普通子线程读到: INHERITABLE.get())) .start(); ExecutorService pool Executors.newFixedThreadPool(1); pool.execute(() - System.out.println(线程池第一次读到: INHERITABLE.get())); pool.execute(() - System.out.println(线程池第二次读到: INHERITABLE.get())); pool.shutdown(); }这段代码的输出很可能让人困惑普通子线程读到: 父线程值 线程池第一次读到: 父线程值 线程池第二次读到: 父线程值普通new Thread能拿到父线程的值这个好理解——创建子线程时JVM会把父线程的inheritableThreadLocals复制一份给子线程。但线程池的表现就隐蔽了第一次执行时线程刚好创建所以继承了父线程当时的旧值第二次执行时线程复用了它继承的还是创建那一刻父线程的值不会自动更新。这就是InheritableThreadLocal在线程池场景下的致命缺陷它只在创建子线程时复制一次线程池里的线程一旦创建完成之后父线程再怎么set线程池里的值都不会跟着变。如果你在父线程里每来一个请求就set一个traceId期望线程池里的线程能拿到最新值InheritableThreadLocal根本做不到。4.2 包装 Runnable最简单可控的透传方案既然线程池无法通过继承机制实时获取父线程的值那就换一种思路在提交任务时捕获当前线程的值在任务执行前恢复值在任务执行后清理值。这就是包装Runnable方案。public class ThreadLocalPropagateRunnable implements Runnable { private final Runnable delegate; private final MapString, Object captured; public ThreadLocalPropagateRunnable(Runnable delegate) { this.delegate delegate; this.captured capture(ThreadLocalHolder.all()); } Override public void run() { ThreadLocalHolder.set(captured); try { delegate.run(); } finally { ThreadLocalHolder.clear(); } } private MapString, Object capture(MapString, ThreadLocal? sources) { MapString, Object snapshot new HashMap(); sources.forEach((key, tl) - snapshot.put(key, tl.get())); return snapshot; } }这个方案的核心思想是快照传递提交任务的那一刻把主线程的ThreadLocal值拍一张快照任务执行前恢复快照执行后清掉恢复线程池线程的初始状态。这么做的好处是完全可控不需要依赖InheritableThreadLocal的继承时机也不会污染线程池中的线程。代价是所有的任务提交入口都要用ThreadLocalPropagateRunnable包装一遍很容易漏。如果项目里异步任务很多或者大部分是通过框架的Async注解、CompletableFuture提交的手工包装的侵入性就太大了。这也是为什么业界需要更完善的方案。4.3 企业级方案TransmittableThreadLocal阿里开源的transmittable-thread-local简称TTL是目前解决线程池传值问题最成熟的方案。它在包装Runnable的基础上实现了捕获capture—重放replay—清理clear的完整生命周期管理还支持线程池在线提交时自动传递不需要手工包装每个任务。dependency groupIdcom.alibaba/groupId artifactIdtransmittable-thread-local/artifactId version2.14.3/version /dependency使用方式也很简单TransmittableThreadLocalString context new TransmittableThreadLocal(); context.set(主线程信息); ExecutorService executorService TtlExecutors.getTtlExecutorService(executor); executorService.execute(() - System.out.println(context.get()));TtlExecutors.getTtlExecutorService会把普通线程池包装成带值传递能力的线程池提交的任务会自动捕获提交线程的TTL快照执行前恢复快照执行后清理全程对业务代码透明。这套方案在京东、阿里等大厂的框架中都被大量使用生产成熟度很高。不过要提醒一句TTL解决的是ThreadLocal值在线程池中的传递它不解决值应该是什么的问题。使用TTL包装线程池时仍然要在业务链路入口设置TTL的值并在链路结束时清理上下文中的请求级数据。TTL只是帮你把正确传递的路径打通了值的生命周期管理依然要靠自己。4.4 方案对比与选型建议方案实现原理支持线程池复用侵入性生产成熟度普通 ThreadLocal线程隔离不支持会产生残留无高InheritableThreadLocal创建子线程时复制线程复用后失效低中包装 Runnable提交时快照执行时恢复支持中易漏包中TransmittableThreadLocalcapture/replay/clear支持完整生命周期低通过包装线程池很高如果项目里只是偶尔有异步任务传参用包装Runnable就够了。如果是大流量、多线程池、中间件层还是线程池套线程池的复杂结构直接用TTL省心得多。5. 内存泄漏的真相弱引用设计的意图与 ThreadLocal 规范操作5.1 弱引用到底弱在哪里ThreadLocal最著名的坑是内存泄漏几乎每个讲ThreadLocal的文章都会提。但很多人只是记住了要remove这个结论不明白背后的原理遇到真正的问题时依然不知道怎么排查。ThreadLocalMap的每个Entry长这样static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); value v; } }这里有两个关键点Entry的key是一个WeakReferenceThreadLocal而value是强引用。WeakReference意味着如果外部没有强引用指向ThreadLocal对象GC时这个ThreadLocal对象会被回收。如果一个ThreadLocal对象被回收了它对应的Entry就变成了key为null的过期条目但value依然被Entry强引用着无法被GC回收这就形成了泄漏。打个比方你租了一个储物柜钥匙ThreadLocal丢了理论上锁就没用了但柜子里的东西value还一直放在那里占着空间。如果不定期清理柜子越堆越多最终整个储物间被垃圾塞满。很多人会问既然ThreadLocal的key是弱引用为什么还要手动remove因为弱引用解决的只是ThreadLocal对象本身可以被回收但value的生命周期和key并不同步直到你调用get()、set()或remove()时ThreadLocalMap才有机会清理那些key为null的过期Entry。如果某个线程一直不结束一直不调用这些方法value就会一直停留在Map里。5.2 set/get 帮你清理了一半但线程池场景会放大残留ThreadLocalMap确实内置了清理机制在执行set、get操作时会尝试清理那些key为null的过期Entry这个方法叫做expungeStaleEntry。但这套清理机制是被动触发的依赖于后续有get、set、remove操作来触达相关位置。当线程是普通new Thread执行完就结束整个Map随线程对象一起被GC回收不清理也不会长期泄漏。真正危险的是线程池线程池里的线程长期存活Map一直存在。如果业务代码只set不remove那条key为null的Entry就会一直留在数组里value无法被回收。我见过一个典型的泄漏场景private static final ThreadLocalbyte[] BIG_DATA new ThreadLocal(); pool.execute(() - { BIG_DATA.set(new byte[100 * 1024 * 1024]); // 100MB大对象 // 业务处理... // 忘记 remove线程池线程继续存活 });一个固定大小20的线程池每个线程泄漏100MB一波流量下来就是2GB的堆内存被无谓占用。用jmap查看堆的时候你会发现这些byte数组都被ThreadLocalMap里的Entry引用着显而易见却又很难立刻定位到是哪段代码set进去的。因为这个问题太常见投行里有些团队甚至会给线程池单独封装一层任务执行前后由框架统一清理ThreadLocal上下文。我觉得这是个好习惯但根本解法还是回到使用规范上——手写finally块remove永远不要觉得多余。5.3 实战规范清单结合我自己的实战经验列一份ThreadLocal使用规范清单用static final修饰ThreadLocal变量防止误创建多个实例导致值存了找不到。static final让所有线程拿到的key是同一个对象这也是官方推荐用法。请求级的数据用完必须remove放到finally里执行保证异常路径也能清理。不要在ThreadLocal里放大对象比如大的byte[]、复杂对象图。ThreadLocal本身是做线程隔离的不是做缓存的把对象放进去只会增加GC压力。不要滥用ThreadLocal传业务参数尤其是那些本该通过方法签名传入的值。隐式传参会严重降低代码可读性也让排查问题变得困难。了解你所用框架对ThreadLocal的处理策略比如Netty的FastThreadLocal、Spring的RequestContextHolder、Logback的MDC它们底层都有一套自己的清理时机使用时别破坏框架的生命周期约定。JDK 17下不要尝试用反射清理ThreadLocalMap强封装机制不支持这种黑科技老老实实写规范的remove。6. JDK 17 虚拟线程临近ThreadLocal 的定位与使用建议6.1 JDK 17 是 LTS也是升级 Java 21 的前一站JDK 17是2021年9月发布的LTS版本也是目前很多企业从JDK 8直接跳升的目标。相比JDK 8JDK 17除了集成了过去几个版本的语言改进比如switch表达式、文本块、record、密封类更关键的是默认启用了强封装整体运行时行为更规范。ThreadLocal在JDK 17中依然是最关键的线程上下文方案没有任何官方替代品被引入。如果你在业务代码里需要做线程级别的数据隔离ThreadLocal仍然是首选而不是那些听起来更高级的自定义上下文方案。自研一套上下文往往意味着自己管理生命周期写不好就是在生产环境埋雷。有一个点值得留意JDK 17项目里线程池的使用规模通常比JDK 8时代更大因为很多框架如Spring Boot 3默认就基于JDK 17构建异步化、响应式编程的实践更加普遍。线程池用得越多ThreadLocal的生命周期管理就越重要。前面几章讲的规范在JDK 17项目里不是最好遵守而是必须遵守。6.2 虚拟线程来了之后ThreadLocal 会有什么问题从JDK 19开始虚拟线程作为预览特性登场JDK 21正式发布。JDK 17还不是虚拟线程的正式版本但很多团队已经在讨论升级到JDK 21的事。虚拟线程的核心理念是让线程变得极其轻量一个进程可以轻松创建几十万个虚拟线程彻底改变线程池大小的编程模型。虚拟线程普及之后ThreadLocal会面临一个新的问题内存放大。虚拟线程的创建成本极低但如果每个虚拟线程都持有自己的ThreadLocal值几万个虚拟线程就对应几万个ThreadLocalMap即使单个Map不大总内存开销也相当可观。Java社区给出的替代方案是ScopedValue它允许在进入某段代码时绑定一个值离开时自动解除生命周期显式且内存占用更低。ScopedValue目前还在标准化的路上JDK 17阶段并不适用。所以在JDK 17这个时间点我的建议是继续用ThreadLocal但养成好的使用习惯。把ThreadLocal的使用尽量收敛到独立封装的类中不要散落在业务代码里四处set、get。这样将来如果项目升级到JDK 21、需要迁移到ScopedValue你只改封装类的内部实现业务代码不用动。6.3 给升级建议保持用完即删的习惯未来迁移更平滑从JDK 17升级到更高版本时ThreadLocal相关代码大概率不会编译报错但运行时的语义差异是需要关注的。虚拟线程场景下线程数量可能远大于平台线程的池化规模ThreadLocalMap的数量也随之膨胀这是当前ThreadLocal设计的隐藏成本。更贴近当下的建议是如果你正在JDK 17项目里使用ThreadLocal尽早定一个团队规范每个使用ThreadLocal的地方都要有对应的清理逻辑所有异步传递必须有明确方案ThreadLocal变量必须是static final不要在一个线程里开无数个ThreadLocal。这个规范我现在就在团队里执行实际效果比写一堆代码审查规则有用得多。另外一个现实经验升级JDK时不要只盯着编译期错误还要关注运行期行为变化。很多ThreadLocal的问题不是代码在JDK 17下跑不了而是在JDK 8下没暴露的错误在更规范的JDK 17下暴露了。提前把使用规范定好升级过程会顺利得多。最后再说几句实操体会排查了那么多次ThreadLocal相关的问题我自己的体会是ThreadLocal是一个用好了很爽、用错了很惨的工具。它解决的是线程隔离和隐式传参问题但它的价值建立在每个使用点都清楚值什么时候进来、什么时候离开这个前提上。你最需要养成的习惯就是创建一个ThreadLocal之前先问自己这个值的生命周期到底跟谁走跟着一次请求走就在请求结束的finally里remove跟着线程池的任务走就确保任务执行的finally里clear跟着业务链路的传递走优先考虑TTL而不是裸用InheritableThreadLocal。最后再分享一个排查小技巧当你怀疑ThreadLocal串值或泄漏时别急着翻代码先用jstack抓一下线程栈看看到底是哪个线程池的哪个线程在跑什么任务再用jmap -histo:live看大对象实例数重点排查类型里是否有大量的value对象。这两板斧下去绝大多数ThreadLocal问题都能锁定到具体线程和代码路径。工具本身无罪用对了地方它就是Java并发体系里最顺手的那件兵器。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →