ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ThreadLocal存储结构与内存泄漏深度解析:从源码到线上排查

ThreadLocal存储结构与内存泄漏深度解析:从源码到线上排查

1. 从一个线上告警说起:ThreadLocal 到底把数据存哪了

很多同学第一次接触ThreadLocal,都是被那句“线程本地变量,线程安全”带进来的。写个SimpleDateFormat工具类,或者存一下当前登录用户信息,用起来确实顺手。但真正让我开始认真研究它内部结构的,是几年前一次线上内存告警:一个后台服务跑了三天,堆内存缓慢上涨,Full GC 越来越频繁,最后 dump 下来一看,ThreadLocalMap$Entry占了将近 40% 的堆。当时排查了半天才定位到,是一个线程池里的任务往ThreadLocal塞了大对象,任务结束没清理,线程又被复用,数据就一直挂在线程上。

这件事之后我把ThreadLocal的源码从头到尾啃了一遍,也踩了不少坑。这篇就围绕ThreadLocal 存储结构和内存溢出问题两个核心点展开,把它的数据结构、哈希策略、引用关系、泄漏成因、排查手段和规避方案讲透。不管你是刚学 Java 并发的新手,还是已经用过几年但没深究过源码的老手,看完应该都能对“为什么会有内存泄漏”“怎么才能不泄漏”有个清晰的认识。

先说结论,方便你带着问题往下看:ThreadLocal本身不存数据,数据存在Thread对象里的ThreadLocalMap中,ThreadLocal只是作为 key。这个 Map 的 key 是弱引用,value 是强引用,一旦 key 被回收而线程还活着,value 就没人能访问到却又回收不掉,这就是泄漏的根源。理解了这个链条,后面所有的分析和方案都是顺理成章的。

2. ThreadLocal 存储结构深度拆解

2.1 数据到底存在谁身上:Thread、ThreadLocal、ThreadLocalMap 三者关系

很多人有个误解,以为ThreadLocal对象里存了值。其实不是。你调用threadLocal.set(value)的时候,值最终是挂在当前线程身上的。具体路径是这样的:每个Thread对象内部都有一个threadLocals字段,类型是ThreadLocal.ThreadLocalMap;这个 Map 里存的是一个个Entry,每个Entry的 key 是ThreadLocal对象本身(弱引用),value 才是你 set 进去的值(强引用)。

用生活化的类比:ThreadLocal像是一把钥匙,Thread像是一个储物柜,ThreadLocalMap是柜子里的格子间。你拿着钥匙去开柜子,把东西放进对应的格子里。钥匙本身不装东西,柜子才是真正存放的地方。所以同一个ThreadLocal对象,在不同线程里 set,值会分别存到各自线程的柜子里,互不干扰——这就是它线程安全的本质。

这个设计有个很关键的含义:数据的生命周期跟线程绑定,而不是跟 ThreadLocal 对象绑定。线程活着,数据就在;线程死了,数据跟着线程一起被回收。理解了这一点,后面线程池场景下的泄漏问题就很好解释了。

2.2 ThreadLocalMap 的内部结构:Entry 数组与开放地址法

ThreadLocalMap虽然名字里带 Map,但它并没有实现Map接口,是ThreadLocal的一个静态内部类,自己手写了一套简单的哈希表。核心结构就一个字段:

private Entry[] table;

Entry继承自WeakReference<ThreadLocal<?>>,也就是说 key 是弱引用:

static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }

注意这里:key 通过super(k)变成了弱引用,而 value 是普通的强引用字段。这个不对称的引用设计,正是内存泄漏问题的核心伏笔,后面会重点讲。

哈希冲突的处理方式,ThreadLocalMap没有用链表法(像HashMap那样拉链),而是用了开放地址法里的线性探测。具体来说,每个ThreadLocal对象有一个threadLocalHashCode,它是通过一个AtomicInteger每次累加一个魔数0x61c88647生成的。这个魔数不是随便选的,它是黄金分割数相关的常量,能让哈希值在 2 的幂次大小的数组里分布得比较均匀,减少冲突。

插入的时候,先算出索引i,如果table[i]为空就放进去;如果被占了,就往后找i+1、i+2……直到找到空位或者找到 key 相同的 Entry。查找也是类似的探测逻辑。这种设计在元素不多的时候效率很高,因为ThreadLocalMap通常一个线程也就存几个到十几个变量,不会太大。

2.3 为什么用开放地址法而不是链表法

这里有个值得琢磨的设计取舍。HashMap用链表(后来加了红黑树)解决冲突,为什么ThreadLocalMap要用开放地址法?

我的理解是两点。第一,元素数量少。一个线程里通常不会塞几十上百个ThreadLocal,开放地址法在低负载因子下性能很好,而且没有链表节点的额外对象开销,内存更紧凑。第二,清理方便。开放地址法下,遇到 key 为 null 的“陈旧 Entry”,可以直接做探测式清理(expungeStaleEntry),把后面的元素往前挪,顺手把 value 置空帮助回收。如果是链表法,清理逻辑会复杂不少。这个清理机制是ThreadLocalMap对抗内存泄漏的重要手段,后面会详细讲。

不过开放地址法也有代价:删除元素时不能简单置空,否则会截断探测链,导致后面的元素找不到。所以ThreadLocalMap的remove操作会做一次“重新哈希整理”,把冲突链上的元素重新安置。这个细节在源码里体现得很清楚,也是面试常问的点。

2.4 关键参数:初始容量、阈值与扩容

ThreadLocalMap的初始容量是 16,负载因子是 2/3(这点和HashMap的 0.75 不同)。也就是说,当元素个数达到threshold = len * 2 / 3时就会触发扩容,容量翻倍。

为什么负载因子是 2/3 而不是 0.75?因为开放地址法对负载因子更敏感。负载因子越高,探测链越长,性能下降越明显。2/3 是一个折中,既保证空间利用率,又不至于让探测太频繁。实际使用中,一个线程的ThreadLocal数量远达不到 16,所以扩容很少发生,这个参数更多是理论上的保障。

这里插一句实操经验:如果你发现某个线程的ThreadLocalMap频繁扩容,那大概率是设计有问题——要么是往一个线程里塞了太多ThreadLocal,要么是动态创建ThreadLocal对象(比如在循环里 new),后者是典型的反模式,会导致 Map 里堆积大量 key 不同但逻辑重复的 Entry。

3. 内存溢出问题的成因与链路分析

3.1 弱引用 key 与强引用 value 的不对称设计

现在进入核心问题。前面说了,Entry的 key 是弱引用,value 是强引用。这个设计带来的直接后果是:当 ThreadLocal 对象没有外部强引用时,key 会在下一次 GC 时被回收,变成 null,但 value 因为还被 Entry 强引用着,无法被回收。

画一下引用链就清楚了:

  • Thread对象 →ThreadLocalMap→Entry→value(强引用链,只要线程活着就存在)
  • ThreadLocal对象 ← 弱引用 ←Entry的 key(弱引用链,GC 时可能断开)

当外部不再持有ThreadLocal的强引用(比如方法执行完,局部变量threadLocal出栈),key 就只剩 Entry 里的弱引用了。下次 GC,key 被回收置 null,但 value 还在,Entry 还在,Map 还在,线程还在——value 就成了“幽灵数据”,既访问不到(key 没了,get 不到),又回收不掉(强引用还在)。

3.2 线程池场景:泄漏被放大的地方

如果线程用完就销毁,那即使有泄漏,线程一死,ThreadLocalMap跟着线程一起被回收,value 也就释放了,问题不大。真正要命的是线程池。

线程池里的核心线程是长期存活的,任务执行完线程不销毁,继续复用。这时候如果任务里 set 了ThreadLocal却没清理,value 就一直挂在这个线程的ThreadLocalMap里。下一个任务复用这个线程,如果它恰好用了同一个ThreadLocal对象(比如静态的ThreadLocal),还可能读到上一个任务的脏数据,造成数据串号;如果用的是不同的ThreadLocal,那旧 value 就一直占着内存,越积越多。

我见过最夸张的案例,是一个定时任务每次执行都往ThreadLocal塞一个几百 KB 的上下文对象,线程池 20 个线程,跑一天下来堆里堆了几万个陈旧 Entry,直接把老年代撑爆。所以记住一句话:在线程池场景下,ThreadLocal 用完必须 remove,这不是可选项,是必选项。

3.3 为什么源码的清理机制不够用

你可能会问,ThreadLocalMap不是有expungeStaleEntry清理机制吗?确实有,但它有触发条件,不是随时都在跑。

清理主要发生在三个时机:set时如果探测过程中遇到陈旧 Entry 会顺手清理;get时如果命中陈旧 Entry 会触发清理;remove时显式清理。问题在于,如果 set 完之后线程就再也不碰这个 Map 了(比如任务结束,线程回到池里待命),那清理逻辑根本没机会执行。陈旧 Entry 就一直躺着,直到下次有任务用这个线程的ThreadLocalMap才可能被顺带清理。

而且清理是“探测式”的,一次只清理探测路径上遇到的,不是全表扫描。如果陈旧 Entry 不在后续操作的探测路径上,可能很久都清不掉。所以不能依赖源码的自动清理来兜底,主动 remove 才是正道。

3.4 内存泄漏与内存溢出的区别

这里要澄清一个概念。严格说,ThreadLocal导致的是内存泄漏(Memory Leak),即对象该回收却回收不掉,内存被白白占用。泄漏积累到一定程度,才会引发内存溢出(OutOfMemoryError)。两者是因果关系,不是一回事。

泄漏的严重程度取决于三个因素:value 对象的大小、泄漏的 Entry 数量、线程存活时间。小对象、少量、短命线程,可能一辈子都撑不到溢出;大对象、大量、长命线程池,几天就能把堆吃满。所以分析这个问题时,要结合具体场景评估风险,而不是一棍子打死说“ThreadLocal 一定会 OOM”。

4. 实操排查与规避方案

4.1 用 jmap 和 MAT 定位泄漏现场

真遇到疑似泄漏,第一步是拿堆快照。命令很简单:

jmap -dump:live,format=b,file=heap.hprof <pid>

拿到 hprof 文件后,用 Eclipse MAT 或者 JProfiler 打开,重点看两个地方。一是Dominator Tree,找占用内存最大的对象,如果看到大量ThreadLocalMap$Entry,基本就锁定方向了。二是对某个Thread对象做Path to GC Roots,排除弱引用,看它的threadLocals里挂了哪些 Entry,value 是什么类型、多大。

有个小技巧:MAT 里可以直接搜ThreadLocalMap,然后看每个 Map 的 Entry 数量和 retained size。如果某个线程的 Map 里 Entry 数量远超正常值(比如几十上百个),或者单个 Entry 的 value 特别大,那就是问题点。再结合线程名(线程池一般会命名),就能定位到是哪个业务模块的线程出的问题。

4.2 代码层面的四种规避手段

排查是事后补救,更重要的是事前预防。我总结了四种手段,按推荐程度排序。

第一种,也是最重要的:用完必须 remove,放在 finally 里。这是铁律。不管中间会不会抛异常,finally 保证清理一定执行:

ThreadLocal<Context> holder = new ThreadLocal<>(); try { holder.set(new Context()); // 业务逻辑 } finally { holder.remove(); }

第二种:用 static final 修饰 ThreadLocal。这样 ThreadLocal 对象本身有强引用,key 不会被回收,至少不会产生 key 为 null 的陈旧 Entry。虽然 value 还是可能因为忘记 remove 而滞留,但至少语义清晰,不会出现“key 没了 value 还在”的诡异状态。注意,static 不能解决泄漏,只是让问题更可控。

第三种:包装成工具类,把 remove 藏在框架里。比如写一个ThreadLocalHolder,提供executeWithContext(ctx, runnable)这样的方法,内部自动 set 和 remove。业务代码不直接碰ThreadLocal,从源头减少忘记清理的可能。很多成熟框架(比如一些 Web 框架的请求上下文)就是这么做的。

第四种:能不用就不用。如果只是方法内传参,老老实实传参数就好,别为了省事用ThreadLocal。它适合的场景是“跨多层调用、又不想污染方法签名”的上下文传递,比如链路追踪 ID、用户身份。滥用是泄漏的温床。

4.3 线程池场景的额外注意事项

线程池 + ThreadLocal 的组合要格外小心。除了任务内 finally remove,还有几点经验。

一是避免在核心线程里长期持有大对象。如果确实需要缓存,考虑用有界缓存 + 过期策略,而不是无脑塞ThreadLocal。二是注意父子线程传递问题。ThreadLocal不会自动传给子线程,如果用了InheritableThreadLocal,在线程池场景下要小心——线程池的线程是复用的,InheritableThreadLocal的继承只发生在创建线程时,复用线程不会重新继承,容易拿到旧值。这种场景建议用阿里开源的TransmittableThreadLocal(TTL),它专门解决了线程池下的传递问题。

三是监控线程池的队列和活跃度。如果发现任务堆积、内存上涨,结合前面说的 dump 分析,能更快定位。我一般会在关键服务里加个定时任务,定期打印线程池状态和堆内存使用,出问题时第一时间有数据。

4.4 常见问题速查表

现象可能原因排查方向解决手段
堆内存缓慢上涨,Full GC 频繁ThreadLocal value 泄漏dump 看 ThreadLocalMap$Entry 占比任务内 finally remove
线程池任务读到脏数据上一个任务没清理 ThreadLocal检查任务是否 remove统一在 finally 清理
子线程拿不到父线程的值ThreadLocal 不继承确认是否用了 InheritableThreadLocal改用 TTL 或显式传参
key 为 null 的 Entry 大量堆积ThreadLocal 对象被回收MAT 看 Entry 的 referent用 static final 修饰
单个线程 Map 异常大动态创建大量 ThreadLocal检查是否有循环 new改为静态复用

5. 几个容易踩的认知误区

5.1 “用了弱引用就不会泄漏”——错

很多人看到 key 是弱引用,就以为 JVM 会自动帮忙回收,不会泄漏。这是最大的误区。弱引用只保证 key 能被回收,value 是强引用,照样泄漏。弱引用的作用其实是给清理机制一个信号:当 key 变成 null,说明这个 Entry 已经没用了,可以清理。但清理动作需要代码触发,不是自动的。所以弱引用是“减轻”而非“消除”泄漏。

5.2 “线程结束就没事了”——分场景

普通线程用完就销毁,确实问题不大。但线程池、ExecutorService、Web 容器的请求处理线程,都是长期存活的。判断风险时,先问自己:这个线程会活多久?如果答案是“跟应用同生命周期”,那必须严格清理。

5.3 “remove 会删除整个 Map”——错

remove只删除当前ThreadLocal对应的那个 Entry,不影响同一个线程里其他ThreadLocal的数据。所以不用担心清理会影响别的变量。它的实现是先找到 key 对应的位置,然后调用clear把 value 置 null,再做一次探测式清理把后面的元素整理好。

5.4 “ThreadLocal 是线程安全的,所以随便用”——半对

ThreadLocal保证的是变量在不同线程间隔离,从而避免共享带来的线程安全问题。但它本身不是“万能线程安全药”。如果你在ThreadLocal里存了一个可变对象,然后把这个对象传给别的线程,那照样有并发问题。隔离的是引用,不是对象本身。

6. 从源码看清理机制的具体实现

6.1 expungeStaleEntry 做了什么

这个方法名字直译是“清除陈旧条目”,是清理的核心。它的逻辑是:从指定的 staleSlot 开始,先把该位置的 Entry 的 value 置 null、table 置 null,然后从 staleSlot+1 开始往后遍历,直到遇到 null 为止。遍历过程中,如果遇到 key 为 null 的 Entry,同样清理;如果遇到 key 不为 null 的 Entry,重新计算它的理想索引,如果和当前位置不一致,就往前挪到合适的位置。

这个“往前挪”很关键,它保证了开放地址法下探测链的连续性。因为前面删了元素,后面的元素如果不挪,查找时可能提前遇到 null 就停了,导致找不到。所以expungeStaleEntry不只是清理,还顺带做了一次局部整理。

6.2 cleanSomeSlots 的启发式清理

set和get触发的清理用的是cleanSomeSlots,它做的是启发式扫描:从当前位置开始,每隔log2(n)个位置检查一次,遇到陈旧 Entry 就调用expungeStaleEntry清理,并返回 true 表示清理过。如果一轮下来没清理到,就返回 false。

这个设计是为了平衡清理开销和效果。全表扫描太慢,完全不扫又清理不及时,所以用对数间隔抽样。如果抽样发现确实有陈旧 Entry,说明泄漏比较严重,就会触发更彻底的清理。这个思路挺巧妙,值得学习。

6.3 为什么 set 时也可能触发扩容和清理

set的流程是:算索引,线性探测找位置。如果找到 key 相同的,直接替换 value;如果找到空位,插入新 Entry,同时size++,并调用cleanSomeSlots做一次启发式清理;如果清理后 size 还是超过阈值,就rehash,rehash里会做一次全表清理,清理完如果还是超过阈值的 3/4,才真正扩容。

注意这个顺序:先清理,再判断是否扩容。也就是说,如果清理能腾出足够空间,就不扩容了。这个设计避免了因为陈旧 Entry 占位而导致的无效扩容,挺聪明的。

7. 实际项目中的取舍与建议

7.1 什么时候该用 ThreadLocal

我的判断标准是:跨多层调用传递上下文,且不想污染方法签名。典型场景是链路追踪的 traceId、用户身份信息、数据库路由的库表标识。这些数据从入口进来,中间经过 Service、DAO 好几层,每层都加参数太啰嗦,用ThreadLocal传递最合适。

反过来,如果只是相邻两层传个参数,或者数据只在单个方法内用,那就别用。ThreadLocal是有成本的:内存占用、清理负担、理解成本。能简单就别复杂。

7.2 团队规范怎么定

如果团队里多人协作,建议定几条硬规矩。第一,所有ThreadLocal必须static final,禁止局部变量形式。第二,所有 set 必须配对 finally remove,代码评审重点检查。第三,禁止在ThreadLocal里存大对象(比如超过 1MB 的集合),需要缓存走专门的缓存组件。第四,线程池任务模板里统一加清理逻辑,业务代码不用操心。

这几条落地之后,基本能杜绝 90% 以上的泄漏问题。剩下的靠监控和 dump 分析兜底。

7.3 监控指标怎么加

生产环境建议加两个监控。一是堆内存的ThreadLocalMap$Entry对象数量和总大小,可以通过 JMX 或者定期 dump 采样。二是线程池的活跃线程数和队列深度,间接反映任务执行情况。如果发现 Entry 数量持续上涨不下降,基本就是泄漏了,赶紧排查。

我个人习惯在服务里加一个定时任务,每小时打印一次关键线程的ThreadLocalMap大小(通过反射拿),超过阈值就告警。虽然有点 hack,但确实能提前发现问题。

8. 最后聊几句个人体会

ThreadLocal这个类,代码量不大,但设计上的取舍很值得品味。弱引用 key 的设计、开放地址法的选择、启发式清理的策略,每一处都能看出作者在“性能”和“安全”之间的权衡。它不是一个完美的方案,泄漏问题至今存在,但通过合理的使用规范,完全可以规避。

我自己踩过的坑,大多不是不懂原理,而是“觉得不会出事”。任务里 set 了忘了 remove,想着线程池会复用应该没事;或者觉得 value 不大,泄漏一点无所谓。结果往往是量变引起质变,等告警来了才后悔。所以现在我的习惯是,只要写了 set,手就自动敲出 try-finally,形成肌肉记忆。

如果你正在排查类似的线上问题,建议先从 dump 入手,用数据说话,别凭感觉猜。定位到具体的线程和 Entry 之后,再回头看代码,往往一目了然。平时写代码时,把 remove 当成 set 的孪生兄弟,写一个就想另一个,能省掉后面很多麻烦。

RELATED READING

延伸阅读

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