☰
ThreadLocal源码级解析:ThreadLocalMap设计、内存泄漏与线程池实践
2026/10/15 16:15:06 网站建设 项目流程

1. 先从宏观设计看ThreadLocal的存储骨架

先说结论:ThreadLocal本身不存储数据,它更像一张“门牌号”,真正存放数据的是每个线程自己手里那张名为ThreadLocalMap的表。这个设计很多人一开始没转过弯来,总以为new一个ThreadLocal,数据就存在这个对象里,其实不是。数据存在当前线程对象的某个字段里,ThreadLocal对象只是作为这个字段里的一个key。

1.1 线程本体长什么样

打开Thread类的源码,你会发现线程对象内部其实挂了两个与ThreadLocal强相关的字段:

ThreadLocal.ThreadLocalMap threadLocals = null; ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

默认都是null,第一次调用set或者get的时候才懒加载创建。这两个字段的区别一句话讲完:threadLocals只属于当前线程,别人碰不到;inheritableThreadLocals是用来在创建子线程时“复印一份”给子线程用的。后者就是我们后面要讲的InheritableThreadLocal。

既然每个线程都持有一张完全独立的表,那“线程隔离”这个核心语义就天然成立了。你往自己的表里写数据,我看不到,也改不了。这也是为什么Spring的事务、日志链路追踪、参数上下文这些东西都爱用它——不需要加锁,因为根本没有共享可变状态,线程之间各自为政。

1.2 ThreadLocalMap不是普通Map

很多人第一次看ThreadLocalMap会愣一下:它怎么没有继承HashMap,也没有实现Map接口?连泛型都没有,纯粹是ThreadLocal里定义的一个静态内部类。

这是有意的。Java的HashMap在并发扩容时容易出问题,而ThreadLocalMap的运行场景是单线程独享,不需要支持并发修改,因此可以直接砍掉很多复杂逻辑,做成一个精简版散列表。

它的内部结构很简单:

private Entry[] table; private int size; private int threshold;

就是一个Entry数组,没有链表,没有红黑树。哈希冲突时用的是线性探测法(open addressing),也就是位置被占了就往后挪一位继续找。Entry本身是一个继承了WeakReference的内部类:

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

这里有几个关键点:

  • key(ThreadLocal对象本身)是被弱引用包裹的
  • value是强引用,直接挂在这个Entry上
  • Entry数组的长度始终保持2的幂次方,默认初始容量16

为什么用弱引用包着key?这是整个设计里最容易被误解、也最值得琢磨的地方。如果key是强引用,那么只要线程还活着,ThreadLocal对象就永远无法被回收。线程池里的线程往往长期存活,时间一长就会积累一堆已经不再使用的ThreadLocal对象,造成所谓的“ThreadLocal内存泄漏”。用弱引用的话,一旦外部不再持有ThreadLocal对象,GC时这个key就会被回收,Entry的key变成null,后面的清理逻辑就有机会把value一起清掉。

但是注意,弱引用并不能完全解决泄漏。key变成null之后,value仍然被Entry强引用挂着,如果线程一直不销毁,value就一直无法回收。真正的回收靠的是后续的get/set/remove操作触发的“过期Entry清理”,以及线程自身生命周期的结束。这点我们在后面专门展开讲。

1.3 hash函数与魔法数0x61c88647

ThreadLocal的哈希计算不像HashMap那样对key的hashCode做复杂扰动,它走的是另一条路:每个ThreadLocal对象在创建时就被分配了一个递增的hashCode,步长是固定的0x61c88647。

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不是随便挑的数,它对应黄金分割比例(0.618)在32位整数空间的映射。用这个增量生成的哈希值,在数组长度是2的幂的情况下,能够非常均匀地散列到各个桶上,显著减少线性探测时发生碰撞的概率。

这个思路和旧版ConcurrentHashMap里用斐波那契散列来均匀分布segment的做法是同一个套路。网上有很多人专门验证过,用这个增量连续生成十几个ThreadLocal实例,它们的hash值在长度16的数组里取模后几乎不碰撞。这种“均匀性”对于线性探测法极其重要,因为一旦发生碰撞,后续元素就会连续占用相邻位置,形成“堆积”,get的时候需要多跳好几个位置才能找到目标。

所以ThreadLocal在设计上其实是做了取舍的:它在单线程场景下放弃了HashMap那种复杂的红黑树化和链表化逻辑,但通过精心设计的哈希增量,让最简单的线性探测在绝大多数情况下都能保持接近O(1)的查找效率。这是教科书级的“针对场景定制数据结构”的案例。

2. 核心方法的源码级拆解

2.1 set()的完整链路

现在来看最常用的set方法。很多人以为set就是把值塞进一个map里,其实里面的流程远比想象中细致,每一步都藏着性能考量和清理逻辑。

public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); }

第一步拿当前线程对象,第二步拿线程身上的threadLocals,如果还没初始化就调用createMap创建。createMap内部无非是t.threadLocals = new ThreadLocalMap(this, value),用当前的ThreadLocal作为key,传入的值作为value,构建出第一个Entry。

真正的核心在ThreadLocalMap.set方法里:

private void set(ThreadLocal<?> key, Object value) { 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)]) { ThreadLocal<?> k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }

这个循环就是在做线性探测。从计算出的桶位置开始,一路往后找:

  • 如果找到一个Entry的key和当前ThreadLocal相同,说明之前已经set过,直接更新value,覆盖旧值
  • 如果找到一个Entry的key为null,说明这个位置的ThreadLocal已经被GC回收了,当前槽位是“过期槽位”,需要走replaceStaleEntry逻辑,把旧Entry替换掉
  • 如果一路找到null的空槽位,说明整条探测路径上没有当前ThreadLocal,直接插入新Entry

这里i = key.threadLocalHashCode & (len - 1)就是用位运算替代取模。因为len一定是2的幂,所以hash % len和hash & (len - 1)的结果完全一致,但位运算比取模快很多。ThreadLocalMap把这条优化贯彻到底了。

replaceStaleEntry是set里最复杂的一个方法。它的作用不仅仅是把当前key写到过期槽位,还会顺着前后两个方向把探测路径上的其他过期Entry一并清理掉。因为线性探测时,一旦中间出现一个过期槽位,后续的查找可能提前终止(get遇到null就停),导致一些本该能找到的key被“截断”在过期槽位后面。replaceStaleEntry在做替换的同时会顺手把这条路径“疏通”一下,保证探测的连续性。

这个细节很多人读了源码也没意识到:它其实是在用“写操作触发清理”来补偿弱引用带来的延迟回收问题。不清理的话,数组里会积累大量的stale Entry,查找效率越来越低,value也越来越难被回收。所以每隔几次set,ThreadLocalMap就会自动做一轮清理,代价是均摊到每次操作上。

2.2 get()里藏着哪些细节

get方法比set看起来简单,但同样有兜底逻辑:

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(); }

getEntry的逻辑先直接算hash位置,如果命中的Entry正好key相等,直接返回value。这是最理想的情况,一次命中,不需要探测。

如果没命中,进入getEntryAfterMiss,这时候会顺着探测路径往后找。有意思的是,即使是在get路径上,遇到key为null的过期Entry时也会顺手做清理:

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal<?> k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; }

只有走到一个完全为null的空槽位才会停止探测并返回null。这里有个关键细节:如果找到了目标key,但一路上遇到过null key的Entry,会触发expungeStaleEntry把这个槽位清掉,然后返回有效值。也就是说,get不只负责取数据,还兼着“清扫员”的活。

setInitialValue是当map还没初始化或者map里没有当前ThreadLocal时的兜底。它内部调用initialValue(),而ThreadLocal的initialValue默认返回null。这也解释了为什么你不在set前调用get,拿到的就是null。想要自定义初值,可以用匿名内部类重写initialValue,或者用Java 8的ThreadLocal.withInitial(Supplier)。

一个容易踩的坑是:如果你在initialValue里调用了set,会怎么样?答案是会死循环。因为setInitialValue本身就会往map里放入值,而如果你在initialValue里再调set,set又会走一遍正常逻辑,和当前正在执行的setInitialValue形成相互调用。这种问题在面试里偶尔会被拿来当开放题问,实际开发中没人这么写,但理解这个边界有助于加深对初始化流程的认识。

2.3 remove()为什么不可省略

remove的源码非常短:

public void remove() { ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) m.remove(this); }

但作用非常关键。在ThreadLocalMap.remove里,找到key对应的Entry后,会先把Entry的value字段置为null,再把这个Entry本身从数组里清掉,然后调用expungeStaleEntry处理后续的过期Entry。这个“先清value再清Entry”的顺序是有讲究的:防止value被Entry强引用导致GC无法回收。

实践中我最想强调的一句话:如果你在线程池里用了ThreadLocal,用完必须remove。这不是洁癖,是保命。

线程池里的工作线程是复用的,它属于“长期存活”的线程。如果你往它身上挂了一个ThreadLocal值,又不在任务结束时清掉,下次这个线程执行另一个任务时,get到的还是上一个任务的残留值。轻则数据串了,重则引发严重的事故。日志链路里traceId串到别的请求上、用户上下文串到别的用户上,都是这个原因。

而且就算你每次set都会覆盖旧值,也架不住有些场景只get不set。所以最稳妥的习惯是:try/finally里调用remove,保证线程执行完任务后干干净净。

3. 内存泄漏与常见误区

3.1 泄漏到底是怎么发生的

官方一点的说法是:ThreadLocal的内存泄漏是指value无法被回收。但要讲清楚,得先分清key和value两条线。

当外部不再持有ThreadLocal对象时,Entry里的key因为是弱引用,在下一次GC时就会被回收,key变成null。这没问题,key本身没有泄漏。但value呢?value被Entry强引用,Entry被Entry数组强引用,Entry数组被当前线程的threadLocals字段强引用。只要线程不销毁,这条引用链就断不开。线程池里的线程生命周期和整个应用一样长,所以value就永远留在堆里,无法回收。

有人会问:那为什么ThreadLocalMap不把value也设计成弱引用?如果真的这么做了,取出来的值随时可能被GC回收,业务数据说没就没,这比内存泄漏还可怕。所以value必须是强引用,这是保证数据可用的底线。代价就是需要开发者配合做完事后的清理。

还有一层容易被忽略的隐患:如果你在代码里把ThreadLocal对象本身保存在了一个静态变量里,那它的生命周期和应用一样长,弱引用永远不会被回收,这个ThreadLocal的value也就永远不会被清理。这种情况下,所有使用这个ThreadLocal的线程都会积累数据。我见过有些框架把ThreadLocal放在静态字段里,本来是想作为全局上下文,结果线程一多,内存直接飙了。

3.2 触发清理的三种时机

ThreadLocalMap并不是只等着你们手动remove,它自己也有一套“被动清理”机制,只不过触发条件比较巧妙。

第一种时机是get的时候遇到过期Entry。getEntryAfterMiss里只要发现某个位置的Entry key为null,就会调用expungeStaleEntry,把这个槽位清掉,并且把后续探测路径上遇到的其它过期Entry也一并清理。

第二种时机是set的时候。set里的replaceStaleEntry会做双向清理,把新旧槽位附近的stale Entry都清理一遍。set末尾还有一个cleanSomeSlots,它会从当前插入位置出发,对数次扫描若干槽位,碰到stale Entry就清理。这个“对数次”的设计很有意思:它不是每次都全量扫描,而是通过heuristic的方式,用很低的成本完成大概率有效的清理。

第三种时机是扩容的时候。当size超过threshold(阈值为数组长度的2/3),ThreadLocalMap会先尝试rehash,在rehash里做一次全量的expungeStaleEntries,把所有过期Entry全部清掉。如果清理后size仍然超过threshold的3/4,才真正扩容到原来的两倍。

所以ThreadLocalMap的清理是“随缘但保证最终一致性”的。它不保证某个时刻遇到过期Entry立刻清理,但通过get/set路径上的随机清理和扩容时全量清理,能把数组里的垃圾控制在一个可以接受的范围。这也是为什么它只适合单线程使用——多线程并发来set的话,这些清理步骤互相交错,很容易出乱子。

3.3 线程池带来的脏数据问题

前面提到过线程池下的ThreadLocal会有脏数据问题,这里再深入说一个实际场景。

假设一个接口里用了ThreadLocal保存当前请求的用户ID,请求A进来时线程池的线程W被分配执行任务,W身上被塞了用户A的ID。任务执行完,代码里忘了remove。下一个请求B进来,线程池又把W分配给了B,B的代码里如果只get不set,拿到的就是用户A的ID。这就是典型的串号事故。

普通Tomcat容器场景下,如果请求线程是每次新建的还好,线程结束ThreadLocal自然没了。但线程池里的线程是常驻的,问题就会无限放大。更麻烦的是,这种脏数据在测试环境很难复现,因为它是间歇性的,取决于线程池的调度情况,让人觉得“偶发Bug”难定位。

我自己的习惯是,所有用ThreadLocal的地方,尤其是线程池场景,一律try/finally + remove。写起来多两行代码,但能省掉后面无数排查事故的时间。这属于投入产出比极高的一种好习惯。

4. InheritableThreadLocal的实现剖析

4.1 继承是怎么发生的

InheritableThreadLocal(简称ITL)要解决的问题很直接:怎么让子线程拿到父线程里的ThreadLocal值。

默认的ThreadLocal没有这个能力,因为threadLocals是线程私有的,子线程创建时不会自动拷贝父线程的数据。ITL专门开了后门,让子线程在创建的时候从父线程的inheritableThreadLocals里复制一份数据过来。

先看ITL的源码,短得惊人:

public class InheritableThreadLocal<T> extends ThreadLocal<T> { protected T childValue(T parentValue) { return parentValue; } ThreadLocalMap getMap(Thread t) { return t.inheritableThreadLocals; } void createMap(Thread t, T firstValue) { t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue); } }

它本身只做了三件事:确定使用inheritableThreadLocals这张表,提供childValue钩子方法,以及创建map的方式。真正的复制动作发生在Thread的构造函数里,而不是ITL类自身。

在Thread.init方法里有一段关键代码:

if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocalMap.createInheritedMap(parent.inheritableThreadLocals);

这里的parent就是当前正在创建子线程的那个线程,也就是“父线程”。如果父线程的inheritableThreadLocals不为空,就把里面的所有Entry完整复制到子线程的inheritableThreadLocals中。拷贝完成后,子线程就拥有了自己的副本,以后父子线程各自修改互不影响。

一个值得注意的细节:这个复制是“深度值复制”,不是“引用共享”。虽然key还是同一个ThreadLocal对象,但value是重新放进新map里的。而value的转换就交给childValue方法,默认直接返回原值,你可以在子类里重写它做自定义转换,比如对可变对象做深拷贝,或者对某些值做脱敏处理。

4.2 createInheritedMap的拷贝细节

createInheritedMap最终调用的是一个以parentMap为参数的ThreadLocalMap构造器:

private ThreadLocalMap(ThreadLocalMap parentMap) { Entry[] parentTable = parentMap.table; int len = parentTable.length; setThreshold(len); table = new Entry[len]; for (int j = 0; j < len; j++) { Entry e = parentTable[j]; if (e != null) { ThreadLocal<Object> key = (ThreadLocal<Object>) e.get(); if (key != null) { Object value = key.childValue(e.value); Entry c = new Entry(key, value); int h = key.threadLocalHashCode & (len - 1); while (table[h] != null) h = nextIndex(h, len); table[h] = c; size++; } } } }

注意这里面有两层过滤:第一层是跳过null槽位,第二层是跳过key为null的过期Entry。也就是说,父线程里那些已经被清理或回收的ThreadLocal,不会带到子线程去。然后对每个有效的Entry,先调用childValue得到子线程的值,再创建一个新的Entry放到新数组里。因为这里的key是同一个ThreadLocal实例,hashCode也一样,所以hash位置和父线程里是一致的,但如果发生碰撞,也会通过线性探测重新寻找位置。

这里还能看到childValue的调用时机:在构造器内部,对每个Entry都执行一次。如果你在childValue里做了耗时操作(比如远程调用、复杂运算),那么每次创建子线程都会付出这份开销,性能上需要心里有数。

4.3 一个亲手验证继承链路的简单例子

我写了一段很简单的测试代码来验证ITL的行为,你自己也可以直接跑:

public class InheritableThreadLocalTest { static ThreadLocal<String> normal = new ThreadLocal<>(); static InheritableThreadLocal<String> inheritable = new InheritableThreadLocal<>(); public static void main(String[] args) { normal.set("normal-value"); inheritable.set("inheritable-value"); Thread child = new Thread(() -> { System.out.println("child normal: " + normal.get()); System.out.println("child inheritable: " + inheritable.get()); }); child.start(); } }

输出结果是:

child normal: null child inheritable: inheritable-value

这就直观体现了两种ThreadLocal的差异。普通ThreadLocal不会传递给子线程,ITL会。而且child线程拿到的inheritable-value是父线程set进去的那个字符串对象,它们在各自线程的map里是相互独立的。父线程后续再inheritable.set("new-value"),子线程里依然是老值,不会跟着变。

如果我在父类里重写childValue做前缀拼接:

static InheritableThreadLocal<String> inheritable = new InheritableThreadLocal<>() { @Override protected String childValue(String parentValue) { return "[child]" + parentValue; } };

那么子线程拿到的值就会是[child]inheritable-value。这个钩子应用范围挺广的,比如父线程传一个对象进来,子线程希望拿到一份浅拷贝或深拷贝,都可以在childValue里实现。

5. 线程池下的继承失效与替代方案

5.1 为什么ITL在池化场景会失效

ITL看起来能解决问题,但它有个致命的前提:它只在“线程创建”的那一刻复制数据。也就是说,只有当父线程new出一个Thread对象的时候,继承动作才会发生。

线程池恰恰破坏了这一点。线程池里的工作线程是提前创建好的,或者是在提交第一个任务时才创建的,但一旦创建完成就会一直复用。后续提交任务时,并不会重新创建线程,所以也不会重新触发ITL的继承机制。

举个例子:主线程往ITL里set了一个用户ID,然后往线程池提交任务T1。如果此时线程池还没有工作线程,会先new一个W线程出来,主线程的ITL值被复制到W上,T1能拿到用户ID,正常。但T1跑完后,W继续留在池子里。下一次主线程又set了一个新用户ID,再提交任务T2,T2分给W执行。这时W已经存在,不会再次从主线程复制ITL值,所以T2拿到的还是旧用户ID。

这个现象非常隐蔽,因为它“第一次成功、第二次失败”,让人很难从直觉上理解为什么。我在排查问题时见过好几个例子,都是新任务第一次能拿到值,之后就越来越不对劲,最后定位到ITL在池化场景的根本限制上。

5.2 解决思路:任务提交时快照,执行时重放

既然继承机制只在建线程时生效,那换一种思路:不在线程创建时传递,而是在任务提交的时候,把当前线程的上下文值“快照”下来,然后封装到任务里,等任务真正执行时再“重放”到执行线程上。

这个方案的手写版大概长这样:

public class ContextPropagateTask implements Runnable { private final Runnable task; private final Map<ThreadLocal<?>, Object> snapshot; public ContextPropagateTask(Runnable task) { this.task = task; this.snapshot = capture(); } private Map<ThreadLocal<?>, Object> capture() { Map<ThreadLocal<?>, Object> map = new HashMap<>(); // 把你关心的ThreadLocal值取出来 map.put(MY_TL, MY_TL.get()); return map; } private void replay() { snapshot.forEach((k, v) -> k.set(v)); } private void restore() { snapshot.keySet().forEach(k -> k.remove()); } @Override public void run() { replay(); try { task.run(); } finally { restore(); } } }

思路有三步:提交前捕获当前线程的上下文,执行前把上下文设置到工作线程上,执行完毕后再清掉,防止影响下一个任务。这个“transmit传值”的核心思想,和业界某些开源组件(比如名字里带Transmittable的那个类库)是同一个套路,只不过它们在捕获范围、性能优化、兼容性上做了更完整的封装。

手写方案看起来很美好,但实际落地有一个难点:你需要在所有提交任务的地方都用这个包装类包一层,一旦有漏网之鱼,上下文就断了。而且如果任务内部又往线程池提交了子任务,包装会层层嵌套,每一层都要捕获和重放,很容易漏。所以如果你的项目里大量使用线程池并且对上下文传递有强需求,直接引入成熟的开源方案比自己从零写要靠谱得多。

5.3 有没有更通用的写法

如果不想引入额外依赖,只想在项目里快速解决ITL失效的问题,有几个实用的小技巧可以分享。

第一个技巧是:在线程池外部统一包装Runnable和Callable。比如你在封装线程池时,可以在execute和submit方法里自动套上ContextPropagateTask,业务代码无感知。这相当于把线程池的入口作为传递上下文的一道闸门,不用每个业务代码都操心。

第二个技巧是:利用NamingThreadFactory之类的自定义线程工厂,在创建线程时做一次上下文绑定。这个办法适合那种“线程创建后完全固定”的场景,但灵活性低,因为线程一旦创建就无法动态更新上下文,本质上和ITL的局限一样。

第三个技巧是:对特定场景单独做“手动put”。比如日志traceId,可以在任务开头通过日志框架的MDC接口主动set一下,任务结束再remove。虽然要多写代码,但胜在简单直观,排查问题时一目了然。

关于这个主题,最后再分享一个我踩过坑后的体会:任何“自动传递”的机制,都必须配套严格的“自动清理”设计。捕获值的时候想得再完美,如果restore这一步漏了,工作线程上就会残留上一次的上下文,造成下一个任务被污染。我见过不止一次线上事故,就是因为某个环节replace了上下文但没有在finally里remove,导致整个线程池里的上下文全都串了。所以不管你用哪种方案,务必保证重放和还原是成对出现的,这是整个方案能不能安全落地的底线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询