JAVA · Vol.II · DAY 18 · 并发编程
ThreadLocal 原理与内存泄漏:弱引用的坑
线上 OOM 事故:200 万个幽灵对象
凌晨三点,监控报警。某电商核心服务的 JVM Heap 使用率从 40% 一路飙升到 96%,Full GC 频繁触发但回收不了多少空间。最终服务 OOM 宕机。
// 服务运行 3 天后,运维导出 Heap Dump(4.2GB)
// MAT (Memory Analyzer Tool) 分析结果:
Dominator Tree:
ThreadLocalMap$Entry[] retained: 3.1 GB
└── Entry[0..1999999] retained: 2.8 GB
└── UserContext × 2000000 ← 200 万个用户上下文对象!
├── userId: Long
├── tenantId: String
└── permissions: List<String>
// 根因:Filter 中 set 了 ThreadLocal,但忘了 remove
// 线程池中的线程被复用,Entry 永远不会被清理
事故根因:开发者在请求 Filter 中用 ThreadLocal 存储了用户上下文信息,但没有在 finally 块中调用 remove()。由于 Tomcat 使用线程池,线程不会被销毁,ThreadLocalMap 中的 Entry 永远不会被清理,UserContext 对象越积越多,最终 OOM。
弱引用只能保证 ThreadLocal 实例本身被回收,但 Entry 中的 Value 是强引用——只要线程不死,Value 就永远无法被 GC。这就是 ThreadLocal 内存泄漏的核心矛盾,后面第 11 站会详细分析。
在正式开始之前,先抛几个你工作中大概率遇到过、但未必想透过的问题:
- 为什么说 ThreadLocal 用完必须
remove(),光靠 GC 不行? - 为什么日志里的 traceId 偶尔会"串号",跑到别人的请求上?
- 子线程为什么读不到父线程的 ThreadLocal?那线程池里怎么传递上下文?
- 同样是存数据,ThreadLocal 和 synchronized 到底谁管什么?
带着这些问题,我们从最底层开始,把 ThreadLocal 的存储结构、哈希设计、弱引用与内存泄漏、线程池透传方案一条条拆开。
一句话本质:线程的私人储物柜
先给 ThreadLocal 下个定义:线程本地存储(Thread-Local Storage)——它给每个线程保存一份独立的变量副本。同一个 ThreadLocal 对象,线程 A 往里 set 的值,线程 B 永远 get 不到,反之亦然。
想象一家健身房的更衣室:储物柜的编号规则全公司统一(同一个 ThreadLocal 对象),但每个柜子只归一个人用(每个线程自己的 ThreadLocalMap)。你把自己的包放进 7 号柜,隔壁的人打开自己的 7 号柜,看到的只能是自己的东西——柜子编号相同,柜内世界却互不相干。这就是"线程隔离":同一把钥匙,各开各的锁,各取各的物。
那问题来了:既然要保证"各线程互不干扰",用 synchronized 加锁不就行了?这俩是完全不同的两码事:
| 维度 | synchronized | ThreadLocal |
|---|---|---|
| 解决的问题 | 互斥:多个线程竞争同一份数据,串行访问 | 隔离:每个线程各持一份数据,并行访问 |
| 手段 | 锁。抢不到就阻塞等待(时间换安全) | 副本。每人一份,不用抢(空间换安全) |
| 变量形态 | 一份共享变量,所有人读写它 | 每个线程一份私有副本 |
| 并发度 | 串行,吞吐受锁竞争影响 | 完全并行,无竞争 |
| 典型场景 | 多线程读写同一计数器、同一缓存 | 每个线程要独立的连接、上下文、格式器 |
前者保证"不会乱",后者保证"不用等"——解决的是两个不同层面的问题。
下一站看它在生产里到底被用来干什么。
四大经典场景:连接、上下文、格式器、链路追踪
ThreadLocal 在生产中有四大典型用途,本质都是同一件事:把"只属于当前线程"的状态,随手带在身边。
| 场景 | 问题 | ThreadLocal 方案 |
|---|---|---|
| 数据库连接 / 事务上下文 | 一个请求可能要多次 DB 操作,每次都新建连接太贵;事务要跨多个 DAO 保持同一个连接 | 一个线程绑定一个 Connection,整个请求复用;Spring 的 DataSourceTransactionManager 就是靠 ThreadLocal 把连接绑到当前线程上的 |
| SimpleDateFormat | SDF 内部持有 Calendar,不是线程安全的,共享实例会日期错乱 | 每个线程持有自己的 SDF 实例(或直接换 JDK 8 的 DateTimeFormatter) |
| 请求上下文传播 | 用户信息需要从 Controller 传到 Service 再到 DAO,层层传参太啰嗦 | Spring Security 的 SecurityContextHolder 底层用 ThreadLocal |
| 链路追踪 TraceId | 日志需要携带 traceId 串联完整调用链 | SLF4J 的 MDC(Mapped Diagnostic Context)底层用 ThreadLocal 存 traceId |
/* ─── 场景 1:数据库连接管理(类似 Spring 的实现思路) ─── */
public class DBUtil {
private static final ThreadLocal<Connection> connHolder = new ThreadLocal<>();
public static Connection getConnection() {
Connection conn = connHolder.get();
if (conn == null) { // 懒加载:本线程第一次取才建
conn = dataSource.getConnection();
connHolder.set(conn);
}
return conn;
}
public static void close() {
Connection conn = connHolder.get();
if (conn != null) { conn.close(); connHolder.remove(); }
}
}
/* ─── 场景 2:SimpleDateFormat 线程安全 ─── */
private static final ThreadLocal<SimpleDateFormat> sdfHolder =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
String formatted = sdfHolder.get().format(new Date());
/* ─── 场景 3:请求上下文(Spring Security 的思路) ─── */
public class UserContext {
private static final ThreadLocal<UserInfo> holder = new ThreadLocal<>();
public static void set(UserInfo user) { holder.set(user); }
public static UserInfo get() { return holder.get(); }
public static void clear() { holder.remove(); }
}
/* ─── 场景 4:MDC TraceId ─── */
MDC.put("traceId", UUID.randomUUID().toString());
// logback 日志格式: %d [%X{traceId}] %-5level %msg%n
看到没,四个场景全是"一个线程内带着走的状态"。理解了"线程隔离"这四个字,下一站我们亲手跑一个 Demo 验证它。
亲手跑一遍:先感受"线程隔离"再谈原理
理论说再多不如跑一遍。下面这段代码可以直接复制运行,它同时演示了线程隔离和线程池脏数据两个现象:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalDemo {
// 注意:生产里 ThreadLocal 必须是 static final,原因见第 17 站
private static final ThreadLocal<Integer> counter = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
// ① 主线程设置自己的值
counter.set(100);
// ② 子线程读不到主线程的值(默认不继承!)
Thread t = new Thread(() -> {
System.out.println("子线程 get() = " + counter.get()); // null
counter.set(200); // 各存各的
System.out.println("子线程 set 后 get() = " + counter.get()); // 200
});
t.start();
t.join();
// ③ 主线程的值没有被污染
System.out.println("主线程 get() = " + counter.get()); // 100
// ④ 线程池场景:线程复用,值会"残留"给下一个任务
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> {
counter.set(999);
System.out.println("任务A get() = " + counter.get()); // 999
// 注意:没有 remove()!
});
pool.submit(() -> System.out.println("任务B get() = " + counter.get())); // 999 ← 脏数据!
pool.shutdown();
}
}
子线程 get() = null
子线程 set 后 get() = 200
主线程 get() = 100
任务A get() = 999
任务B get() = 999 ← 任务 B 读到了任务 A 留下的值!
④ 里线程池只有一个线程,任务 A 和任务 B 是先后跑在同一个线程上的。线程没死,它 ThreadLocalMap 里的值就一直挂着,任务 B 自然读到了任务 A 的残留。这就是生产里最常见的两类事故——脏数据和内存泄漏的源头,也是"必须 remove()"的根本原因。先记住这个结论,第 13 站我们看真实的线上事故。
内部实现:Thread 持有一张 ThreadLocalMap
ThreadLocal 的数据并不存储在 ThreadLocal 对象本身,而是存储在每个 Thread 对象内部的 ThreadLocalMap 中。理解这个结构是理解一切问题的前提:
对应到源码,就是 Thread 类里躺着的两个字段:
// 每个线程都有一张自己的 map(懒加载:第一次 set 才创建)
ThreadLocal.ThreadLocalMap threadLocals = null;
// 专供 InheritableThreadLocal 用的另一张 map(第 15 站细讲)
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
如果 Map 挂在 ThreadLocal 上(Map<Thread, Value>),那么所有线程共享一个 Map,需要处理并发问题(加锁),而且 Thread 对象会作为 Key 被强引用,导致线程无法被回收。
把 Map 挂在 Thread 上(Map<ThreadLocal, Value>),天然线程隔离无需加锁,而且 ThreadLocal 作为弱引用 Key 可以被 GC。这是 Josh Bloch 和 Doug Lea 的设计选择。
源码剖析:set() 与 get() 的完整流程
面试高频题:"说说 ThreadLocal 的 set/get 原理"。来看 JDK 源码(带关键注释):
public void set(T value) {
// 1. 获取当前线程
Thread t = Thread.currentThread();
// 2. 获取该线程的 ThreadLocalMap("当前线程取 map")
ThreadLocalMap map = getMap(t); // return t.threadLocals;
if (map != null) {
// 3. 以 this(当前 ThreadLocal 实例)为 key 存入 map
map.set(this, value);
} else {
// 4. 首次使用,为线程创建 ThreadLocalMap
createMap(t, value);
}
}
// createMap 的真相:map 是挂在当前线程对象上的
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
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;
}
}
// map 为空 或 没找到 Entry → 调用 initialValue() 初始化
return setInitialValue();
}
private T setInitialValue() {
T value = initialValue(); // 默认返回 null;withInitial() 可自定义
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
两个方法的共同点:一切从"当前线程"出发——先 Thread.currentThread() 拿到线程,再从线程上取 map,最后以 this 为 key 操作。这就是为什么 ThreadLocal 天然线程安全:每个线程操作的都只是自己的那张 map,根本没有共享数据,也就不需要锁。
这三个问题分别对应 ThreadLocal 最精妙的三处设计——魔法数字 0x61c88647(第 8 站)、开放定址法(第 9 站)、remove() 的讲究(第 7 站)。我们一站一站来。
remove():为什么"删除"比"插入"更讲究
规范要求 set/get/remove 三个流程都要吃透。remove() 的代码很短,但藏着一个只有懂开放定址法的人才看得懂的细节:
// ThreadLocal.remove()
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
// ThreadLocalMap.remove()
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);
// 线性探测找到 key 对应的 Entry
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear(); // ① 清掉弱引用(Reference.clear()),key 变 null
expungeStaleEntry(i); // ② 删除该槽位,并把后续被"挤偏"的 Entry 重新放置
return;
}
}
}
注意第 ② 步:删掉一个元素后,不能就这么留个空位走人。因为线性探测法里,一个 Entry 可能因为冲突被"挤"到了离它真实槽位很远的地方;如果中间删掉一个元素留下空洞,后面按探测链查找的 Entry 就会"断链"——明明存在却找不到。所以 expungeStaleEntry() 删除槽位后,会继续往后扫描,把后面被挤偏的 Entry 重新 hash、挪回更靠前的位置,保证探测链不断。
不能。set(null) 只是把 value 覆盖成 null,Entry 对象还躺在数组里(key 的弱引用还在)。后果:① 占着槽位不还,探测链变长;② 它仍然强引用着 key(弱引用还存在时,只要外部还有强引用,ThreadLocal 就回收不了);③ 下次 set 走到这个槽,还得先处理它。清理的唯一正解是 remove()——既清 value 又清 key,还顺手重排探测链。
- remove() = 定位 Entry →
e.clear()断弱引用 →expungeStaleEntry()删槽 + 重排后续元素; - 线性探测的删除必须重排,否则探测链断裂、元素"丢失";
- 清 ThreadLocal 一律用
remove(),别用set(null)糊弄。
魔法数字 0x61c88647:黄金分割散列
每个 ThreadLocal 实例都有个 threadLocalHashCode,它是这样算出来的:
// 每个 ThreadLocal 实例在构造时固定自己的 hash
private final int threadLocalHashCode = nextHashCode();
// 魔数:2^32 × (√5 − 1) / 2 ≈ 2654435769 = 0x61C88647
private static final int HASH_INCREMENT = 0x61c88647;
// 全局递增计数器:每 new 一个 ThreadLocal,就加上一次魔数
private static AtomicInteger nextHashCode = new AtomicInteger();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
0x61c88647 这个数字不是随手写的,它是黄金分割(Golden Ratio)在哈希里的经典应用:2³² × (√5 − 1) / 2 ≈ 2654435769,十六进制就是 0x61C88647。按这个步长递增得到的序列,称为斐波那契散列(Fibonacci Hashing),它有个神奇的性质:
——后一个 ThreadLocal 的落点与前一个之间,永远隔着一个"黄金角", 像钟表指针按黄金角旋转,扫过的刻度均匀铺满整个表盘。
为什么 ThreadLocal 需要这个?想想场景:一个请求的线程上,可能同时挂着 UserContext、traceId、SimpleDateFormat、Connection……好几个 ThreadLocal,它们几乎同时被 new 出来。如果 hash 是随机的,撞车概率高;而"递增魔数 + 黄金分割"保证这些相邻创建的实例在数组上散得很开,大幅减少线性探测的次数(HashMap 靠扰动函数,ThreadLocal 靠黄金角步长,殊途同归)。
开放定址法:线性探测与 2/3 扩容阈值
ThreadLocalMap 处理哈希冲突的方式和 HashMap 完全不同:HashMap 用链地址法(冲突了挂链表),ThreadLocalMap 用开放定址法(Open Addressing)——冲突了就线性探测(Linear Probing),一个槽位一个槽位地往后找空位:
对比一下两种冲突解决策略,面试被追问"为什么 ThreadLocalMap 不用链表"时可以这样答:
| 维度 | HashMap(链地址法) | ThreadLocalMap(开放定址法) |
|---|---|---|
| 冲突处理 | 链表 / 红黑树挂桶 | 向后探测空槽(线性探测) |
| 装载因子 | 0.75 | 2/3(更保守) |
| 删除 | 直接摘节点 | 不能直接置 null,必须重排探测链 |
| 适用规模 | 大、动态增长、海量 key | 小:一个线程通常只有几个 ThreadLocal |
| 内存 | 每个节点要存 next 指针 | 无链表节点,Entry 更紧凑 |
| 额外好处 | —— | 连续存储,方便整段扫描清理"脏 Entry" |
再看扩容阈值。源码里阈值是这样定的:
private static final int INITIAL_CAPACITY = 16; // 初始容量 16
// 阈值 = 长度的 2/3
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
// set() 末尾:先做启发式清理,size 仍超阈值就 rehash
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
// rehash:先全表清一遍脏 Entry,再决定要不要真正扩容
private void rehash() {
expungeStaleEntries(); // 全表清理脏 Entry(白捡空间)
if (size >= threshold - threshold / 4) // 清理后仍 ≥ 3/4 × 阈值(即 1/2 长度)
resize(); // 才真正扩容翻倍(新阈值 = 新长度 × 2/3)
}
HashMap 用链地址法,桶里挂多长都能存,装载因子可以放宽;开放定址法不行——表越满,冲突概率和探测链长度呈指数恶化,接近满时几乎退化成 O(n) 扫描。所以 ThreadLocalMap 把阈值压到 2/3,宁可多浪费 1/3 空间,也要保证探测链短。而且 2/3 的余量还给"脏 Entry 清理时的重排"留了操作空间。
还有个细节:rehash 时先全表清理脏 Entry 再判断——很多"该扩容"的时机,清完脏 Entry 空间就够了,根本不用扩容。这是内存和 CPU 的双重节约。
弱引用的设计考量:两害相权取其轻
面试官最爱追问:"ThreadLocal 的 Entry 为什么用弱引用做 Key?" 这需要从生命周期管理的角度来分析。先回顾 Java 的四种引用类型:
// 强引用:只要引用存在,GC 绝不回收
Object obj = new Object(); // obj 是强引用
// 软引用:内存不足时才回收(适合做缓存)
SoftReference<Object> soft = new SoftReference<>(obj);
// 弱引用:下次 GC 必定回收(不管内存够不够)
WeakReference<Object> weak = new WeakReference<>(obj);
// 虚引用:随时可回收,无法通过它获取对象(仅用于跟踪回收)
PhantomReference<Object> phantom = new PhantomReference<>(obj, queue);
假设 Entry 的 Key 用强引用会怎样:
| 引用类型 | ThreadLocal 实例能否被 GC? | 后果 |
|---|---|---|
| 强引用(假设) | 不能。Entry.key 强引用 ThreadLocal,只要线程活着,ThreadLocal 就无法回收 | ThreadLocal 对象 + Value 都泄漏,更糟 |
| 弱引用(实际) | 能。当外部不再持有 ThreadLocal 强引用时,下次 GC 会回收 ThreadLocal 实例 | ThreadLocal 本身可回收,但 Value 仍有泄漏风险(见第 11 站) |
弱引用保证了:当你的代码中不再持有 ThreadLocal<UserContext> 这个变量的引用时(比如它是一个局部变量,方法结束后就没了),ThreadLocal 实例本身可以被 GC 回收。
但问题是:Entry 中的 value 字段是强引用。即使 key 变成了 null(ThreadLocal 被回收了),value 依然被 Entry 强引用着。这就是著名的 "key 为 null 的脏条目(Stale Entry)" 问题。
Thread ──强引用──▶ ThreadLocalMap ──强引用──▶ Entry ──强引用──▶ Value(不可回收!)
Entry ──弱引用──▶ ThreadLocal(可回收,但回收后 key=null,Value 孤立)
内存泄漏详解:脏 Entry 与三大条件
ThreadLocal 内存泄漏的完整发生条件,缺一不可:
= Value 永远无法被 GC → 内存持续增长 → OOM
注意一个容易混淆的点:static final 的 ThreadLocal 反而不会泄漏 ThreadLocal 实例本身——因为它一直被类强引用着,key 永远不可能是 null。这种写法下泄漏风险来自哪里?来自value 一直不清理:每个请求 set 一次新对象(比如新的 UserContext),旧对象虽然没被"脏 Entry"困住,但如果你从不 remove,下一次 set 会覆盖引用,旧值才能被回收——看起来问题不大;可一旦 value 里装着集合、大对象,或者线程上绑了多个这种 ThreadLocal,积少成多同样会撑爆堆。更隐蔽的坑是线程复用时的脏数据串号,第 13 站专门讲。
被动清理:JDK 的"保险丝"与它的边界
JDK 设计者当然知道弱引用会留下脏 Entry,所以在 ThreadLocalMap 里埋了被动清理机制——在 get/set/remove/rehash 的过程中"顺手"清理,一共有四层:
| 方法 | 触发时机 | 干什么 |
|---|---|---|
expungeStaleEntry(i) | get/set/remove 命中脏槽时 | 清掉 i 处的脏 Entry,并向后扫描、重排后续被挤偏的 Entry,遇到脏 Entry 一并清掉 |
replaceStaleEntry(...) | set() 撞上脏槽时 | 用新值"顶替"脏 Entry 的位置(就地复用槽位),同时向右清理一段 |
cleanSomeSlots(...) | 每次 set() 成功插入后 | 启发式扫描:从当前位置向后扫 log2(n) 个槽,碰运气清理脏 Entry |
expungeStaleEntries() | rehash() 扩容前 | 全表扫描,把所有脏 Entry 一次性清干净 |
// getEntry 时如果没直接命中,会走 getEntryAfterMiss:
// 线性探测过程中遇到 key=null 的脏 Entry,调用 expungeStaleEntry 清理
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); // 探测 + 顺路清理
}
}
// expungeStaleEntry:清理脏 Entry + 重排后续元素(核心逻辑)
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 1. 解除 value 的强引用 —— value 终于可以被 GC 了
tab[staleSlot].value = null;
tab[staleSlot] = null; // 清掉整个槽位
size--;
// 2. 继续向后扫描:重放被挤偏的 Entry,顺手再清脏
Entry e;
int i;
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) { // 又一个脏 Entry,一并清掉
e.value = null;
tab[i] = null;
size--;
} else {
// 重新 hash,把这个 Entry 挪到更靠前的位置(保持探测链完整)
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;
}
够用:如果你的 ThreadLocal 是 static final(常见写法),它永远不会被 GC,Key 不会变 null,配合 remove() 就没有泄漏。
不够用:如果 ThreadLocal 是局部变量或方法参数中创建的(用完就没人引用了),且线程池中的线程后续不再调用 get/set(比如该业务逻辑不再触发),那脏 Entry 就永远不会被清理——因为所有清理都挂在"下一次操作"上,你不操作,它就不清理。
结论:被动清理是"保险丝",不是"安全网"。你必须主动调用
remove()。真实事故:traceId 串号与用户数据串号
某业务团队的日志平台突然告警:同一个 traceId 同时出现在两个完全不同的用户请求里,排查链路被污染,整个团队花了两个下午定位问题。根因就一句话:MDC.put() 之后忘了 MDC.remove()。
// 错误写法:任务结束没清 MDC(MDC 底层就是 ThreadLocal)
public void handle(Request req) {
MDC.put("traceId", UUID.randomUUID().toString()); // 请求入口放 traceId
asyncExecutor.submit(() -> {
// 线程池线程执行 —— 上个任务留下的 traceId 还在!
log.info("处理请求"); // 日志里带着"别人的" traceId
});
// 忘了在 finally 里 MDC.remove() → traceId 残留在当前线程
// 下一个请求复用这个线程 → 新 traceId 覆盖失败?不,是读到了旧值……
}
// 正确写法:finally 里清理,配套第 14 站的模板
try {
MDC.put("traceId", UUID.randomUUID().toString());
// ... 业务逻辑 ...
} finally {
MDC.remove(); // 或者 MDC.clear()
}
更严重的版本:一个网关服务用 ThreadLocal 存当前登录用户,请求处理完没 remove。线程池的同一个线程先后处理用户 A、用户 B 的请求——用户 B 可能在某个分支里读到用户 A 的上下文,轻则数据错乱,重则越权访问,这是安全事故级别的问题。这类事故的共性特征:偶发、难以复现、只在并发量上来时才出现,因为是否串号取决于"线程是否恰好被复用"。
- ThreadLocal 的隔离是"线程级"的,不是"任务级"的——线程池复用让"上一个任务"和"下一个任务"共享同一个线程的存储;
- 只要 set 了 ThreadLocal,就要在任务结束时 remove,两者必须成对出现;
- 排查线索:Heap Dump 里大量同类型业务对象(内存泄漏),或日志 traceId / 用户 ID 对不上(串号)。
方案一:finally + remove() 的正确模板
永远在 finally 中调用 remove()——这是最基本也是最重要的规则。无论业务代码是正常返回还是抛异常,finally 保证清理一定执行:
public class UserContextFilter implements Filter {
// ① 静态字段持有:避免 ThreadLocal 实例被 GC(见第 17 站)
private static final ThreadLocal<UserContext> USER_HOLDER = new ThreadLocal<>();
@Override
public void doFilter(ServletRequest req, ServletResponse resp,
FilterChain chain) throws IOException, ServletException {
try {
UserContext ctx = parseUser(req);
USER_HOLDER.set(ctx); // ② 设置上下文
chain.doFilter(req, resp); // ③ 业务处理(Controller → Service → DAO 都能拿到)
} finally {
USER_HOLDER.remove(); // ④ 必须在 finally 中清理!
// 不能放在 try 里(异常会跳过);不能漏;线程池场景更不能省
}
}
}
- 必须 static final:防止 ThreadLocal 实例本身被 GC,key 变 null;
- 必须 finally + remove():异常也拦不住清理;
- 必须成对出现:set 和 remove 中间隔的代码越少越好,最好封装在 Filter/Interceptor/AOP 里统一处理。
方案二:InheritableThreadLocal 与它的局限
子线程默认读不到父线程的 ThreadLocal(第 4 站 Demo 里验证过)。如果业务上需要"父线程的值传给子线程",用 InheritableThreadLocal:
ThreadLocal<String> tl = new InheritableThreadLocal<>();
tl.set("main-thread-value");
new Thread(() -> {
// 子线程可以读到父线程的值!
System.out.println(tl.get()); // 输出: main-thread-value
}).start();
// 原理:Thread.init() 时,如果父线程的 inheritableThreadLocals 不为空,
// 会把整张 map 浅拷贝给子线程(值对象共享引用)
对应源码,子线程创建那一刻发生了"搬家":
// 创建子线程时(Thread.init()),父线程的 inheritableThreadLocals 被拷贝
if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
// 拷贝是"浅拷贝":每个 Entry 的 value 走 childValue() 转换,默认原样返回
// 注意:value 对象本身是共享的 —— 子线程改了对象内部状态,父线程也看得到
关键:继承发生在创建子线程的那一刻,且只发生一次。线程池里的线程是预先创建并复用的,不会为每个任务重新创建线程。所以:
1. 任务 A 设置了 ThreadLocal 值,线程 T1 执行完后值还留在 T1 的 Map 中,任务 B 复用 T1 会读到 A 的残留(脏数据);
2. 即使你用 InheritableThreadLocal,父线程的修改也不会同步到已经创建好的池线程里——继承是"一次性快照",不是"持续透传"。
结论:父子线程直连场景用 InheritableThreadLocal;线程池场景必须换方案三——TransmittableThreadLocal。
方案三:TransmittableThreadLocal(TTL)
阿里开源的 TransmittableThreadLocal(TTL,Maven 坐标 com.alibaba:transmittable-thread-local)专门解决线程池场景的上下文透传。它的核心是三步机制:
② replay(回放):任务真正执行前,把快照值"放"到执行线程上
③ restore(恢复):任务执行完,还原执行线程原来的值(防串号)
// Maven: com.alibaba:transmittable-thread-local:2.14.x
// 1. 使用 TTL 替代 ThreadLocal
TransmittableThreadLocal<UserContext> ctx = new TransmittableThreadLocal<>();
// 2. 包装线程池(关键步骤!)
ExecutorService pool = TtlExecutors.getTtlExecutorService(
Executors.newFixedThreadPool(10)
);
// 3. 提交任务时,TTL 自动完成 capture → replay → restore
ctx.set(new UserContext("user-123"));
pool.submit(() -> {
// 线程池线程能正确读到提交时的值,任务结束自动还原,不会串号
UserContext user = ctx.get(); // user-123
processRequest(user);
});
/* ─── 配合 Spring + 线程池的完整配置 ─── */
@Configuration
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-ttl-");
// 用 TTL 装饰任务,确保 @Async 方法能正确传递上下文
executor.setTaskDecorator(new TtlTaskDecorator());
executor.initialize();
return executor;
}
}
/* ─── 自定义 TaskDecorator(Spring 集成) ─── */
public class TtlTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// TtlRunnable 会自动 capture/replay/restore
return TtlRunnable.get(runnable);
}
}
/* ─── 使用方式 ─── */
@Service
public class OrderService {
@Async("taskExecutor")
public CompletableFuture<Void> processAsync() {
// 即使在不同线程执行,也能拿到正确的用户上下文
UserContext user = UserContextHolder.get();
log.info("processing order for user: {}", user.getUserId());
return CompletableFuture.completedFuture(null);
}
}
① 只是"父子线程直连"、不经过线程池 → InheritableThreadLocal 够用;② 涉及线程池、异步、MQ 消费、分布式框架 → TTL;③ 三种方案共用时注意:TTL 继承自 InheritableThreadLocal,两者可以协同(TTL 会透传 TTL 值,ITL 值走 ITL 的继承逻辑)。生产里统一用 TTL 最省心,避免混用导致"有些值传过去了、有些没传"。
生产实践清单与高频踩坑表
- 始终用 static final 声明 ThreadLocal,避免 ThreadLocal 实例本身被 GC 导致 key 变 null;
- 始终在 finally 中调用 remove(),线程池场景下尤其不能省;
- 不要用
ThreadLocal.set(null)代替remove()——set(null) 只是把 value 设为 null,Entry 还在; - 优先使用
withInitial()工厂方法,确保首次 get() 有合理的默认值; - 线程池场景需要跨线程传递上下文时,使用 TransmittableThreadLocal;
- value 里别放大对象:每个线程一份副本,线程数 × 对象大小就是真实内存占用。
| 踩坑场景 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 忘了 remove() | 线程池 OOM,Heap 中大量同类型业务对象 | Value 被 Entry 强引用,线程不死不回收 | finally 中 remove() |
| 线程池复用 + 未清理 | traceId 串号、用户上下文串号(数据错乱/越权) | 上一个任务的值残留在线程的 Map 里 | 任务结束 remove() / 用 TTL 自动 restore |
| ThreadLocal 声明为局部变量 | get() 返回 null 或读到脏数据 | ThreadLocal 实例被 GC,key 变 null | 声明为 static final |
| 线程池 + InheritableThreadLocal | 子任务读不到父线程最新值 | 线程复用,不是每次新建;继承只发生一次 | 使用 TTL |
| SimpleDateFormat 共享 | 日期格式化结果错乱 | SDF 内部 Calendar 不是线程安全的 | ThreadLocal 包装或换 DateTimeFormatter |
| ThreadLocal 存大对象 | 内存占用异常 | 每个线程都有一份拷贝(N 线程 = N 份) | 减小对象体积,或考虑其他方案 |
面试高频追问:五个必问点
追问 1:ThreadLocalMap 为什么不用 HashMap 的链地址法?
三层原因:① 规模小——一个线程通常只挂几个 ThreadLocal,开放定址在小表上探测成本低,且省掉链表节点的内存;② 弱引用 Entry 需要"扫描式"清理——脏 Entry 的清理要向后连续扫描重排,连续数组比链表更适合这种整段操作;③ 开放定址配合黄金分割散列,冲突率天然低。反过来,HashMap 面向海量 key,开放定址在接近满载时探测链会指数恶化,所以必须用链地址法。
追问 2:为什么 ThreadLocal 必须是 static final?
如果是局部变量:方法执行完,外部强引用消失,ThreadLocal 实例被 GC,Entry 的 key 变 null → 脏 Entry,value 泄漏。static final 让 ThreadLocal 实例永生,key 永远有效,泄漏风险只剩"value 没清理"这一条,可控得多。这也是为什么规范把它列为强制项。
追问 3:ThreadLocal 和 synchronized 的区别?
见第 2 站的对比表,一句话版本:synchronized 是"多个线程互斥访问一份数据"(时间换安全),ThreadLocal 是"每个线程各持一份数据"(空间换安全)。前者解决"竞争",后者解决"共享"。两者互补,不是替代关系——比如连接池场景:池本身要 synchronized 保证并发安全,而"当前线程拿哪个连接"用 ThreadLocal 记录。
追问 4:Netty 的 FastThreadLocal 为什么更快?
Netty 的 FastThreadLocal 换了个思路:放弃哈希,直接数组索引。每个 FastThreadLocal 构造时拿一个全局递增的 index,线程上的 InternalThreadLocalMap 本质是一个数组,set/get 就是 indexedVariables[index] = value——O(1)、无哈希冲突、无线性探测,还支持线程退出时自动清理(FastThreadLocalRunnable)。代价是每个线程的数组按最大 index 分配(懒扩容),适合 Netty 这种"每个 handler 一个 FastThreadLocal、高频访问"的场景。面试提到这个,说明你见过高并发框架的取舍。
追问 5:remove() 和 set(null) 有什么区别?
remove() 走 e.clear() + expungeStaleEntry():断弱引用、删槽位、重排探测链,彻底释放;set(null) 只是把 value 覆盖成 null,Entry 还在、key 的弱引用还在、槽位还占着。清理只能靠 remove(),这是阿里规约和 JDK 注释都强调的点。
本系列追问地图
- 原理层:存储结构(第 5 站)→ set/get/remove 流程(第 6、7 站)→ 哈希与冲突(第 8、9 站);
- 内存层:弱引用设计(第 10 站)→ 泄漏三条件(第 11 站)→ 被动清理边界(第 12 站);
- 生产层:事故复盘(第 1、13 站)→ 三种透传方案(第 14–16 站)→ 使用清单(第 17 站)。
这一篇你掌握了什么
核心知识点回顾
- 本质:线程本地存储(Thread-Local Storage)——同一把钥匙(ThreadLocal 实例),每个线程开自己那扇柜门(ThreadLocalMap),互不相干;
- 结构:数据存在
Thread.threadLocals(ThreadLocalMap)里,key 是 ThreadLocal 实例的弱引用,value 是强引用;map 挂在线程上,天然无锁; - 流程:set/get 都是"当前线程取 map → 以 this 为 key 存取";remove() = 定位 →
e.clear()→ 删槽 + 重排探测链; - 哈希:
threadLocalHashCode按黄金分割魔数0x61c88647递增(斐波那契散列),相邻实例散列均匀; - 冲突:开放定址法(Open Addressing)线性探测,删除必须重排;扩容阈值 2/3,rehash 先清脏再扩容;
- 泄漏:key 被 GC 后 value 仍被强引用 + 线程池线程存活 + 无后续操作触发清理 → value 泄漏;被动清理(expungeStaleEntry 系列)只是"保险丝",必须主动 remove();
- 透传:父子线程直连用
InheritableThreadLocal(创建时浅拷贝一次);线程池场景用TransmittableThreadLocal(capture → replay → restore); - 最佳实践:static final 声明 + finally 中 remove() + 用 withInitial() + 线程池场景用 TTL。
"ThreadLocal 是线程本地存储:每个线程内部有一张 ThreadLocalMap,以 ThreadLocal 实例为 key 存值,天然线程隔离、无需加锁。Entry 的 key 用弱引用,是为了外部不再持有 ThreadLocal 时实例本身可被 GC;但 value 是强引用,线程池线程不销毁、又不 remove() 时,就会出现 key 为 null 而 value 无法回收的内存泄漏,还会造成线程复用下的脏数据串号。ThreadLocalMap 用开放定址法解决冲突、阈值 2/3,get/set 时会被动清理脏 Entry,但不可靠。所以最佳实践三条:static final 声明、finally 里 remove()、线程池透传用 TransmittableThreadLocal。"
一句话总结
ThreadLocal = 用空间换隔离(每线程一份副本),用弱引用换可控(key 可回收),用主动 remove() 换安全(防泄漏防串号)。记住一件事:线程隔离是"线程级"的,不是"任务级"的——只要线程会被复用,清理就永远是你的责任。
Comments · 评论