1 问题背景:为什么需要线程本地变量
在 Web 服务、链路追踪、日志框架等场景中,经常需要在一次请求的调用链上传递「隐式参数」:traceId、用户 ID、事务上下文、Locale 等。如果把这些参数逐层作为方法参数传递,中间层往往只是透传,既不灵活,也会让方法签名膨胀。
ThreadLocal 自 JDK 1.2 引入,提供了一种替代方案:每个线程持有独立副本,同一线程内的任意方法都可以通过 get() 读取,无需显式传参。典型用法包括 SLF4J MDC、SimpleDateFormat 线程隔离、InheritableThreadLocal 向子线程传递父线程上下文等。
这条演进主线可以概括为:
- ThreadLocal:解决同一线程内的上下文共享
- TransmittableThreadLocal(TTL):解决线程池场景下「提交任务时 → 执行任务时」的上下文传递
- ScopedValue(JDK 25 定稿):解决 ThreadLocal 在可变性、生命周期、继承成本上的结构性缺陷
下文按这条主线,从 JDK 8 源码出发,逐层展开机制与取舍。
2 ThreadLocal 的底层结构
2.1 三层对象关系
这里真正需要关注的是:ThreadLocal 实例本身不存储业务值。值存放在当前 Thread 对象的 ThreadLocalMap 中,ThreadLocal 只是 lookup 时使用的 key。
Thread 上有两个 map 字段:
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
threadLocals:普通ThreadLocal使用inheritableThreadLocals:InheritableThreadLocal使用,子线程创建时可从父线程拷贝
JDK 源码注释写得很清楚:
/**
* ThreadLocals rely on per-thread linear-probe hash maps attached
* to each thread (Thread.threadLocals and inheritableThreadLocals).
* The ThreadLocal objects act as keys, searched via threadLocalHashCode.
*/
对象关系如下:

ThreadLocal 是「钥匙」,Thread 上的 map 是「储物柜」,每个线程各有一组柜子,互不干扰。
2.2 ThreadLocalMap 与 Entry 设计
ThreadLocalMap 是 ThreadLocal 的静态内部类,对外不可见,仅供 Thread 持有。它是一个针对 ThreadLocal 场景定制的开放寻址哈希表(linear-probe hash map),初始容量 16,负载因子约 2/3 时扩容。
Entry 的设计是理解后续内存问题的关键:
// ThreadLocalMap.Entry
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key:对 ThreadLocal 的弱引用
value = v; // value:强引用
}
}
- key(ThreadLocal):弱引用。ThreadLocal 实例被 GC 回收后,key 变为 null,该 slot 成为 stale entry
- value(业务对象):强引用。只要 Entry 还在 map 里,value 就不会被回收
哈希冲突采用开放寻址:冲突时按 (i + 1) % len 向后探测。ThreadLocal 的 hash 不是 Object.hashCode(),而是全局递增的 threadLocalHashCode,步长为 0x61c88647(与黄金比例相关),使连续创建的 ThreadLocal 在 2 的幂次方表长下分布更均匀。
InheritableThreadLocal 的差异仅在于 map 的挂载点:
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
子线程创建时,若 inheritThreadLocals == true,会拷贝父线程的 inheritable map:
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
拷贝发生在 new Thread() 时,与线程池无关——这一点在第四节会再次用到。
2.3 get / set / remove 调用链
三条 API 的公共入口模式相同:先取当前线程,再取该线程上的 map(getMap 默认读 t.threadLocals)。map 本身是懒创建的——线程刚启动时 threadLocals == null,第一次 set 或 get 未命中时才会 createMap。
2.3.1 get
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
return (T) e.value;
}
}
return setInitialValue(); // 未命中:initialValue() 后写入 map
}
map 层 getEntry 先按 threadLocalHashCode & (len - 1) 定位 slot,命中则直接返回;否则进入 getEntryAfterMiss 沿开放寻址链向后探测。探测过程中若发现 key 已为 null 的 stale entry,会顺带调用 expungeStaleEntry 清理——读操作也会触发部分 GC 善后。
未命中时 setInitialValue() 调用子类可覆写的 initialValue()(默认返回 null),再通过 map.set(this, value) 或 createMap(t, value) 写入。
sequenceDiagram
participant App as 业务代码
participant TL as ThreadLocal
participant T as Thread
participant Map as ThreadLocalMap
App->>TL: get()
TL->>T: currentThread()
TL->>Map: getMap(t)
alt map 为 null
TL->>TL: setInitialValue()
TL->>T: createMap(t, value)
else map 存在
Map->>Map: getEntry(this)
alt 直接命中或探测命中
Map-->>TL: e.value
else 未命中
TL->>TL: setInitialValue()
TL->>Map: map.set(this, value)
end
end
TL-->>App: 返回值2.3.2 set
ThreadLocal.set 本身没有额外逻辑,直接把 (this, value) 交给 map:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value); // 首次 set:在 Thread 上新建 ThreadLocalMap
}
}
ThreadLocalMap.set 从 hash slot 起沿探测链扫描,逻辑分三条分支:
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; // 分支 1:key 已存在,原地更新 value
return;
}
if (k == null) {
replaceStaleEntry(key, value, i); // 分支 2:遇到 stale entry,替换并清理整段 run
return;
}
}
tab[i] = new Entry(key, value); // 分支 3:探测到空 slot,插入新 Entry
if (++size >= threshold)
rehash(); // 超阈值则 expungeStaleEntries + 可能扩容
}
与 get 不同,set 刻意不做 fast path——源码注释说明,新建 entry 与覆盖旧值同样常见,fast path 反而多数时候会 miss。
sequenceDiagram
participant App as 业务代码
participant TL as ThreadLocal
participant T as Thread
participant Map as ThreadLocalMap
App->>TL: set(value)
TL->>T: currentThread()
TL->>Map: getMap(t)
alt map 为 null
TL->>T: createMap(t, value)
else map 存在
Map->>Map: set(this, value)
alt key 已存在
Map->>Map: e.value = value
else 遇到 stale entry
Map->>Map: replaceStaleEntry
else 空 slot
Map->>Map: new Entry + 必要时 rehash
end
end2.3.3 remove
对外 API 同样很薄,核心在 map 内部的清理与 rehash:
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
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(); // 清空 WeakReference key
expungeStaleEntry(i); // 置 null value、减 size、rehash 后续 entry
return;
}
}
}
expungeStaleEntry 做三件事:当前 slot 的 value 置 null 并删除 entry;size--;对后续非空 slot 按新 hash 位置重新安置(避免删除后探测链断裂)。这是 ThreadLocal 主动释放 value 的正确路径,也是第三节讨论内存泄漏时 remove() 为何重要的直接依据。
sequenceDiagram
participant App as 业务代码
participant TL as ThreadLocal
participant Map as ThreadLocalMap
App->>TL: remove()
TL->>Map: getMap(currentThread)
alt map 为 null
Note over TL: 无操作
else map 存在
Map->>Map: 沿探测链查找 key
alt 找到
Map->>Map: e.clear()
Map->>Map: expungeStaleEntry(i)
else 未找到
Note over Map: 静默返回
end
end2.3.4 线程退出时的兜底
Thread 正常退出时,Thread.exit() 会将 threadLocals 和 inheritableThreadLocals 整体置为 null,随 Thread 对象一起释放。短生命周期线程上不调用 remove() 通常问题不大;线程池 Worker 长期存活、map 不会被整体丢弃,必须依赖 remove() 或 TTL/ScopedValue 等任务级方案——这一点在第三节展开。
3 ThreadLocal 为什么会内存泄漏
3.1 引用链与 stale entry
ThreadLocal 导致内存泄漏这句话常被误读为 ThreadLocal 对象本身泄漏。从引用链看,真正的问题是:Entry 对 value 的强引用 + 持有 map 的线程长期存活,和 ThreadLocal 对象是否是弱引用没有关系。
引用关系:

当 ThreadLocal 被回收(例如定义为局部变量、或类卸载)后:
- Entry 的 WeakReference key 变为 null,slot 成为 stale entry
- value 仍被 Entry 强引用,无法回收
- stale entry 不会立即清除——JDK 未使用 ReferenceQueue,只在
set、remove、rehash或getEntryAfterMiss时惰性调用expungeStaleEntry
// expungeStaleEntry 核心:清 value、清 slot、rehash 后续 entry
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// ... 对后续 entry 重新定位
若 ThreadLocal 是 static final(常见写法),key 永远不会被 GC,stale entry 路径反而不触发——此时泄漏完全取决于 是否 remove 以及线程是否长寿。
3.2 线程池场景下的两类问题
线程池 Worker 线程由 ThreadPoolExecutor 预先创建并反复复用,生命周期与 JVM 进程相当。在此场景下,ThreadLocal 会带来两类问题:
(1)内存泄漏
任务执行时 context.set(largeObject),任务结束后未 remove()。value 一直挂在 Worker 的 threadLocals 上,多个任务累积,Old Gen 压力持续增大。ThreadLocal 的 key 是 static 时,连 stale entry 清理的契机都没有。
(2)跨任务污染(比泄漏更常见)
任务 A 设置了 traceId = "A",执行完毕未 remove。任务 B 在同一线程上运行,若未重新 set,可能读到 "A",导致 MDC 串号、权限上下文错乱。这是生产上更频繁出现的故障形态。
根因在于:ThreadLocal 的生命周期绑定的是线程,而业务期望的往往是任务或请求的生命周期。JEP 506 将这一缺陷归纳为 Unbounded lifetime——set 之后,值默认保留到线程结束或显式 remove。
3.3 正确用法与边界
工程上的最低要求:
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
public void handleRequest() {
CTX.set(buildContext());
try {
doWork();
} finally {
CTX.remove(); // 与 set 对称,在线程池场景下不可省略
}
}
补充几条边界判断:
| 场景 | 建议 |
|---|---|
短生命周期线程(new Thread 处理单次请求) | remove() 仍推荐,但线程退出会自动清 map |
| 线程池异步 | 必须 remove(),或改用 TTL / ScopedValue |
| 父子线程传递 | InheritableThreadLocal 仅在 new Thread 时拷贝,对线程池无效 |
| static ThreadLocal + 大对象 | 高风险组合,务必 try/finally remove |
4 TTL 解决了什么问题,思路是什么
4.1 InheritableThreadLocal 的局限
InheritableThreadLocal 继承自 ThreadLocal,在子线程构造时把父线程 inheritable map 中的值拷贝一份:
// 拷贝时机:new Thread(...) 内部
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
这对「父线程 new Thread 启动子线程」有效。但线程池的 Worker 在池初始化时就已创建,与提交任务的业务线程之间没有父子创建关系。业务线程在 T1 时刻 set(traceId) 并 executor.submit(task) 时,Worker 的 inheritableThreadLocals 仍是创建时的空 map 或旧值,提交时刻的上下文无法到达 Worker。
应用真正需要的是:任务提交给线程池时的 ThreadLocal 值 → 任务在 Worker 上执行时可用。InheritableThreadLocal 的模型与这一需求错位。
4.2 CRR 模型(capture / replay / restore)
Alibaba 开源的 TransmittableThreadLocal(TTL) 在 2.x 分支通过 CRR 三步解决上述问题:
| 步骤 | 时机 | 作用 |
|---|---|---|
| capture | 任务提交 / 包装时 | 抓取当前线程所有 TTL 及已注册 ThreadLocal 的快照 |
| replay | Worker 执行任务前 | 将快照写入 Worker 线程,并 backup Worker 原有值 |
| restore | 任务 finally | 恢复 Worker 执行前的状态,避免污染后续任务 |
Transmitter.capture() 遍历 transmitteeSet,对每个 TTL 调用 copyValue() 生成快照:
// TransmittableThreadLocal.Transmitter(TTL 2.x 简化)
public static Object capture() {
HashMap<Transmittee<Object, Object>, Object> transmittee2Value = ...;
for (Transmittee<Object, Object> transmittee : transmitteeSet) {
transmittee2Value.put(transmittee, transmittee.capture());
}
return new Snapshot(transmittee2Value);
}
replay 把 captured 值 set 进当前线程,同时 backup 原有值;restore 在 finally 中还原 backup。TTL 还允许通过 registerThreadLocal 让普通 ThreadLocal 参与传递,并通过 copy() / TtlCopier 定制拷贝语义(引用传递 vs 深拷贝)。
TTL 继承 InheritableThreadLocal,但官方明确建议:在线程池场景 disable inheritable,避免 Worker 创建时意外继承无关上下文。
4.3 TtlRunnable 如何把 CRR 接到线程池
框架集成时,业务侧通常不直接调用 Transmitter,而是包装 Runnable/Callable。TtlRunnable 把 CRR 嵌入任务生命周期:
private TtlRunnable(Runnable runnable, boolean releaseTtlValueReferenceAfterRun) {
this.capturedRef = new AtomicReference<>(capture()); // 构造时 capture
this.runnable = runnable;
}
@Override
public void run() {
final Object captured = capturedRef.get();
final Object backup = replay(captured); // 执行前 replay
try {
runnable.run();
} finally {
restore(backup); // 执行后 restore
}
}
关键点:capture 发生在 TtlRunnable 构造时,即 executor.submit(TtlRunnable.get(task)) 的提交线程上,冻结的是提交时刻的上下文,而非 Worker 上次任务残留的值。
sequenceDiagram
participant Parent as 业务线程
participant Ttl as TtlRunnable
participant Pool as 线程池队列
participant Worker as Worker 线程
Parent->>Parent: context.set("trace-123")
Parent->>Ttl: TtlRunnable.get(task)
Note over Ttl: 构造时 capture 快照
Parent->>Pool: submit(ttlRunnable)
Pool->>Worker: 调度执行 run()
Worker->>Worker: replay(captured)
Note over Worker: context.get() → "trace-123"
Worker->>Worker: 执行业务逻辑
Worker->>Worker: restore(backup)
Note over Worker: 恢复 Worker 原有上下文对比 ThreadLocal 在线程池上的用法:TTL 把上下文生命周期对齐到单次任务,通过 restore 保证 Worker neutral,无需业务在每个任务里手动 remove——当然,若混用普通 ThreadLocal 而不走 TTL 包装,仍需自行 remove。
5 JDK 25 的 ScopedValue
5.1 JEP 506 要解决的三个 ThreadLocal 缺陷
JEP 506: Scoped Values 在 JDK 25 正式定稿(此前在 JDK 21–24 经历 Preview/Incubator)。JEP 归纳了 ThreadLocal 的三个结构性缺陷:
- Unconstrained mutability(不受约束的可变性):任意能
get的代码都可以set,数据可在调用链任意方向流动,难以追踪谁改了共享状态。 - Unbounded lifetime(无界生命周期):
set之后值保留到线程结束或显式remove;线程池下忘记 remove 导致泄漏与串号(第三节已述)。 - Expensive inheritance(昂贵的继承):
InheritableThreadLocal为每个子线程复制父线程全部 ITL 条目;大量虚拟线程场景下内存开销显著,且子线程很少需要 mutate 继承值。
ScopedValue 的设计目标不是「删掉 ThreadLocal」,而是为单向、不可变、有界的上下文传递提供更低成本的原语,尤其配合虚拟线程(JEP 444)与结构化并发(JEP 505)。
5.2 动态作用域与 API 用法
ScopedValue 引入的是动态作用域(dynamic scope):绑定在 run / call 的 lambda 执行期间有效,lambda 直接或间接调用的任意方法都可 get,作用域结束自动解绑。没有 set(),只有 where(...).run(...) 建立绑定。
private static final ScopedValue<String> TRACE = ScopedValue.newInstance();
void handleRequest(String traceId) {
ScopedValue.where(TRACE, traceId).run(() -> {
service.process(); // 深层可 TRACE.get()
dao.query(); // 同样可读
});
// run 结束,绑定销毁;此处 TRACE.get() 会抛 NoSuchElementException
}
对比 ThreadLocal 写法:
// ThreadLocal:隐式 set,生命周期靠自觉 remove
CTX.set(context);
try {
service.process();
} finally {
CTX.remove();
}
// ScopedValue:绑定边界由语法结构表达,自动清理
ScopedValue.where(CTX, context).run(() -> service.process());
嵌套绑定也受控:bar 内 where(X, "goodbye").run(() -> baz()) 只在 baz 的子作用域生效,baz 返回后 bar 仍读到外层的 "hello"。
// 外层 bar 作用域:X = "hello"
bar {
// 临时绑定:在本次 run 整个闭包内,X 临时变成 "goodbye"
ScopedValue.where(X, "goodbye").run(() -> {
baz(); // baz 是 run 的子作用域,baz 内部读到的 X 都是 "goodbye"
});
// run 已经执行完退出,临时绑定失效
// 这里 bar 作用域重新读到最开始外层的 X = "hello"
}
JDK 25 相对 Preview 的一处 API 变更:ScopedValue.orElse(null) 改为传入 null 时抛 NullPointerException。
5.3 与 StructuredTaskScope 的继承
ScopedValue 与 StructuredTaskScope.fork 集成:父线程在 where(...).run(...) 内 fork 子任务,子线程可直接 get 父线程绑定的值。
与 InheritableThreadLocal 的关键差异:不可变绑定可被多线程共享引用,无需为每个子线程复制一份。JEP 506 指出,在百万级虚拟线程场景下,这显著降低内存占用与同步成本。
private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();
ScopedValue.where(CONTEXT, "duke").run(() -> {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> childTask1()); // childTask1 内 CONTEXT.get() → "duke"
scope.fork(() -> childTask2());
scope.join();
}
});
限制同样存在:普通 ForkJoinPool、Executors 等非结构化线程创建无法保证子线程在父作用域结束前退出,因此不支持 ScopedValue 继承。这是设计上的有意取舍——作用域边界必须可推理。
5.4 迁移边界:什么时候仍该用 ThreadLocal
JEP 506 明确:不要求迁移所有 ThreadLocal,也不 deprecate ThreadLocal API。
| 适合迁移到 ScopedValue | 仍适合 ThreadLocal |
|---|---|
| 请求 ID、鉴权上下文等单向传递 | callee 通过 TL 向 caller 回写(双向通信) |
| 框架 hidden parameter | 无结构异步(任意线程池 submit) |
| 配合 StructuredTaskScope 的并发 | 可变对象 per-thread 缓存(如旧版 SimpleDateFormat) |
| 虚拟线程高并发 | 存量系统 + 线程池 + TTL 已稳定运行 |
与 TTL 的分工:ScopedValue 解决「有界 + 不可变 + 结构化并发继承」;TTL 解决「现有线程池 + 可变上下文 + 无需改造为 StructuredTaskScope」的传递问题。二者并非互斥替代,而是针对不同约束的工具。
6 总结
ThreadLocal 的本质是挂在 Thread 上的 per-thread 哈希表:ThreadLocal 实例作 key(弱引用),业务对象作 value(强引用)。同线程内读写高效,但生命周期默认绑定线程而非任务。
内存泄漏与串号的共同根因是 Entry 对 value 的强引用叠加 Worker 线程长寿;remove() 是在线程池场景下的最低防线。stale entry 机制只能在线程 Local 被 GC 后清理 key,对 static ThreadLocal 帮助有限。
TTL 用 capture → replay → restore 把上下文对齐到任务粒度,TtlRunnable 在提交时刻 capture、Worker 执行时 replay、finally restore,补齐 InheritableThreadLocal 在线程池上的空白。
ScopedValue 在 JDK 25 定稿,以动态作用域绑定替代无界 mutable ThreadLocal,配合不可变共享与 StructuredTaskScope 继承,面向虚拟线程时代的高并发上下文传递。单向 hidden parameter 场景值得评估迁移;双向、可变、非结构化异步场景仍应保留 ThreadLocal 或 TTL。