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 使用
  • inheritableThreadLocalsInheritableThreadLocal 使用,子线程创建时可从父线程拷贝

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.
 */

对象关系如下:

image-20260623211231782

ThreadLocal 是「钥匙」,Thread 上的 map 是「储物柜」,每个线程各有一组柜子,互不干扰。

2.2 ThreadLocalMap 与 Entry 设计

ThreadLocalMapThreadLocal 的静态内部类,对外不可见,仅供 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,第一次 setget 未命中时才会 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
    end

2.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
    end

2.3.4 线程退出时的兜底

Thread 正常退出时,Thread.exit() 会将 threadLocalsinheritableThreadLocals 整体置为 null,随 Thread 对象一起释放。短生命周期线程上不调用 remove() 通常问题不大;线程池 Worker 长期存活、map 不会被整体丢弃,必须依赖 remove() 或 TTL/ScopedValue 等任务级方案——这一点在第三节展开。

3 ThreadLocal 为什么会内存泄漏

3.1 引用链与 stale entry

ThreadLocal 导致内存泄漏这句话常被误读为 ThreadLocal 对象本身泄漏。从引用链看,真正的问题是:Entry 对 value 的强引用 + 持有 map 的线程长期存活,和 ThreadLocal 对象是否是弱引用没有关系。

引用关系:

image-20260623211905792

当 ThreadLocal 被回收(例如定义为局部变量、或类卸载)后:

  1. Entry 的 WeakReference key 变为 null,slot 成为 stale entry
  2. value 仍被 Entry 强引用,无法回收
  3. stale entry 不会立即清除——JDK 未使用 ReferenceQueue,只在 setremoverehashgetEntryAfterMiss 时惰性调用 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 的快照
replayWorker 执行任务前将快照写入 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 的三个结构性缺陷:

  1. Unconstrained mutability(不受约束的可变性):任意能 get 的代码都可以 set,数据可在调用链任意方向流动,难以追踪谁改了共享状态。
  2. Unbounded lifetime(无界生命周期)set 之后值保留到线程结束或显式 remove;线程池下忘记 remove 导致泄漏与串号(第三节已述)。
  3. 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());

嵌套绑定也受控:barwhere(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();
    }
});

限制同样存在:普通 ForkJoinPoolExecutors非结构化线程创建无法保证子线程在父作用域结束前退出,因此不支持 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。