ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ThreadLocal源码深度解析:从ThreadLocalMap到线程池上下文传递

ThreadLocal源码深度解析:从ThreadLocalMap到线程池上下文传递

1. 为什么资深开发者都在重新翻ThreadLocal源码

最近在维护一个大型业务系统时,排查线上偶发性的参数串号问题,顺着调用链一路追下去,最后定位到是一个异步任务池里的上下文传递BUG。折腾完这个case之后,我把ThreadLocal和InheritableThreadLocal的源码又完整过了一遍。说实话,这两个类平时用得不少,但真正能讲清楚"子线程为什么能继承""线程池里为什么又失效了"的人并不多。这篇文章想把ThreadLocal家族的源码逻辑、使用边界和那些坑一次讲透。

适合谁看?如果你写过ThreadLocal存登录态、存链路追踪ID,或者接过"为什么线程池里取不到值"的工单,这篇都能帮你省时间。我会从类结构、核心方法、变量生命周期、父子线程传导、线程池失效场景这几个角度拆,不绕圈子,直接对着JDK源码说话。

先说一个反直觉的结论,很多人以为ThreadLocal是"线程私有变量",实际上它更像是一把钥匙——每个线程维护了一张Map,ThreadLocal只是Map的key。搞清楚这个定位,后面所有源码都能串起来。

2. ThreadLocal的存储模型:每个线程都有一张属于它自己的Map

2.1 ThreadLocalMap的诞生时机

先看ThreadLocal最核心的成员变量:

public class ThreadLocal<T> { private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; }

每个ThreadLocal实例创建时都会分配一个哈希码,这个哈希码不是随便生成的,用的是斐波那契散列。0x61c88647这个魔法数字能保证在ThreadLocalMap容量为2的幂时,散列分布足够均匀,减少碰撞。这一点每次给新ThreadLocal赋值时都直接决定性能,值得记住。

而真正的存储容器在Thread类内部:

public class Thread implements Runnable { ThreadLocal.ThreadLocalMap threadLocals = null; ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; }

可以看到,每个线程对象里有两个ThreadLocalMap字段。一个线程第一次调用set或get时,才会触发ThreadLocalMap的创建,不是线程一启动就有。这段懒加载逻辑在ThreadLocal的createMap里体现得很明显:

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

我画个不太严谨但好记的类比:ThreadLocal是个钥匙扣,ThreadLocalMap是挂满钥匙的板子,每个线程都随身带一块板子。ThreadLocal本身并不存储任何值,它只是决定"这块板子上哪一格放你的东西"。

2.2 Entry数组与开放寻址法

ThreadLocalMap内部维护的是一个Entry数组,Entry继承自WeakReference:

static class ThreadLocalMap { private Entry[] table; private int size = 0; private int threshold; } static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }

Entry的key是弱引用指向ThreadLocal,value是强引用。很多人看到这个设计会问:为什么key用弱引用?答案是为了配合线程销毁后的回收。

假设线程存活在池中,ThreadLocal对象在业务代码里已经失去强引用,如果Entry的key是强引用,ThreadLocal永远无法被回收,等于变相内存泄漏。用弱引用后,只要外部不再持有ThreadLocal,GC时key直接被回收,此时Entry的key变成null,value还在,就需要后续的清理机制兜底。

解决哈希冲突时,ThreadLocalMap使用的不是链表法,而是开放寻址法。也就是如果计算出的槽位被占用,就往后逐格找空位:

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

这里有两个关键点。第一,查找key时如果遇到Entry不为null但key为null,说明这是一个过期脏Entry,需要清理和重新利用。第二,扩容阈值是len的三分之二,也就是threshold = len * 2 / 3,整体负载因子比HashMap的0.75还要低,因为开放寻址法对负载更敏感,太高会导致探测路径过长。

2.3 为什么ThreadLocal的get要显式做一次setInitialValue

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

如果ThreadLocalMap还没创建,或者map里查不到当前ThreadLocal对应的Entry,会走setInitialValue。默认实现是:

private T setInitialValue() { T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); return value; } protected T initialValue() { return null; }

这解释了为什么重写initialValue后,第一次get不需要先set也能拿到默认值。我见过不少业务代码在ThreadLocal子类里既不重写initialValue,也不先set就直接get,结果拿到null,然后空指针。这不是JVM的错,是没搞懂这个初始化流程。

3. ThreadLocalMap的清理机制:replaceStaleEntry是整条链路的灵魂

3.1 处理过期Entry的完整流程

replaceStaleEntry这个名字在源码里到处都是,很多人读源码时会跳过,但它恰恰是避免内存泄漏的核心。看一下完整实现:

private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; int slotToExpunge = staleSlot; for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) { if (e.get() == null) slotToExpunge = i; } for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); }

这段逻辑可以拆成三步理解。

第一步,从staleSlot往前遍历,找到连续非null区域内最靠前的过期槽位,记为slotToExpunge。这个往前找的过程是为了定位整个连续探测区间的起点,避免只清理一个槽位后留下更多脏数据。

第二步,从staleSlot往后遍历,尝试查找是否已经有相同key的Entry。如果找到了,就把值更新进去,并且把staleSlot和当前槽位互换位置。互换的目的是让Entry排列紧凑,减少后续探测的距离。这里有个细节,value更新后旧Entry的key引用已经被GC清掉了,但value还强引用着,所以先置null再换位,避免旧的value残留。

第三步,如果没找到相同key,直接在staleSlot处塞进新Entry,同时把原value置null。最后调用expungeStaleEntry和cleanSomeSlots做批量清理。

3.2 expungeStaleEntry为何要顺带rehash

expungeStaleEntry的job不只是把当前过期槽位置null,它还会从下一个位置开始,把后面所有被移动过的Entry重新哈希到更靠前的位置:

private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; tab[staleSlot].value = null; tab[staleSlot] = null; size--; Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; }

这里有两个收尾动作很有价值。一是每清理一个槽位,size就减一。size直接参与扩容判断,及时减掉才能让扩容阈值准确。二是对后续Entry做一次rehash。开放寻址法最怕删除后留下"坑",如果不把后面冲突的Entry挪回来,下次get同一个key可能要探测好几个空位才能找到目标,性能会慢慢劣化。

我在代码评审时经常看到有人质疑"为什么ThreadLocal的remove这么重要",其实从这套清理机制就能看出:ThreadLocal自己不清理时,全靠触发get、set时顺带扫几格。如果长时间不访问,脏Entry会一直堆积在线程的Map里,尤其在业务线程池中,线程存活很久,value引用的对象根本释放不掉,这比直接new一个对象占用的内存要隐蔽得多。

3.3 误删同key的边界问题

replaceStaleEntry里有个细节很多人没注意:两个不同ThreadLocal对象的哈希码完全可能落同一个槽位并形成探测链,清理时只看key是否为null,不看是不是同一个key。所以清理逻辑只能判断"这个Entry的ThreadLocal是否已被GC回收",不能判断"这个槽位是否属于某个具体业务变量"。这也是为什么key用弱引用而value必须置null,因为value根本不知道属于谁,只有靠key判死。

4. InheritableThreadLocal:父线程如何把变量传递到子线程

4.1 继承机制的源码位置

InheritableThreadLocal的源码非常短:

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

它只是覆写了getMap和createMap,让读写落到inheritableThreadLocals字段上。真正的传递逻辑在Thread类的init方法里。

看Thread初始化时这一段:

if (inheritThreadLocals && parent.inheritableThreadLocals != null) { this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); }

也就是说,当创建新线程时,JVM会检查父线程的inheritableThreadLocals是否为空。不为空,就把父线程的ThreadLocalMap复制一份出来,作为新线程自己的inheritableThreadLocals。复制过程是逐Entry调childValue完成的,默认childValue就是直接返回父值,你可以通过覆写childValue实现"父线程传值给子线程时允许篡改"。

createInheritedMap长这样:

static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) { return new ThreadLocalMap(parentMap); } private ThreadLocalMap(ThreadLocalMap parentMap) { Entry[] parentTable = parentMap.table; int len = parentTable.length; setThreshold(len); table = new Entry[len]; for (Entry e : parentTable) { 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++; } } } }

这里注意,复制的是Entry数组里的"值",不是同一个Entry对象。key还是原来那个ThreadLocal对象,value是调用childValue后的结果。所以父子线程各自持有独立副本,修改子线程的值不会影响父线程。

4.2 变量在父子线程中的可见性

由于复制发生在Thread构造函数执行时,所以有一个天然规律:InheritableThreadLocal只传递创建时刻的值。父子线程建立之后,父线程再往InheritableThreadLocal里写新值,已经创建的子线程看不到。这不算线程安全问题,而是快照语义。

我举个形象例子:主线程往InheritableThreadLocal里放了requestId=1,然后new了一个子线程。子线程启动后无论是get还是set,操作的都是自己那份副本。主线程随后把requestId改成2,子线程依然读到1,除非子线程自己重新赋值。

4.3 配合线程复用时需要特别小心

线程池场景下,InheritableThreadLocal的坑就来了。池里的线程是复用的,不是每次提交任务都新建线程。如果你在提交任务前给InheritableThreadLocal赋值,池线程并不会重新触发createInheritedMap,因为那是Thread构造时的行为,不是每次任务提交的行为。于是你会看到一种诡异现象:第一次提交任务时线程池里的线程能拿到值,第二次提交时拿到的还是旧值,因为这个池线程从诞生那一刻起,它的inheritableThreadLocals就固化在那里了。

我实际遇到的case是:主线程用InheritableThreadLocal传traceId,配合固定线程池做并行子任务。第一次调用一切正常,第二次调用时traceId全是第一次调用时的值。查了半天才发现问题出在"池线程的inheritableThreadLocals只在创建线程时复制一次"。

这个问题的常规解法是什么?无非三种。

第一种,使用TransmittableThreadLocal这类工具,它本质上是在任务提交时把上下文快照绑定到Runnable上,执行前再回填到当前线程。第二种,自己在任务提交时手动set值到线程的普通ThreadLocal里,执行完再清理。第三种,不用线程池,每次new Thread,代价高,不推荐。

5. 线程池场景下的值传递失效:原因与常规替代方案

5.1 为什么线程池里两代ThreadLocal都会失效

普通ThreadLocal在线程池里失效,原因非常简单:线程池线程是复用线程,这些线程最初是由池工厂在任意时间点创建的,那时候业务变量还不存在,threadLocals是空的。任务提交后,任务代码明明是在这个池线程上运行的,但ThreadLocal的set/get绑定的是"线程当前状态",池线程并不会因为你提交一个任务就自动继承提交者的threadLocals。

InheritableThreadLocal失效则是因为继承只发生在线程创建那一刻。线程池创建线程比任务提交更早,等任务进来时,继承快照已经定格在池线程诞生的时间点,里面什么都没有或者只有过期值。

一个字概括两代失效根因:没有"任务级传递"。

5.2 阿里开源的TransmittableThreadLocal解决思路

与其干巴巴讲原理,不如直接说成熟方案。阿里开源的TransmittableThreadLocal(简称TTL)是目前最常见的替代品,整体设计可以概括为一句话:提交任务时把当前线程的上下文快照抓下来,封装进Runnable;执行任务前把快照回填到执行线程;执行结束后恢复原来线程的上下文状态。

TTL的核心类包括:

  • TransmittableThreadLocal:继承InheritableThreadLocal,支持增量更新快照。
  • TtlRunnable:包装普通Runnable,持有captured快照。
  • TtlExecutors:提供线程池装饰方法,把普通ExecutorService包成支持上下文传递的版本。

它的设计精妙之处在于只拷贝变化的Entry,不是全量复制整个Map,采用所谓的"增量快照"策略。如果父线程修改了某个ThreadLocal值,下次提交时快照只需要记录这个增量,开销比全量复制小很多。

我实际使用时,代码大概长这样:

TransmittableThreadLocal<String> traceId = new TransmittableThreadLocal<>(); ExecutorService ttlExecutor = TtlExecutors.getTtlExecutorService(threadPool); traceId.set("abc"); ttlExecutor.execute(() -> System.out.println(traceId.get()));

这样线程池里的Runnable能读到正确traceId,任务执行完再跑别的任务也没问题。TTL还提供了TtlRunnable.get和TtlCallable.get这种手动包装方式,适合直接使用Spring的@Async等不给你换线程池装饰器的场景。

5.3 轻量自研方案的成本评估

如果公司禁止引入外部依赖,也可以自己做一个简化版。思路是维护一个名为holder的ThreadLocal,把用户想传递的上下文放进Map结构,然后自己包装Runnable或Callable,捕获与回填。

但这里我要泼一点冷水:自己实现很容易漏掉异常场景。任务执行一半抛出异常,finally里没恢复上下文,下一次任务可能读到脏上下文。线程池里一个线程会被成百上千个任务复用,上下文污染一出现,排查起来非常痛苦。所以我个人建议,除非团队对细节极其熟悉,否则直接用TTL这类被充分验证过的方案,成本更低。

6. 源码之外:使用ThreadLocal时真正需要注意的四个问题

6.1 内存泄漏的根因与remove时机

线程池线程的存活周期通常很长,ThreadLocalMap被线程持有,Map里的Entry如果不清理就会一直保留。即使ThreadLocal本身是弱引用会被回收,但value是强引用,value指向的对象也不会被回收。

这也就是为什么很多编码规范里强调:使用完后必须调用remove。具体时机可以参考:

使用场景推荐清理时机
Web请求拦截器在afterCompletion中remove
RPC调用上下文在调用链结束的finally中remove
异步任务在任务执行完的finally中remove
设置了initialValue的ThreadLocal同样需要remove,防止脏值残留

我自己的习惯是:创建ThreadLocal时就在同一处写清楚清理点,set和remove保持成对出现,比事后靠代码审查发现强得多。

6.2 线程池串值与ThreadLocal的静态变量陷阱

另一种常见串值场景是ThreadLocal被定义成static。这种写法本身不违法,但要注意所有线程共享同一个ThreadLocal对象作为key,每个线程的值是独立的没错,可一旦ThreadLocal对象被重新赋值,比如static ThreadLocal td = new ThreadLocal(),原来所有线程存储的Entry就全失效了,因为key变了。所以static ThreadLocal应该尽量final,别动不动就重新new。

6.3 性能开销并非想象中那么小

ThreadLocal的优点确实多,但也不是零成本。每一次get和set都要经过Thread.currentThread(),然后查Map,遇到哈希冲突还要走一段探测链。在高并发热路径上,频繁调用ThreadLocal会带来一定的CPU开销和缓存局部性问题。

不过大多数业务场景里,这种开销远小于锁竞争的开销,所以ThreadLocal通常还是比加锁更划算。切忌为了"性能好"而到处堆ThreadLocal,每个ThreadLocal对象本身也会占用内存,大量使用却不开清理,内存压力也不小。

6.4 与异步框架搭配时的坑

使用CompletableFuture、RxJava、响应式流这类框架时,它们内部维护着自己的线程调度,不一定感知ThreadLocal。比如异步回调可能切换到完全不同线程,原线程的ThreadLocal值就丢失了。这种情况下,要主动把需要传递的上下文放入任务对象,或者选择合适的线程上下文传递方案,不能指望靠ThreadLocal自动延续。

我见过一个典型故障:业务代码用ThreadLocal保存用户ID,在一个方法里提交了CompletableFuture任务,回调里又要读用户ID,结果全部是null。原因就是回调执行线程是ForkJoinPool的共享线程,和提交线程没有任何继承关系。

7. 我梳理ThreadLocal源码后沉淀的实用排查清单

如果你现在遇到"某值该有却没有"或者"该换却没换"的问题,不要急着改代码,先按下面的顺序排查。

第一步,确认ThreadLocal绑定在哪个线程。打印Thread.currentThread().getName(),看get/set是否发生在同一条线程。很多时候问题根本在于代码跳了线程,比如并行流、异步回调,却以为还在原线程执行。

第二步,确认set和get之间是否有remove或线程池复用的干扰。如果线程池线程之前被别的任务写过脏值,而恰好当前任务又没显式set,就会读到上一个任务残留的旧值。

第三步,确认是不是inheritThreadLocals被关闭了。Thread构造函数中如果inheritThreadLocals传false,即使父线程有InheritableThreadLocal也不会复制给子线程。这是隐藏开关,很容易被忽略。

第四步,确认类加载器是否不同。Tomcat这类容器里,不同Web应用的ThreadLocal可能因为类加载器隔离而互相看不到,但线程池线程可能被多个类加载器共享,这时ThreadLocal本身没问题,但value引用的类可能无法被加载识别。

第五步,确认ThreadLocal对象是否被重新new过。如果ThreadLocal是static final的,不会有这种问题;如果不是final,某次代码分支里new了新的ThreadLocal对象,那数据当然就读不到。

这套排查清单只是帮你看清方向,真正理解代码还得靠源码本身。我每次遇到线程上下文问题,都会重新看一遍ThreadLocalMap的扩容阈值、Entry清理逻辑、继承复制点,每次都多少有点新收获。这也是为什么我一直主张,这类基础类库的知识值得反复精读——它平时不显眼,但一到线上事故现场,能省下半天排查时间。

RELATED READING

延伸阅读

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