源码级剖析 Java ThreadLocal:线程局部变量的存储结构、哈希探测与内存回收实战
【免费下载链接】source-code-hunter😱 从源码层面,剖析挖掘互联网行业主流技术的底层实现原理,为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶,Mybatis、Netty、Dubbo 框架,及 Redis、Tomcat 中间件等项目地址: https://gitcode.com/GitHub_Trending/so/source-code-hunter
ThreadLocal 是 Java 并发编程中最常用也最容易被误用的工具之一。本文以 JDK 源码为线索,从 Thread 类 中的threadLocals字段出发,完整剖析ThreadLocal的set/get/remove核心实现、ThreadLocalMap的哈希存储与扩容机制,以及弱引用Entry与内存泄漏之间的关系。读完你不仅能看懂ThreadLocal的每一行关键代码,还能掌握线程池与 Web 容器场景下线程局部变量的正确清理方式,并顺带理解 Netty 的FastThreadLocal为什么更快。
ThreadLocal 是什么:一句话理解线程局部变量
ThreadLocal类提供了线程局部变量(thread-local variables)的get/set实现。它与普通成员变量最大的不同在于:每个线程都可以通过同一个ThreadLocal对象 get/set 出属于自己的专属值,线程之间互不可见、互不干扰。
ThreadLocal实例通常是类中的私有静态变量,常用于将状态与线程关联,典型场景包括:
- 用户 ID、事务 ID 等请求维度的上下文传递;
- 数据库连接、Session 等"一线程一实例"的资源持有;
- 框架内部(如 Spring 的
RequestContextHolder、MyBatis 的 SqlSession 管理)的隐式传参。
tips:在类中定义
ThreadLocal变量时,一般在定义时就进行实例化,避免多次创建或空指针问题。
如上图所示:Thread1与Thread2各自持有一个独立的ThreadLocalMap,即使它们操作的是同一个threadLocalA对象,存储的值也完全独立(valueA1与valueA2)。这正是"同一个 ThreadLocal 对象能为每个线程绑定专属值"的奥秘——set的值实际上被存到了各个调用线程自己的ThreadLocalMap中。
溯源:Thread 类中的 threadLocals 字段
在阅读ThreadLocal核心 API 之前,必须先看Thread类本身。因为ThreadLocal的get/set方法操作的其实都是Thread类中的成员变量。仓库中的 Thread 类源码分析 对这一点做了详细铺垫:
public class Thread implements Runnable { /** 线程名 */ private volatile char name[]; /** 优先级 */ private int priority; /** 是否为守护线程 */ private boolean daemon; /** 线程要执行的目标任务 */ private Runnable target; /** 所属线程组 */ private ThreadGroup group; /** 类加载器 */ private ClassLoader contextClassLoader; /** * ThreadLocal 能为线程设置线程私有变量 就是通过下面这个threadLocals变量完成的, * ThreadLocal的get/set方法就是通过操作 各个线程的 threadLocals 变量实现的。 * 1、线程A持有一个 ThreadLocalMap 变量; * 2、线程A调用一个类的 ThreadLocal变量 tlA 的 get/set方法; * 3、tlA(ThreadLocal)的 get/set方法 获取当前线程A,调用 线程A 的 ThreadLocalMap变量 的get/put方法; * 4、其它线程 调用 tlA(ThreadLocal)的 get/set方法 同理。 */ ThreadLocal.ThreadLocalMap threadLocals; ThreadLocal.ThreadLocalMap inheritableThreadLocals; /** 线程栈的大小 */ private long stackSize; ... }可以看到,Thread类持有两个ThreadLocal.ThreadLocalMap类型的成员变量:
threadLocals:每个线程自己的线程局部变量表,是ThreadLocal存取的主战场;inheritableThreadLocals:可继承的线程局部变量表,用于子线程继承父线程的变量(后文详述)。
线程对象在初始化时这两个字段均为null,直到第一次调用某个ThreadLocal的set或get时才被懒加载创建。ThreadLocal本身不存储任何数据,它只是"钥匙"——真正的"锁柜"是每个线程各自的ThreadLocalMap。
ThreadLocal 核心 API 源码解析
有了上面的铺垫,ThreadLocal的源码就非常直观了。以下代码与注释来自仓库文档 ThreadLocal.md 的完整继承与解读。
set(T value):把值写入当前线程
public class ThreadLocal<T> { /** * ThreadLocal能为每个 Thread线程 绑定一个专属值的奥秘就是: * 每个Thread对象都持有一个 ThreadLocalMap类型的成员变量,其key为ThreadLocal对象, * value为绑定的值,所以每个线程调用 ThreadLocal对象 的set(T value)方法时,都会将 * 该ThreadLocal对象和绑定的值 以键值对的形式存入当前线程,这样,同一个ThreadLocal对象 * 就可以为每个线程绑定一个专属值咯。 * 每个线程调用 ThreadLocal对象的get()方法时,就可以根据 当前ThreadLocal对象 get到 绑定的值。 */ public void set(T value) { // 获取当前线程 Thread t = Thread.currentThread(); // 获取当前线程对象中持有的 ThreadLocalMap类型的成员变量 // ThreadLocalMap,看名字也知道它是一个 Map类型的 类 ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } ThreadLocalMap getMap(Thread t) { // 经过前面对 Thread类 源码的分析,可以知道,Thread类中有一个 ThreadLocalMap 类型的 // threadLocals变量 return t.threadLocals; } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); } ... }set的完整调用链为:ThreadLocal.set(value)→Thread.currentThread()拿到当前线程 →getMap(t)取出该线程的threadLocals→ 若map已存在则map.set(this, value),否则先通过createMap创建ThreadLocalMap并放入首个键值对。
get():从当前线程取出专属值
public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { // 通过当前 ThreadLocal对象,获取绑定的值 ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); }get的逻辑与set对称:先取当前线程的ThreadLocalMap,若 map 存在且能通过当前ThreadLocal对象命中对应的Entry,直接返回其value;若 map 不存在或没有命中,则调用setInitialValue()。
setInitialValue()在 JDK 源码中的实现如下:它调用可被子类重写的initialValue()方法获取初始值(默认返回null),然后走与set相同的"创建/写入"路径:
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; }这就是为什么第一次get()未显式set时会返回null(或你重写initialValue()提供的默认值),同时它也会触发ThreadLocalMap的懒创建。
remove():显式解除绑定
public void remove() { // 获取当前线程的ThreadLocalMap成员变量,不为空就将当前 ThreadLocal对象 // 对应的 键值对 remove掉 ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) m.remove(this); }remove直接删除当前线程ThreadLocalMap中与该ThreadLocal对象对应的键值对。它是防止内存泄漏的关键手段,后文会重点强调其使用时机。
ThreadLocalMap:线程局部变量的"存储仓"
ThreadLocalMap是ThreadLocal的静态内部类,也是整个机制的核心。与大部分Map实现相同,它底层使用动态数组保存键值对Entry,同样具备rehash、resize等操作,但它与HashMap有两个显著差异:key 被固定为ThreadLocal类型,且采用开放寻址法(线性探测)解决哈希冲突。
Entry:弱引用 Key 的设计
static class ThreadLocalMap { /** * 存储键值对,key 为 ThreadLocal对象,value 为 与该ThreadLocal对象绑定的值 * Entry的key是对ThreadLocal的弱引用,当抛弃掉ThreadLocal对象时,垃圾收集器会 * 忽略这个key的引用而清理掉ThreadLocal对象,防止了内存泄漏 */ static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } ... }Entry继承自WeakReference<ThreadLocal<?>>,即key(ThreadLocal 对象)是弱引用,value 是强引用。这样设计的好处是:当外部不再强引用某个ThreadLocal对象时,GC 可以回收该 key,避免ThreadLocal对象本身长期驻留内存。但代价也随之而来——value 仍然是强引用,只要线程(线程池中的线程)存活,即使 key 已被回收(e.get() == null),value 也不会被自动回收,从而形成"key 为 null 的陈旧条目",这就是 ThreadLocal 内存泄漏的根源。
哈希定位与线性探测
// 看过 HashMap 或 ConcurrentHashMap 源码的同学 一定下面对这些代码很眼熟 /** * 数组初始容量 */ private static final int INITIAL_CAPACITY = 16; /** * Entry数组,用于存储 <ThreadLocal<?> k, Object v>键值对 */ private Entry[] table; /** * Entry元素数量 */ private int size = 0; /** * 类似于 HashMap 扩容因子机制 */ private int threshold; // Default to 0 private void setThreshold(int len) { threshold = len * 2 / 3; } private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); } private static int prevIndex(int i, int len) { return ((i - 1 >= 0) ? i - 1 : len - 1); }- 初始容量
INITIAL_CAPACITY = 16; - 负载因子阈值不是 0.75 而是2/3(
threshold = len * 2 / 3),比 HashMap 更"保守",这是为了给线性探测留出更多的空槽位、降低探测链长度; nextIndex/prevIndex实现了环形数组遍历:到数组末尾时回绕到下标 0,这就是开放寻址法(线性探测)的"继续往后找"逻辑。
每个ThreadLocal对象都持有一个threadLocalHashCode,在 JDK 源码中它由静态AtomicInteger按固定增量0x61c88647(黄金分割数相关的斐波那契散列增量)递增生成,能保证哈希值在数组长度取模后分布足够均匀。定位公式为:
int i = key.threadLocalHashCode & (len - 1);构造方法:首次创建的两种入口
/** * 系列构造方法 */ ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); } 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) { @SuppressWarnings("unchecked") 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++; } } } }第一个构造方法用于线程首次set/get时创建,直接把首个键值对放入哈希位置。
第二个私有构造方法接收一个parentMap(父线程的inheritableThreadLocals),逐条复制父线程的键值对,并对每个值调用key.childValue(e.value)。childValue的默认实现是原样返回,而InheritableThreadLocal重写了该方法,从而实现了子线程继承父线程线程局部变量的能力——这正是inheritableThreadLocals发挥作用的地方(详见下文)。
set():覆盖、替换陈旧条目与触发 rehash
/** * 常规Map实现类 的set()方法,只不过这里的 key被规定为 ThreadLocal类型 */ 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(); // 如果key相等,覆盖value if (k == key) { e.value = value; return; } // 如果key为null,用新key、value覆盖,同时清理历史key=null的陈旧数据 if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; // 若超过阀值,则rehash if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }set的核心流程:
- 从哈希位置开始线性探测:若遇到 key 相同的
Entry,直接覆盖 value 并返回; - 若遇到
k == null的陈旧条目(key 已被 GC 回收),调用replaceStaleEntry用新键值对替换,同时清理探测链上的其他陈旧数据; - 若探测一圈后找到空位,则放入新
Entry,size自增; - 若本次插入未能通过
cleanSomeSlots(启发式清理)消除足够的陈旧条目,且size >= threshold,则触发rehash()。
可见 ThreadLocal 在每次set时都会"顺手"做内存清理,这也是"key 弱引用 + 操作时清理"这套防泄漏机制的完整闭环。
getEntry() 与探测失败后的补救
/** * 根据 ThreadLocal对象 获取其对应的 Entry实例 */ private Entry getEntry(ThreadLocal<?> key) { int i = key.threadLocalHashCode & (table.length - 1); Entry e = table[i]; if (e != null && e.get() == key) return e; else return getEntryAfterMiss(key, i, e); }哈希位置直接命中(e.get() == key)时 O(1) 返回;否则调用getEntryAfterMiss沿着探测链继续查找,过程中若遇到e.get() == null的陈旧条目,同样会调用expungeStaleEntry(i)顺手清理——每次get也在做内存回收。
remove():清除并整理
/** * Remove the entry for key. */ private void remove(ThreadLocal<?> key) { 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)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } }remove找到目标Entry后执行e.clear()(清掉弱引用 key),再调用expungeStaleEntry(i)清理该位置的陈旧条目并重整探测链,保证后续探测不被空洞打断。
rehash 与 resize:清理 + 扩容
/** * 调整当前table的容量。首先扫描整个容器,以删除过时的条目,如果这不能充分缩小表的大小, * 将进行扩容操作 */ private void rehash() { // 扫描整个容器,删除过时的条目 expungeStaleEntries(); // 若未能充分缩小表的大小,则进行扩容操作 if (size >= threshold - threshold / 4) resize(); } /** * 扩容为原容量的两倍 */ private void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; // 遍历Entry[]数组 for (int j = 0; j < oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal<?> k = e.get(); // 如果key=null,把value也置null,有助于GC回收对象 if (k == null) { e.value = null; // Help the GC } else { int h = k.threadLocalHashCode & (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } // 设置新的阈值 setThreshold(newLen); size = count; table = newTab; }rehash先做全表expungeStaleEntries()清理陈旧条目;若清理后size仍达到threshold - threshold / 4(即阈值的 3/4),才执行resize;resize将容量翻倍(newLen = oldLen * 2),遍历旧表重新哈希:陈旧条目直接把value置null帮助 GC,有效条目按新长度重新线性探测入位;- 与
HashMap的链表重排不同,ThreadLocalMap 扩容后所有有效条目都要按新容量重新探测放置,这也是后续 NettyFastThreadLocal想要规避的开销之一。
子线程传值:InheritableThreadLocal 与 Thread.init
前面提到Thread类还有一个inheritableThreadLocals字段。在 Thread 类源码分析 的init()方法中可以看到子线程是如何继承父线程变量的:
private void init(ThreadGroup threadgroup, Runnable runnable, String name, long l, AccessControlContext accesscontrolcontext) { ... // 当前线程就是该线程的父线程 Thread parent = currentThread(); ... target = runnable; setPriority(priority); if (parent.inheritableThreadLocals != null) // 创建线程共享变量副本 inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); stackSize = l; // 分配线程id tid = nextThreadID(); }关键逻辑:
- 创建新线程时,
currentThread()即为父线程; - 若父线程的
inheritableThreadLocals不为 null,则调用ThreadLocal.createInheritedMap(parent.inheritableThreadLocals),内部就是走前文分析的ThreadLocalMap(ThreadLocalMap parentMap)私有构造方法,配合InheritableThreadLocal.childValue()完成值复制; - 因此只有继承自
InheritableThreadLocal的变量才能被子线程感知;普通ThreadLocal的数据放在threadLocals中,子线程无法访问。
这一点在框架中非常实用,例如需要在新建线程时自动携带主线程的 traceId、用户上下文等场景。但要注意:继承发生在线程创建的那一刻,之后父线程对InheritableThreadLocal的修改不会同步给已创建的子线程。
使用注意事项与实战规范
原文档在结尾给出了两条非常重要的使用铁律,这里结合源码展开说明。
1. ThreadLocal 不是用来解决线程安全问题的
ThreadLocal 不是用来解决线程安全问题的,多线程不共享,不存在竞争!其目的是使线程能够使用本地变量。
从源码可以清晰看到:set/get操作的数据都落在当前线程自己的ThreadLocalMap里,线程之间物理隔离,自然不存在共享与竞争,也就谈不上"解决线程安全问题"。如果你的业务对象本身需要被多个线程共享并修改,ThreadLocal 并不能替你保证线程安全——它只负责把数据"藏"到各线程自己的储物柜里。另外,ThreadLocal 变量本身(ThreadLocal 对象)是共享的,线程之间共享的是这把"钥匙",而不是柜子里的"值"。
2. 线程池场景必须 remove,否则可能内存泄漏或数据串扰
项目如果使用了线程池,那么线程回收后 ThreadLocal 变量要 remove 掉,否则线程池回收线程后,变量还在内存中,可能会带来意想不到的后果!
结合源码理解这背后的机制:
- 线程池中的线程执行完任务后并不会销毁,而是回到池中等待复用;
- 只要线程存活,它持有的
ThreadLocalMap就存活;即使ThreadLocal的 key 已被弱引用回收,value 仍是强引用,无法被 GC 回收——这是内存泄漏的根源(需要线程存活 + key 被回收两个条件同时满足); - 更隐蔽的风险是数据串扰:同一个复用线程执行的下一个任务,可能"继承"上一个任务遗留的 ThreadLocal 值,读到过期甚至错误的数据。
正确的姿势是在每个业务处理结束点(finally块或容器拦截器)显式调用remove():
try { UserContextHolder.set(currentUser); doBusiness(); } finally { UserContextHolder.remove(); // 无论业务成功失败,都要清理 }对于 Tomcat 容器的线程池场景,原文档给出了经典方案:继承HandlerInterceptorAdapter,复写afterCompletion()方法完成清理:
public class ThreadLocalCleanInterceptor extends HandlerInterceptorAdapter { @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求处理完成后,清理当前线程(Tomcat 工作线程)上绑定的 ThreadLocal 变量, // 防止变量残留在线程池复用线程中,避免内存泄漏与后续请求的数据串扰 UserContextHolder.remove(); super.afterCompletion(request, response, handler, ex); } }随后在 Spring MVC 配置中注册该拦截器即可。afterCompletion在整个请求处理链路(含异常)结束后被回调,正好对应 Tomcat 工作线程归还线程池的时机。(注:HandlerInterceptorAdapter在 Spring 5.3 起已标记为废弃,新项目可直接实现HandlerInterceptor接口并用其default方法,思路完全一致。)
延伸阅读:Netty FastThreadLocal 的优化思路
理解了原生 ThreadLocal 的"哈希 + 线性探测 + 扩容重哈希"实现后,就能更好地理解为什么 Netty 要自研 FastThreadLocal。
仓库中的FastThreadLocal源码分析.md指出,原生 ThreadLocal 的每次存取都要经历:计算threadLocalHashCode→ 与容量取模定位 → 若冲突则线性探测。而FastThreadLocal换了一条路:每个FastThreadLocal在构造时通过InternalThreadLocalMap.nextVariableIndex()从全局自增计数器拿到一个固定下标,存取时直接用该下标访问Object[] indexedVariables数组的对应位置:
public FastThreadLocal() { index = InternalThreadLocalMap.nextVariableIndex(); }其性能优势正是针对原生 ThreadLocal 的三个开销点:
- 定位更快:数组下标直接访问,免去了哈希计算与冲突探测;
- 扩容更简单:数组扩容只需复制原内容并用占位对象填充,无需像 ThreadLocalMap 那样对全部有效条目重新哈希(仍可能二次冲突);
- 遍历与回收更可控:所有
FastThreadLocal的引用被统一保存在数组首位集合中,通过FastThreadLocal.removeAll()一次性清理全部变量,配合 NettyDefaultThreadFactory对任务执行完的自动清理(finally { FastThreadLocal.removeAll(); }),在避免内存泄漏的同时省去了原生 ThreadLocal 每次操作后的启发式清理开销。
这恰好从反面印证了原生 ThreadLocal 设计中"弱引用 + 操作时启发式清理"这套机制的成本与必要性。
总结
回到核心结论:
- ThreadLocal 本身不存数据,它只是 key;真正存储数据的
ThreadLocalMap挂在每个Thread的threadLocals字段上,天然实现线程隔离; set线性探测写入、get哈希定位读取、remove显式清除;底层是容量 16、阈值 2/3、冲突线性探测的动态Entry[]数组;Entry的 key 为弱引用,value 为强引用——这既是防泄漏设计,也是泄漏隐患的来源,因此每次 set/get/remove 都会伴随陈旧条目清理;InheritableThreadLocal通过Thread.init()中的createInheritedMap实现子线程继承;- 线程池 / Web 容器复用线程的场景下,务必在业务结束点
remove(),这是源码层面可以明确推导出的硬性规范。
如果想继续深入,仓库还提供了关联的 Thread 类源码分析(理解threadLocals的完整上下文)与 Netty FastThreadLocal 源码分析(高性能线程局部变量的另类实现),两者对照阅读,对线程局部变量机制的理解会更立体。
【免费下载链接】source-code-hunter😱 从源码层面,剖析挖掘互联网行业主流技术的底层实现原理,为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶,Mybatis、Netty、Dubbo 框架,及 Redis、Tomcat 中间件等项目地址: https://gitcode.com/GitHub_Trending/so/source-code-hunter
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考