ThreadLocal · ThreadLocalMap · WeakReference · 串号 · remove · expungeStaleEntry · cleanSomeSlots · InheritableThreadLocal · TransmittableThreadLocal · TaskDecorator · ScopedValue

ThreadLocal:值挂在线程上,线程不死,值就不走

ThreadLocal 让一个请求不用层层传参,就能在任何地方取到“当前用户”。代价是:值存在线程上,而线程池里的线程比请求活得长得多。请求结束了不 remove(),下一个请求会读到上一个人的用户号(串号);值一直挂在线程上,就是泄漏。弱引用只救得了 key,救不了 value。实测 不 remove 时,4 个不带登录头的请求全部记到了别人头上;每个请求 new 一个 ThreadLocal 放 1 MB,GC 之后线程上还挂着 22~41 MB。

全景:一个下单请求在线程池里 · 第 3、11 站地图:J02 · ThreadLocal 与内存泄漏(第 7 个知识点)结构的上一圈:HashMap 的亲戚们 ④-4(ThreadLocalMap 和 0x61c88647)实验:JDK 17.0.20,2 核 ARM 实测

①为什么需要:用户号要跟着请求走,却不想层层传参

在全景第 3 站,这一步要解决的是:过滤器从请求头读出 X-User: 1024,后面的 Controller、Service、日志、审计都要用它,但不想给每个方法都加一个 userId 参数。第 11 站要解决的是:请求处理完,线程回到池子里,挂在它身上的 1024 必须清掉。

Thread exec-1(Tomcat 池里的线程,一直活着,一个接一个处理请求) 请求 A · X-User: 1024 USER.set(1024)第 3 站 过滤器 USER.get() → 1024下单记到 1024 第 11 站 回池忘了 remove() 请求 B · 没带头(本该是“未登录”) 过滤器:没头,不 set第 3 站 USER.get() → 1024订单记到了别人头上:串号 exec-1.threadLocals:USER → 1024 这张表挂在线程上,不随请求结束而消失,从 A 一直留到 B 加上 finally { USER.remove(); }:A 结束时表里的 1024 被删掉,B 读到 null,按未登录处理。
全景第 2 站保证“一个请求从头到尾在同一个线程上跑”,ThreadLocal 就靠这个保证,把“属于这个请求的东西”挂在线程上。可是线程池里的线程会接着处理下一个请求。
需求地图上这一项的深度是“懂机制”:说得出值到底存在哪、谁引用着它;为什么不 remove 会串号、会泄漏;弱引用管什么、不管什么;以及值为什么不会自己跟到别的线程去(下一个知识点 CompletableFuture 要用)。

②没有它会怎样:层层传参,或者自己建一张全局表

不懂这一点时的典型误用:把 ThreadLocal 当成“线程安全的全局变量”,只 set 不 remove,还觉得“请求结束了,值自然就没了”。实测 2 个线程的池子,先来两个登录用户,再来 4 个不带头的请求,4 个全部读到了前一个用户的号(⑤)。

③相邻方案:上下文怎么带

做法值存在哪跨线程谁负责清
显式传参 / 上下文对象方法参数跟着参数走不用清
ThreadLocal当前线程的 ThreadLocalMap不跟你:remove()
InheritableThreadLocal同上,另一张表只在 new Thread 时复制一次你
TransmittableThreadLocal(阿里开源)同上提交任务时抓取,执行前放进去,执行后恢复包装器
ScopedValue(JDK 25 正式)当前线程的绑定,只在一段代码里有效结构化并发的子任务自动继承出了作用域自动没了

选择规则:能传参就传参;要穿过框架代码时用 ThreadLocal,但 set 和 remove 必须成对;要跨到线程池里,用包装器显式带过去(⑥第三圈);JDK 25 起新代码可以考虑 ScopedValue。本页学 ThreadLocal 本身,以及它怎么泄漏。

ThreadLocal 还有第二种用法:给每个线程一份线程不安全的对象,比如 ThreadLocal.withInitial(() -> new SimpleDateFormat(…))。机制一样,坑也一样,只是这种通常不用清(值不属于某个请求)。

④原理:引用链、两种坏事、“顺手”的清理

先知道这些
  • 值存在线程上:每个 Thread 对象有个 threadLocals 字段,是一张 ThreadLocalMap;ThreadLocal 对象本身只带一个哈希值,充当 key。表的开放寻址和 0x61c88647 在HashMap 的亲戚们 ④-4 讲过,这里不重讲。
  • 弱引用:一个对象如果只剩弱引用指着,下次 GC 就会被回收,弱引用变成 null(WeakHashMap 那节)。
  • 活着的线程是 GC Root:从它出发能走到的对象都不会被回收。线程池里的线程几乎不死(线程池执行流程)。

④-1引用链:key 是弱的,value 是强的

谁指着 ThreadLocal 对象 static final USER类活着就一直指着 请求里的局部变量 tl请求结束,这条引用就没了 ThreadLocal 对象(key) ThreadLocal USER只有哈希值,不存数据 ThreadLocal tl#7只剩弱引用 → 被 GC 回收 Thread exec-1 · 活着 = GC Root threadLocals → ThreadLocalMap → Entry[] table Entry extends WeakReference<ThreadLocal> key ┄┄ 弱 → USER value ━━ 强 → 1024L Entry(tl#7 的那格) key = null(referent 被 GC 清掉了) value ━━ 强 → byte[1 MB],还在 红框那格:线程 → 表 → Entry → value 全是强引用。线程不死、这格没被清,1 MB 就一直在。 ✕ ✕
虚线是弱引用:只用来找 key,不拦着 GC 回收 ThreadLocal 对象。实线是强引用。value 只有表里这一条引用,所以它不能是弱的,否则下次 GC 就没了。

④-2两种坏事:串号和泄漏

坏事怎么出现后果会自己好吗
串号static 的 ThreadLocal,set 是有条件的(有头才 set),请求结束不 remove下一个请求读到上一个请求的值:订单记错人、越权不会。key 一直被 static 字段强引用着,弱引用根本用不上
泄漏 ①:大值 × 线程数static 的 ThreadLocal 放大对象(缓存、整个查询结果),不 remove每个线程都挂一份。200 个线程 × 5 MB = 1 GB被下一次 set 覆盖才放旧的;线程不死就一直占一份
泄漏 ②:key 没了 value 还在ThreadLocal 不是 static:每个请求或对象 new 一个,不 removeGC 清掉 key 以后 value 还挂在线程上只在“碰巧被扫到”时清(④-3)。实测 挂着 22~41 MB
泄漏 ③:整个类加载器Web 应用重新部署,旧应用的 value 还挂在容器的线程上value 的类 → 旧的类加载器 → 它加载的所有类都卸不掉,Metaspace 一次次涨不会。Tomcat 停应用时会检测并打警告(⑥第三圈)

串号比泄漏更常见,也更危险:它不报错,只是把数据写错人。弱引用只跟泄漏 ② 有关;另外三种它都管不着。

④-3清理是“顺手”的:动手看 ThreadLocalMap

key 变成 null 以后,表并不会马上知道。JDK 17 的源码注释原话是:没有用引用队列,所以过期条目只有在表快满的时候才保证会被清掉(“stale entries are guaranteed to be removed only when the table starts running out of space”)。平时只在这几个时候顺手清:

先猜:一个请求 new 了一个 ThreadLocal,放了 1 MB,没 remove。请求结束、GC 跑过之后,那 1 MB 会怎样?

key 活着key 没人用了,还没 GCkey = null,value 还在

表是线程 exec-1 的 ThreadLocalMap,初始 16 格。点上面的按钮,每一步都按 JDK 17 源码的 set / get / remove 算。

模拟逻辑是 JDK 17 ThreadLocalMap 的 set、getEntry、remove、replaceStaleEntry、expungeStaleEntry、cleanSomeSlots、rehash、resize 逐行翻译,在 node 里跑过。真实 JVM 里 GC 什么时候来由堆决定,这里由你点。

④-4为什么这样设计:弱 key、强 value、不用引用队列

Entry 怎么引用ThreadLocal 对象没人用了以后结果
key 强、value 强表里的 key 拉着它,永远回收不了key 和 value 都泄漏,表里还认不出哪格是垃圾
key 弱、value 弱value 只有这一条引用,下次 GC 就没了正在用的值也会凭空消失,get 拿到 null,不能用
key 弱、value 强(JDK 的选择)key 被回收,格子变成“key = null”能认出垃圾,下次碰到就清;value 要等到清理
再加上及时 remove()根本不会出现没人用的格子唯一确定不泄漏的做法

ThreadLocalMap:碰到再清弱 key + 探测时顺手清理

  • 表只有本线程读写,清理也在本线程里做,不需要任何同步
  • 大多数程序只有几个 static ThreadLocal,key 从不过期,这套清理几乎不花时间
  • 过期条目什么时候被碰到,没人说得准:实测 GC 后挂着 22~41 MB

被放弃的:引用队列WeakHashMap 的做法

  • GC 把清掉的 Entry 放进队列,每次操作先把队列里的删干净,清得及时
  • 队列由 GC 和 Reference Handler 线程往里放,本线程往外取,要同步;每次 get 都要先查一下队列
  • 每个 Entry 多带一个队列引用,而这条最热的路径上,绝大多数时候没有任何东西可清

决定胜负的约束:get 要快、表只属于一个线程、key 几乎从不过期。所以 JDK 把弱引用当成兜底,把“及时清”交还给使用者:remove()。(源码注释只写了“不用引用队列”;右边几条是按这个约束推出来的,不是源码原话。)

API最少要会的几行
public final class UserContext {
  private static final ThreadLocal<Long> USER = new ThreadLocal<>();   // static final:全进程一个 key
  public static Long get()        { return USER.get(); }
  public static void set(Long id) { USER.set(id); }
  public static void clear()      { USER.remove(); }
}

// 过滤器(全景第 3 站和第 11 站):set 和 remove 在同一层,用 finally 配对
UserContext.set(parseHeader(req));          // 没头也 set(null),不留上一个请求的值
try {
  chain.doFilter(req, resp);
} finally {
  UserContext.clear();
}

ThreadLocal<DateTimeFormatter> f = ThreadLocal.withInitial(() -> …);   // 第一次 get 时建

跨线程怎么带(InheritableThreadLocal、TTL、TaskDecorator)、Tomcat 的类加载器泄漏、虚拟线程和 ScopedValue,放到第⑥步。

⑤亲眼看到:串号,和挂在线程上的 1 MB

两个程序,--release 8 编译,JDK 17 运行,模拟交易靶场里的 UserContext。完整代码:study-labs/java/juc/threadlocal-leak →

实验 1:2 个 exec 线程,先来两个登录用户,再来 4 个不带头的请求

$ java -cp … Mixup
请求 1  头 X-User=1024  线程 exec-1  下单用户=1024
请求 2  头 X-User=2048  线程 exec-2  下单用户=2048
请求 3  头 X-User=null  线程 exec-1  下单用户=1024  <-- 串号!
请求 4  头 X-User=null  线程 exec-2  下单用户=2048  <-- 串号!
请求 5  头 X-User=null  线程 exec-1  下单用户=1024  <-- 串号!
请求 6  头 X-User=null  线程 exec-2  下单用户=2048  <-- 串号!

$ java -cp … Mixup remove        # finally 里 USER.remove()
请求 3  头 X-User=null  线程 exec-1  下单用户=null
请求 4 … 请求 6                      下单用户=null

实验 2:池里 1 个线程,跑 200 个请求,每个请求往 ThreadLocal 里放 1 MB

$ java -Xmx512m --add-opens java.base/java.lang=ALL-UNNAMED -cp … Leak perobj 200
模式 perobj,200 个请求跑完                 # 每个请求 new 一个 ThreadLocal,不 remove
  [请求刚结束]       表长 128,活条目 22(22 MB),key=null 的条目 0
  [Full GC 之后]     表长 128,活条目 2(0 MB),key=null 的条目 22(还挂着 22 MB)
  [再来一次 set 之后] 表长 128,活条目 2(0 MB),key=null 的条目 20(还挂着 20 MB)

$ java … Leak static 200               # static ThreadLocal,不 remove
  [Full GC 之后]     表长 16,活条目 3(1 MB),key=null 的条目 0

$ java … Leak remove 200               # 每个请求 new 一个,finally remove
  [Full GC 之后]     表长 16,活条目 1(0 MB),key=null 的条目 0
模式GC 后线程上挂着表长说明
perobj,200 个请求(两次)22 MB、29 MB128key 被 GC 清成 null,value 没走
perobj,1000 个请求41 MB128没有涨到 1000 MB:GC 一清 key,后面的 set 就能顺手清掉一些
static1 MB16每次 set 覆盖同一格,一个线程只挂最新的一份
remove016表都不用扩容
到这里已经能交差了:你能说清值存在线程的 ThreadLocalMap 里、key 弱 value 强;知道不 remove 会串号、会泄漏;会写“入口 set、finally remove”的过滤器。
已知还没处理的细节:有条件的 set(⑥ 坑 1)、每次 new ThreadLocal(⑥ 坑 2)、往里放大对象(⑥ 坑 3)、异步线程里拿不到(⑥ 坑 4)、InheritableThreadLocal 碰上线程池(⑥ 坑 5)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1有条件的 set,加上不 remove:串号← ④-2 串号
现象
偶发:订单、日志、审计记到了另一个用户头上;不带 token 的请求“登录”成了别人。⑤ 实验 1:4 个请求全串号
根源
线程被复用,上一个请求的值还在表里;static 的 key 一直活着,弱引用不起作用。
做法
入口无条件 set(包括 null),出口 finally remove;放在同一个 Filter 或 HandlerInterceptor 的 preHandle / afterCompletion 里。
坑 2ThreadLocal 不是 static:每个请求或对象 new 一个← ④-3 顺手清理
现象
堆慢慢涨,堆转储里一堆 ThreadLocalMap$Entry 的 referent 是 null,value 很大。⑤ 实验 2:GC 后挂着 22~41 MB
根源
key 被 GC 回收了,value 还是强引用;过期格子要等“碰巧被扫到”。
做法
ThreadLocal 一律 private static final;真需要每个对象一份,就自己在对象里放字段,别用 ThreadLocal。
坑 3往 ThreadLocal 里放大对象或缓存← ④-1 线程是 GC Root
现象
上线后老年代一直下不去;堆转储里每个 exec 线程都挂着一份几 MB 的列表。
根源
每个线程一份,线程不死就不放:线程数 × 值的大小。
做法
只放 ID 这类小东西,要用实体时再查;缓存交给 Caffeine 这类有容量上限的缓存。
坑 4异步任务、线程池里拿不到值← ④-1 值存在当前线程上
现象
CompletableFuture、@Async 里的日志没有 traceId,UserContext.get() 是 null。全景第 10 站 实测:在 Thread-552 上拿到 null
根源
get() 查的是当前线程的表,换了线程就是另一张表。
做法
提交任务时把值抓出来,任务开始时 set、结束时 remove:Spring 的 TaskDecorator,或者阿里的 TTL(第三圈)。下一个知识点 CompletableFuture 会用到。
坑 5InheritableThreadLocal 碰上线程池← ⑥ 第三圈 只在建线程时复制
现象
子任务里的用户号是“很早以前的某个人”:恰好是池子建这个线程时,提交任务的那个请求的值。
根源
JDK 17 源码:只在 Thread 的构造方法里把父线程的 inheritableThreadLocals 复制一份;池子里的线程建好以后就不再复制。
做法
线程池场景不要用 InheritableThreadLocal 传请求上下文,用装饰器在每次提交时传。
第二圈

使用规范

private static final,封装在一个类里

只暴露 get / set / clear,不把 ThreadLocal 对象传来传去。

坑 2:每次 new 一个。

set 和 remove 成对,放在同一层的 try / finally

谁 set 谁 remove,入口无条件 set。阿里巴巴 Java 开发手册把“线程池场景下必须回收自定义的 ThreadLocal”列为强制。

坑 1:串号。

只放小东西

用户号、traceId、租户号;不放实体、列表、缓存。

坑 3:线程数 × 大小。

跨线程显式传

线程池统一包一层装饰器,提交时抓取、执行前放入、执行后清掉。

坑 4、坑 5。

第三圈

深处:跨线程传递、容器泄漏、版本差异

跨线程传递的三种做法

InheritableThreadLocal:new Thread 时,构造方法调 ThreadLocal.createInheritedMap 把父线程的表复制一份,childValue 默认原样返回(浅拷贝,父子拿到的是同一个对象)。只复制一次,所以只适合“每个任务新建线程”。Spring 的 TaskDecorator:包住每个 Runnable,在提交线程里抓取、在执行线程里放入和清掉,MDC、SecurityContext 都这么带(@Async 页)。TransmittableThreadLocal(阿里开源)把这套做成通用的:提交时 capture,执行前 replay,执行后 restore 回执行线程原来的值,还能用 Java Agent 把 JDK 线程池整个包起来。

Tomcat 重新部署时的类加载器泄漏

Tomcat 的线程属于容器,比 Web 应用活得久。应用里的 value 是应用类加载器加载的类,它挂在容器线程上,应用停了这个类加载器也卸不掉,连带它加载的所有类。按 Tomcat 文档,停应用时它会检查线程的 ThreadLocal,发现没清的就打一条 SEVERE 日志(“created a ThreadLocal with key of type … but failed to remove it”),并会逐步替换掉池里的线程来止损。Spring Boot 的 fat jar 一个进程一个应用,不重新部署,所以基本碰不到;Spring 自己的 RequestContextHolder、事务同步、日志 MDC 都是在 finally 里清的。

JDK 8 / 17 / 21 有什么不同

8 和 17 的算法一样,17 只是把 e.get() == key 换成了 JDK 16 新加的 e.refersTo(key),不用把 referent 取出来。21 加了虚拟线程:每个虚拟线程有自己的 ThreadLocalMap,而且一定支持 ThreadLocal(JEP 444 去掉了预览版里“不支持 ThreadLocal 的虚拟线程”这个选项)。百万个虚拟线程各挂一份缓存,内存就炸了,所以“每线程一份对象”的用法在虚拟线程上要换掉;JDK 21 源码里有个 -Djdk.traceVirtualThreadLocals=true,虚拟线程每次 set 都会打出调用栈,用来找这种代码。

ScopedValue:没有 remove 这回事

JDK 21 预览(JEP 446),JDK 25 正式(JEP 506)。写法是 ScopedValue.where(USER, 1024).run(() -> …):值只在 run 里面有效,出来自动解绑;不能 set,只能在更内层重新绑定;结构化并发里的子任务自动继承。串号和泄漏从结构上就不可能发生,代价是值必须在一个代码块里用完。

面试官会怎么问

ThreadLocal 的值存在哪?

存在当前 Thread 对象的 threadLocals 字段(一张 ThreadLocalMap)里,ThreadLocal 对象只充当 key,带一个哈希值(④-1)。

为什么会内存泄漏?key 为什么用弱引用?

Entry 的 key 弱、value 强。ThreadLocal 对象没人用时 key 被回收,但 value 还被线程 → 表 → Entry 强引用,要等到被顺手清理、remove 或线程结束;线程池的线程不结束。key 用弱引用是为了让这种格子能被认出来并清掉,value 不能弱,否则正在用的值会被 GC(④-1、④-4)。

static 的 ThreadLocal 还会泄漏吗?

key 不会被回收,弱引用不起作用;每个线程挂一份最新的值,是“线程数 × 值大小”的有界占用,更大的问题是串号(④-2,⑤ static 模式 1 MB)。

父子线程、线程池之间怎么传 ThreadLocal?

InheritableThreadLocal 只在建线程时复制一次,不适合线程池;用 TaskDecorator 或 TTL 在每次提交时抓取、执行前放入、执行后恢复(坑 5、第三圈)。

下一圈

↺回到⑤:改造你的案例

1. 把 Mixup 改成“入口无条件 set”

不加 remove,只把 if (header != null) 去掉,看还串不串号;再想想这样做留下了什么问题(提示:value 还挂着)。

用到:④-2、坑 1

2. 算一算 static 模式的上限

把 Leak 的池子改成 8 个线程,用 static 模式跑 200 个请求,预测 GC 后一共挂着多少 MB,再跑一次对照。

用到:④-1、坑 3

3. 写一个最小的 TaskDecorator

写 wrap(Runnable r):在提交线程里读出 USER,返回一个新的 Runnable,执行前 set、finally 里 remove。用它把全景第 10 站的通知任务包一下,看还是不是 null。

用到:坑 4、第三圈

自测

1. 用一句话说出这一页的精华。

值存在线程自己的表里,key 弱、value 强;线程池的线程不死,所以每次请求都要 finally remove,否则串号、泄漏,弱引用只能兜住 key。

2. 自己建 static Map<Thread, Long>,疼在哪?

所有线程抢一张表要同步;表强引用着线程对象,线程结束也删不掉;请求结束不删照样串号(②)。

3. 串号的根源是 ④ 的哪一条?为什么弱引用帮不上忙?

④-1 和 ④-2:值挂在被复用的线程上;key 是 static 字段,一直被强引用着,永远不会变成 null,弱引用这一层根本不起作用。

4. 预测:⑤ 实验 2 的 perobj 模式,如果每个请求结束后都主动 System.gc() 一次,GC 后挂着的会比 22 MB 多还是少?

少得多。每次 GC 后上一个请求的 key 就是 null 了,下一次 set 落点附近很容易扫到它并清掉(还会因此把扫描次数重置成 log₂ 表长,多清几个),表也不会扩到 128。用本页的实验台“连来 5 个”之间点 GC 可以看到同样的现象(④-3)。

5. 预测:把 Tomcat 的线程数从 200 调到 20,坑 1 的串号会变多还是变少?坑 3 的占用呢?

串号概率变大:线程少,每个线程被更多请求复用,不带头的请求更容易落到“有旧值”的线程上。坑 3 的占用变小:线程数 × 值大小,线程少了,挂的份数就少了(④-1、④-2)。

6. 放回全景:第 3 站 set 了 1024,第 11 站忘了 remove,第 10 站的异步通知又是在 Thread-552 上发的。下一个请求和这次的通知各会看到什么?

下一个请求如果落在同一个 exec 线程上,又没带头,会读到 1024,订单记到别人头上(串号)。这次的通知在 Thread-552 上,那里的表里没有 USER,读到 null,通知发不出去或发给了“未知用户”(坑 4)。一个是值留得太久,一个是值没跟过去,根源都是“值挂在线程上”。