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 必须清掉。
②没有它会怎样:层层传参,或者自己建一张全局表
- 层层传参:
placeOrder(userId, traceId, tenantId, …),每加一个上下文字段就要改一串方法签名;中间夹着框架代码(AOP、MyBatis 拦截器、日志 Appender)时,参数根本传不进去。 - 自己建
static Map<Thread, Long>:所有线程抢同一张表,要用 ConcurrentHashMap;表强引用着 Thread 对象,线程结束了也不会自动删;请求结束忘了删,照样串号。 - 缺的那一块:每个线程自己的一格存储,只有本线程读写,不用加锁。这就是 ThreadLocal。它省掉了锁和参数,但没有省掉“用完要删”这件事。
不懂这一点时的典型误用:把 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 是强的
- 1key 弱:ThreadLocal 对象没人用了(比如它是某个请求对象的字段),GC 能回收它,表里那格的 key 变成 null,成了“能认出来的垃圾”。
- 2value 强:value 只有表里这一条引用。它要等这一格被清理(④-3)、被
remove(),或者线程死掉(Thread.exit()把threadLocals置 null)才会被放开。 - 3线程池让第三条几乎不成立:Tomcat 的 exec 线程、业务线程池的线程跟进程同寿。所以在服务端代码里,“线程结束就没了”不能当清理手段。
④-2两种坏事:串号和泄漏
| 坏事 | 怎么出现 | 后果 | 会自己好吗 |
|---|---|---|---|
| 串号 | static 的 ThreadLocal,set 是有条件的(有头才 set),请求结束不 remove | 下一个请求读到上一个请求的值:订单记错人、越权 | 不会。key 一直被 static 字段强引用着,弱引用根本用不上 |
| 泄漏 ①:大值 × 线程数 | static 的 ThreadLocal 放大对象(缓存、整个查询结果),不 remove | 每个线程都挂一份。200 个线程 × 5 MB = 1 GB | 被下一次 set 覆盖才放旧的;线程不死就一直占一份 |
| 泄漏 ②:key 没了 value 还在 | ThreadLocal 不是 static:每个请求或对象 new 一个,不 remove | GC 清掉 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”)。平时只在这几个时候顺手清:
- 1
get()没在第一格命中、往后探测时,遇到 key 为 null 的格子就清(expungeStaleEntry,顺带把后面的条目往前挪)。第一格就命中时什么都不清。 - 2
set()放进新条目后,往后扫 log₂(条目数) 格(cleanSomeSlots);扫到一个过期的,就把扫描次数重置成 log₂(表长) 继续扫。 - 3条目数到了表长的 2/3:先全表清一遍(
expungeStaleEntries),剩下的还多才扩容。 - 4
remove():清掉这一格,再顺着往后清一段。这是唯一确定的清理。
先猜:一个请求 new 了一个 ThreadLocal,放了 1 MB,没 remove。请求结束、GC 跑过之后,那 1 MB 会怎样?
表是线程 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()。(源码注释只写了“不用引用队列”;右边几条是按这个约束推出来的,不是源码原话。)
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
- 1串号跟着线程走:exec-1 上的请求都读到 1024,exec-2 上的都读到 2048。真实的 Tomcat 有几十个线程,哪个请求落到哪个线程上是随机的,所以这个 bug 时有时无,很难复现。
- 2加一行
finally { USER.remove(); }就全对了。也可以在入口无条件 set(没头就 set 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 MB | 128 | key 被 GC 清成 null,value 没走 |
| perobj,1000 个请求 | 41 MB | 128 | 没有涨到 1000 MB:GC 一清 key,后面的 set 就能顺手清掉一些 |
| static | 1 MB | 16 | 每次 set 覆盖同一格,一个线程只挂最新的一份 |
| remove | 0 | 16 | 表都不用扩容 |
- 1弱引用只救了 key:Full GC 后 22 个 key 全变成 null,22 MB 一个字节都没少。再来一次 set,
cleanSomeSlots只扫几格,清掉 2 个。 - 2泄漏有上限,但上限没人说得准:取决于 GC 什么时候来、新的 set 碰巧扫到哪几格。三次分别是 22、29、41 MB。线程池里有 200 个线程,就要乘 200。
- 3堆比开始时多占了约 2 倍(44~82 MB),因为 G1 这里的 region 是 1 MB,1 MB 的数组加上对象头放不进一个 region,按“大对象”占了两个。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 偶发:订单、日志、审计记到了另一个用户头上;不带 token 的请求“登录”成了别人。⑤ 实验 1:4 个请求全串号
- 根源
- 线程被复用,上一个请求的值还在表里;static 的 key 一直活着,弱引用不起作用。
- 做法
- 入口无条件 set(包括 null),出口 finally remove;放在同一个 Filter 或
HandlerInterceptor的preHandle/afterCompletion里。
- 现象
- 堆慢慢涨,堆转储里一堆
ThreadLocalMap$Entry的 referent 是 null,value 很大。⑤ 实验 2:GC 后挂着 22~41 MB - 根源
- key 被 GC 回收了,value 还是强引用;过期格子要等“碰巧被扫到”。
- 做法
- ThreadLocal 一律
private static final;真需要每个对象一份,就自己在对象里放字段,别用 ThreadLocal。
- 现象
- 上线后老年代一直下不去;堆转储里每个 exec 线程都挂着一份几 MB 的列表。
- 根源
- 每个线程一份,线程不死就不放:线程数 × 值的大小。
- 做法
- 只放 ID 这类小东西,要用实体时再查;缓存交给 Caffeine 这类有容量上限的缓存。
- 现象
- CompletableFuture、@Async 里的日志没有 traceId,
UserContext.get()是 null。全景第 10 站 实测:在 Thread-552 上拿到 null - 根源
get()查的是当前线程的表,换了线程就是另一张表。- 做法
- 提交任务时把值抓出来,任务开始时 set、结束时 remove:Spring 的
TaskDecorator,或者阿里的 TTL(第三圈)。下一个知识点 CompletableFuture 会用到。
- 现象
- 子任务里的用户号是“很早以前的某个人”:恰好是池子建这个线程时,提交任务的那个请求的值。
- 根源
- JDK 17 源码:只在
Thread的构造方法里把父线程的inheritableThreadLocals复制一份;池子里的线程建好以后就不再复制。 - 做法
- 线程池场景不要用 InheritableThreadLocal 传请求上下文,用装饰器在每次提交时传。
使用规范
只暴露 get / set / clear,不把 ThreadLocal 对象传来传去。
坑 2:每次 new 一个。
谁 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、第三圈)。
- TransmittableThreadLocal 源码:capture / replay / restore 怎么做到不串号,Java Agent 怎么包住 JDK 线程池
- 用 MAT 打开堆转储,从
Thread→threadLocals找到泄漏的 value(J14 线上排障) - Netty 的 FastThreadLocal:用数组下标代替哈希探测,为什么更快、什么时候反而更费内存
- ScopedValue 加 StructuredTaskScope:虚拟线程时代的上下文传递(第 9 个知识点)
- 在 Tomcat 里复现一次重新部署的类加载器泄漏,看 Metaspace 和那条 SEVERE 日志
↺回到⑤:改造你的案例
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)。一个是值留得太久,一个是值没跟过去,根源都是“值挂在线程上”。