文章目录

一、开场:会员已经离开,柜子里为什么还留着东西

晚上十点,健身房准备打烊。

前台小李把最后一批会员送走,正准备关闭储物区,却发现一个奇怪的问题:明明 1001 号会员已经离店,他放在私人储物柜里的毛巾和水杯还没有清走。更麻烦的是,第二天早上新会员拿到了同一把柜门钥匙,打开以后看到了上一位会员留下的东西。

这件事放到 Java 线程池里,几乎就是一次典型的 ThreadLocal 数据串线:

  • 健身房会员请求进入系统;
  • 工作线程像值班员工,负责处理会员业务;
  • ThreadLocal 像一套“按当前员工定位私人储物柜”的规则;
  • 会员上下文是柜子里存放的物品;
  • 线程池会反复使用同一名员工;
  • 如果上一项任务结束后没有清空柜子,下一项任务就可能看到旧数据。

先把生活角色和 Java 概念对上号:

健身房场景 Java ThreadLocal
值班员工 当前线程 Thread
员工自己的储物柜登记表 ThreadLocalMap
柜门钥匙 ThreadLocal 对象
柜子中的会员信息 ThreadLocal 对应的 Value
根据钥匙找到柜子 get()
放入会员上下文 set(value)
任务结束后清空柜子 remove()
员工被长期排班、反复接待会员 线程池复用工作线程

Java ThreadLocal 健身房私人储物柜主题封面

本文以 Java SE 25 与 OpenJDK JDK 25 源码为背景。ThreadLocal 的公开 API 语义比较稳定,但 ThreadLocalMap、探测和清理策略属于 JDK 内部实现,升级时应以目标版本源码为准。

二、ThreadLocal 到底解决什么问题

ThreadLocal 解决的不是“多个线程怎样安全地修改同一个值”,而是“每个线程怎样拥有自己的那一份值”。

假设我们想在一次请求的调用链里保存会员信息,最直接的错误写法是使用一个静态共享变量:

public final class MemberHolder {
    private static MemberContext current;

    public static void set(MemberContext member) {
        current = member;
    }

    public static MemberContext get() {
        return current;
    }
}

线程 A 刚写入会员小李,线程 B 又写入会员小周,A 后续读取时很可能已经变成小周。给变量加 volatile 只能提高可见性,不能让两个线程各自保存一份。给读写加 synchronized 可以让访问排队,却仍然只有一个共享槽位。

ThreadLocal 换了一种思路:

private static final ThreadLocal<MemberContext> CONTEXT =
        new ThreadLocal<>();

CONTEXT.set(new MemberContext("M1001", "小李"));
MemberContext current = CONTEXT.get();
CONTEXT.remove();

线程 A 调用 CONTEXT.set(小李),线程 B 调用 CONTEXT.set(小周)。它们虽然使用同一个 CONTEXT 对象,实际写入的却是各自线程内部的映射表,所以互不覆盖。

健身房储物柜与 ThreadLocal 概念对应图

2.1 ThreadLocal、方法参数和锁怎么选

这三种工具解决的是不同问题:

需求 更合适的方式
数据只经过两三层调用,依赖关系需要明确 方法参数
多个线程确实要读写同一个对象 锁、原子类或并发容器
同一线程的深层调用需要读取请求级上下文 ThreadLocal
不可变上下文只在一个词法作用域内向下传递 JDK 25 ScopedValue

如果参数传递并不麻烦,优先使用参数,因为它把依赖直接写在方法签名里。ThreadLocal 是一种隐藏参数:调用者看不到方法依赖了什么上下文,测试和排查也更困难,所以不应该把普通业务参数全部塞进去。

ThreadLocal 常见于以下场景:

  • 日志 Trace ID;
  • 当前登录用户或租户标识;
  • 框架事务上下文;
  • 每线程日期格式化器等非线程安全对象;
  • 某些数据库连接或会话绑定。

它不适合保存跨线程共享的计数器、缓存、全局配置,也不能替代正常的生命周期管理。

三、四个核心操作:withInitial、get、set、remove

3.1 使用 withInitial 提供每线程初始值

不传初始值时,第一次 get() 返回 null。如果每个线程都需要一个默认对象,可以使用 withInitial

private static final ThreadLocal<StringBuilder> BUFFER =
        ThreadLocal.withInitial(StringBuilder::new);

StringBuilder builder = BUFFER.get();
builder.setLength(0);
builder.append("member=").append("M1001");

初始化函数不是“整个程序只执行一次”,而是每个线程第一次调用 get() 时各执行一次。线程调用 remove() 后,再次 get() 还会重新初始化:

AtomicInteger count = new AtomicInteger();
ThreadLocal<Integer> lockerNo =
        ThreadLocal.withInitial(count::incrementAndGet);

System.out.println(lockerNo.get()); // 1
System.out.println(lockerNo.get()); // 1,同一线程复用旧值

lockerNo.remove();
System.out.println(lockerNo.get()); // 2,重新执行初始化函数
lockerNo.remove();

3.2 get 只读取当前线程的值

两个线程同时使用一个 ThreadLocal 时,线程 A 可以看到“会员 1001”,线程 B 可以看到“会员 2002”。完整实战会用两个并发任务验证这一点。ThreadLocal 没有把 get() 变成一次带锁的共享读取;它先取得 Thread.currentThread(),再去当前 Thread 的映射表中查找。

3.3 set 会覆盖当前线程的旧值

同一线程对同一个 ThreadLocal 连续调用 set,后一次会覆盖前一次。这不会影响其他线程中的值,但会改变当前线程后续调用链看到的上下文。

3.4 remove 才是真正移除 Entry

推荐把 setremove 写在同一个控制流中:

CONTEXT.set(memberContext);
try {
    handleRequest();
} finally {
    CONTEXT.remove();
}

finally 很重要,因为正常返回、抛出异常和提前 return 都会经过它。

四、数据到底存在哪里:跟 Thread 走,不跟 ThreadLocal 走

很多人会把 ThreadLocal 想象成下面这样:

ThreadLocal
├── 线程 A 的值
├── 线程 B 的值
└── 线程 C 的值

这个模型便于入门,却和真实存储方向相反。JDK 中更接近下面这样:

Thread A
└── threadLocals: ThreadLocalMap
    └── Entry(CONTEXT -> 会员 1001)

Thread B
└── threadLocals: ThreadLocalMap
    └── Entry(CONTEXT -> 会员 2002)

Thread 内部包含两个相关字段,下面是为了讲解而压缩后的结构示意:

public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals;
    ThreadLocal.ThreadLocalMap inheritableThreadLocals;
}

普通 ThreadLocal 使用 threadLocalsInheritableThreadLocal 使用 inheritableThreadLocals。因此调用链可以概括为:

CONTEXT.get()
    -> Thread.currentThread()
    -> currentThread.threadLocals
    -> 使用 CONTEXT 作为 Key 查找 Value

Thread 与 ThreadLocalMap 底层结构图

这种设计有两个直接结果:

  1. ThreadLocal 对象不需要维护全局的“线程到值”映射,也就不需要为普通读写加一把全局锁;
  2. 只要 Thread 还活着,它就可能继续持有 ThreadLocalMap 及其中的 Value。

第二点正是线程池问题的入口。普通短命线程结束后,整个 Thread 对象及其 ThreadLocalMap 都可以被回收;线程池工作线程可能存活几小时甚至几个月,忘记清理的 Value 也会跟着长期存活。

五、ThreadLocalMap:它不是 HashMap

ThreadLocalMap 是 ThreadLocal 的内部定制映射表,不是 java.util.HashMap。它只服务于 ThreadLocal 场景,因此结构更紧凑,也包含专门处理弱引用 Key 的逻辑。

5.1 Entry 的 Key 是弱引用,Value 是强引用

JDK 25 的核心关系可以压缩为:

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;

    Entry(ThreadLocal<?> key, Object value) {
        super(key);
        this.value = value;
    }
}

为什么 Key 要使用弱引用?

如果业务代码已经不再引用某个 ThreadLocal,而 Entry 仍用强引用抓住它,那么 ThreadLocal 对象和 Value 都可能一直跟着线程存活。弱引用允许 GC 把已经没人使用的 ThreadLocal Key 清掉。

但弱引用只作用于 Key,不会自动把 Value 一起清空。GC 之后可能出现:

Entry
├── key   -> null
└── value -> 一个仍被强引用的业务对象

这样的 Entry 被称为 stale Entry,也就是“钥匙已经丢了,柜子里的东西还在”。

5.2 为什么使用特殊的哈希增量

每个 ThreadLocal 会得到一个递增的 threadLocalHashCode。相邻 ThreadLocal 不是简单加 1,而是增加一个固定值;ThreadLocalMap 的数组长度是 2 的幂,初始槽位可以理解为:

private static final int HASH_INCREMENT = 0x61c88647;
int index = threadLocalHashCode & (table.length - 1);

固定增量能让连续创建的 ThreadLocal 更均匀地散布在 2 的幂长度数组中,减少它们扎堆在相邻槽位的机会。它是表内分布策略,不是密码学哈希,也不能解决恶意碰撞问题。

5.3 冲突以后使用线性探测

ThreadLocalMap 没有 HashMap 那样的链表或红黑树。目标槽位已经被占用时,它会继续向后查找:

int index = firstIndex;

while (table[index] != null) {
    if (table[index].get() == targetKey) {
        return table[index];
    }
    index = (index + 1) % table.length;
}

真实源码还要处理 stale Entry、替换、清理、扩容等分支。这里的代码只表达“开放寻址 + 线性探测”的主干。

删除一个槽位后也不能简单把它置空就结束,否则可能截断后面元素的探测路径。expungeStaleEntry 会从 stale 位置继续向后扫描,清理失效 Entry,并把仍有效但位置不合适的 Entry 重新放回正确探测序列。

六、get、set、remove 会走哪些路径

6.1 get:先走快速路径,未命中再探测

get() 的简化流程如下:

public T get() {
    Thread thread = Thread.currentThread();
    ThreadLocalMap map = getMap(thread);

    if (map != null) {
        Entry entry = map.getEntry(this);
        if (entry != null) {
            return (T) entry.value;
        }
    }

    return setInitialValue(thread);
}

getEntry 会先计算目标下标。如果该槽位的 Key 就是当前 ThreadLocal,直接返回,这是最快的命中路径。否则进入 miss 路径继续线性探测;遇到 Key 已变成 null 的 stale Entry 时,会顺路触发清理。

如果完全找不到 Entry,get() 才调用 initialValue()withInitial 提供的 Supplier,并把初始值写入当前线程。

6.2 set:覆盖、占空位或替换 stale Entry

set(value) 先取得当前 Thread,再获取或创建它的 ThreadLocalMap。进入 Map 后主要有三种情况:

  1. 找到相同 Key:覆盖 Value;
  2. 找到空槽位:插入新 Entry;
  3. 找到 stale Entry:用新键值替换,并清理附近的 stale Entry。

插入完成后还会启发式扫描一部分槽位。如果没有清理到 stale Entry,而且有效 Entry 达到阈值,就会进行更全面的清理,并在需要时扩容。

6.3 remove:清 Key,再修复探测序列

remove() 会在当前线程的 ThreadLocalMap 中找到对应 Entry,清除弱引用 Key,然后从该位置开始执行 stale 清理。它不只是把 Value 改成 null,而是移除当前 ThreadLocal 对应的映射关系,并让后续探测序列保持可用。

七、弱 Key 为什么仍然可能留下强 Value

先看一条典型引用链:

GC Root
  -> 线程池工作线程 Thread
  -> threadLocals
  -> ThreadLocalMap.table
  -> Entry
  -> value

如果业务代码不再强引用 ThreadLocal,GC 可以把 Entry 中的弱 Key 清为 null。但是 Thread、Map、Entry 和 Value 之间仍然是强引用链,所以 Value 不会因为 Key 变成 null 就立刻消失。

ThreadLocal 弱 Key 强 Value 滞留链路图

需要准确区分两个说法:

  • “Key 是弱引用,所以完全不会泄漏”——错误,Value 仍可能滞留;
  • “只要忘记 remove,Value 就永远无法回收”——也不准确,后续 getsetremove 可能触发清理,线程死亡也会让整张 Map 可回收。

更准确的说法是:在长寿命线程中,忘记 remove 会让 Value 的生命周期变得不可预测,并可能在下一次有效清理或线程结束之前一直占用内存。

7.1 static final ThreadLocal 就没有问题了吗

很多工程把 ThreadLocal 定义为 private static final 字段。此时 Key 有稳定的强引用,通常不会变成 null,但 Value 仍然跟着线程存活。忘记清理会产生两类风险:

  1. 当前线程一直持有大对象,造成内存占用;
  2. 线程被下一项任务复用时,读到上一项任务的用户、租户或 Trace ID。

所以 static final 解决的是 Key 被意外回收的问题,不是 Value 生命周期和业务数据隔离问题。

八、线程池中的真正危险:下一位会员打开了上一只柜子

下面故意只创建一个工作线程,确保两个任务复用同一个 Thread:

private static final ThreadLocal<String> MEMBER = new ThreadLocal<>();

ExecutorService pool = Executors.newFixedThreadPool(1);

pool.submit(() -> {
    MEMBER.set("会员 1001");
    System.out.println("第一个任务:" + MEMBER.get());
    // 故意忘记 remove
}).get();

pool.submit(() -> {
    System.out.println("第二个任务:" + MEMBER.get());
}).get();

pool.shutdown();

可能输出:

第一个任务:会员 1001
第二个任务:会员 1001

第二个任务根本没有写入会员信息,却读取到了上一个任务的值。在线上系统中,这不只是内存问题,还可能变成严重的数据越权:

  • 日志把 A 用户的请求记到 B 用户名下;
  • 多租户系统使用了上一租户的数据源;
  • 权限检查拿到旧用户上下文;
  • Trace ID 串线,让链路排查失真。

8.1 为什么一定要放进 finally

正确模式就是前文的 set -> try -> finally remove。下面几种路径都会执行 finally

  • 业务正常结束;
  • 业务抛出运行时异常;
  • 方法中途 return
  • 下游调用失败后向上抛错。

ThreadLocal 在线程池中的安全生命周期图

8.2 set(null) 为什么不等于 remove

这两种写法不同:

CONTEXT.set(null); // Entry 仍在,只是 Value 为 null
CONTEXT.remove();  // 移除映射,修复探测序列

如果 ThreadLocal 使用了 withInitial,区别更加明显:

  • set(null) 后再次 get(),得到的仍是 null,不会重新执行初始化函数;
  • remove() 后再次 get(),会重新执行初始化函数并创建新值。

因此清理生命周期时使用 remove(),不要用 set(null) 代替。

九、完整实战:健身房会员上下文

下面的完整程序依次验证四件事:

  1. withInitialremove 后会重新初始化;
  2. 两个线程同时使用一个 ThreadLocal 时互相隔离;
  3. 单线程池忘记清理会复现数据串线;
  4. try/finally/remove 可以让下一任务读到空值。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class GymThreadLocalDemo {
    private static final ThreadLocal<MemberContext> CONTEXT = new ThreadLocal<>();

    record MemberContext(String memberId, String memberName) {
        @Override
        public String toString() {
            return memberId + "/" + memberName;
        }
    }

    public static void main(String[] args) throws Exception {
        demonstrateInitialValue();
        demonstrateIsolation();
        demonstratePoolContamination();
        demonstrateSafeCleanup();
    }

    private static void demonstrateInitialValue() {
        AtomicInteger initializations = new AtomicInteger();
        ThreadLocal<Integer> lockerNo =
                ThreadLocal.withInitial(initializations::incrementAndGet);

        int first = lockerNo.get();
        int second = lockerNo.get();
        lockerNo.remove();
        int afterRemove = lockerNo.get();
        lockerNo.remove();

        System.out.printf(
                "[初始化] first=%d, second=%d, afterRemove=%d%n",
                first, second, afterRemove);
    }

    private static void demonstrateIsolation() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        CountDownLatch ready = new CountDownLatch(2);
        CountDownLatch start = new CountDownLatch(1);

        try {
            Future<String> alice = pool.submit(
                    isolatedTask(new MemberContext("M1001", "小李"), ready, start));
            Future<String> bob = pool.submit(
                    isolatedTask(new MemberContext("M2002", "小周"), ready, start));

            ready.await();
            start.countDown();

            System.out.println("[隔离] " + alice.get());
            System.out.println("[隔离] " + bob.get());
        } finally {
            pool.shutdown();
            pool.awaitTermination(5, TimeUnit.SECONDS);
        }
    }

    private static java.util.concurrent.Callable<String> isolatedTask(
            MemberContext member,
            CountDownLatch ready,
            CountDownLatch start) {
        return () -> {
            CONTEXT.set(member);
            try {
                ready.countDown();
                start.await();
                return Thread.currentThread().getName() + " -> " + CONTEXT.get();
            } finally {
                CONTEXT.remove();
            }
        };
    }

    private static void demonstratePoolContamination() throws Exception {
        ExecutorService oneWorker = Executors.newFixedThreadPool(1);
        try {
            oneWorker.submit(() -> {
                CONTEXT.set(new MemberContext("M3003", "小王"));
                System.out.println("[错误任务] 写入 " + CONTEXT.get()
                        + ",但忘记 remove");
            }).get();

            oneWorker.submit(() ->
                    System.out.println("[污染] 下一任务读到 " + CONTEXT.get())
            ).get();

            oneWorker.submit(CONTEXT::remove).get();
        } finally {
            oneWorker.shutdown();
            oneWorker.awaitTermination(5, TimeUnit.SECONDS);
        }
    }

    private static void demonstrateSafeCleanup() throws Exception {
        ExecutorService oneWorker = Executors.newFixedThreadPool(1);
        try {
            oneWorker.submit(() ->
                    handleSafely(new MemberContext("M4004", "小陈"))
            ).get();

            oneWorker.submit(() ->
                    System.out.println("[安全] 下一任务读到 " + CONTEXT.get())
            ).get();
        } finally {
            oneWorker.shutdown();
            oneWorker.awaitTermination(5, TimeUnit.SECONDS);
        }
    }

    private static void handleSafely(MemberContext member) {
        CONTEXT.set(member);
        try {
            System.out.println("[安全任务] 正在处理 " + CONTEXT.get());
        } finally {
            CONTEXT.remove();
        }
    }
}

关键输出如下,线程名称可能因环境不同而变化:

[初始化] first=1, second=1, afterRemove=2
[隔离] pool-1-thread-1 -> M1001/小李
[隔离] pool-1-thread-2 -> M2002/小周
[错误任务] 写入 M3003/小王,但忘记 remove
[污染] 下一任务读到 M3003/小王
[安全任务] 正在处理 M4004/小陈
[安全] 下一任务读到 null

真实 Web 项目通常在 Filter、Interceptor 或框架入口中写入上下文,在最外层 finally 中统一清理。清理责任要放在创建生命周期的那一层,而不是期待每个业务方法都记得调用。

十、InheritableThreadLocal:继承发生在线程创建时

普通 ThreadLocal 的值不会自动传给子线程。InheritableThreadLocal 允许子线程在创建时取得父线程中的值:

InheritableThreadLocal<String> tenant =
        new InheritableThreadLocal<>();

tenant.set("tenant-a");

Thread child = new Thread(() ->
        System.out.println(tenant.get())
);
child.start();
child.join();

tenant.remove();

这里最容易误解的是“创建时”三个字。继承不是每提交一次任务就重新发生。

10.1 为什么在线程池里经常不符合预期

线程池的工作线程通常提前创建或长期复用:

主线程设置 tenant-a
    -> 线程池工作线程早已存在
    -> 提交任务
    -> 不会因为这次提交重新复制 tenant-a

反过来,如果工作线程在创建时继承了某个值,它还可能在后续任务中继续保留旧上下文。默认继承对可变对象也只是传递对象引用,父子线程仍可能操作同一个可变实例。

因此不要把 InheritableThreadLocal 当成通用的线程池上下文传播工具。在线程池中更稳妥的做法是:

  1. 提交任务前显式捕获需要的不可变上下文;
  2. 在任务入口设置;
  3. finally 中恢复或移除;
  4. 优先使用框架官方提供并经过验证的上下文传播机制。

十一、虚拟线程和 JDK 25 ScopedValue

11.1 虚拟线程仍然支持 ThreadLocal

JEP 444 在 JDK 21 正式交付虚拟线程,并明确保证虚拟线程支持 ThreadLocal,因此大量现有库能够继续工作。在虚拟线程中仍然遵循相同的 set -> try -> finally remove 生命周期。

不过虚拟线程便宜且数量很多,JEP 444 建议为每个任务创建新的虚拟线程,而不是把虚拟线程放进池里复用。不要把平台线程时代“池化一切”的习惯照搬过来。

ThreadLocal 在虚拟线程中可用,不代表可以无成本地塞入大量对象。数十万虚拟线程各自保存大对象,空间成本仍然真实存在。用于不可变请求上下文时,可以评估 ScopedValue。

11.2 ScopedValue 在 JDK 25 已正式定稿

JEP 506 在 JDK 25 将 Scoped Values API 正式定稿。它适合把不可变数据从调用者传给当前线程中的下游方法,以及结构化创建的子线程。

public class ScopedValueDemo {
    private static final ScopedValue<MemberContext> CONTEXT =
            ScopedValue.newInstance();

    record MemberContext(String memberId, String memberName) {
    }

    public static void main(String[] args) {
        MemberContext member = new MemberContext("M5005", "小赵");

        ScopedValue.where(CONTEXT, member).run(() -> {
            printCurrentMember();

            MemberContext temporary =
                    new MemberContext("M9999", "临时教练");
            ScopedValue.where(CONTEXT, temporary)
                    .run(ScopedValueDemo::printCurrentMember);

            printCurrentMember();
        });

        System.out.println(
                "作用域结束后是否绑定:" + CONTEXT.isBound());
    }

    private static void printCurrentMember() {
        System.out.println("当前上下文:" + CONTEXT.get());
    }
}

输出体现了词法作用域和嵌套重新绑定:

当前上下文:MemberContext[memberId=M5005, memberName=小赵]
当前上下文:MemberContext[memberId=M9999, memberName=临时教练]
当前上下文:MemberContext[memberId=M5005, memberName=小赵]
作用域结束后是否绑定:false

ThreadLocal 和 ScopedValue 不是简单的“旧 API 与新 API”关系:

对比项 ThreadLocal ScopedValue
值是否可反复修改 可以 set 作用域内绑定为不可变引用
生命周期 依赖调用者主动 remove 由词法作用域自动结束
数据流方向 任意深层代码可读写 更适合调用者向下游只读传递
线程池风险 忘记清理会串线 不通过线程池残留旧绑定
现有框架兼容 广泛 需要 JDK 25 与相应适配

JEP 506 明确没有要求所有代码迁移,也没有弃用 ThreadLocal。已有库依赖 ThreadLocal、确实需要可变线程状态时可以继续使用;新的不可变上下文传递可以优先评估 ScopedValue。

十二、最常见的十个误区

12.1 ThreadLocal 让共享对象自动线程安全

错误。ThreadLocal 只隔离“每个线程保存的引用”。如果多个线程分别 set 了同一个可变对象,它们仍然会并发修改同一对象。

12.2 Key 是弱引用,所以不需要 remove

错误。弱引用只让无外部引用的 Key 可以被 GC;Value 仍可能被 Entry 强引用,线程池还会带来业务数据串线。

12.3 忘记 remove 一定会永久泄漏

不准确。后续 Map 操作可能清理 stale Entry,线程死亡也会释放整张 Map。真正的问题是生命周期失控:你不知道 Value 会保留多久,也不知道哪个后续任务会先看到它。

12.4 set(null) 就等于 remove

错误。set(null) 保留 Entry,remove() 删除映射并参与修复探测序列;使用 withInitial 时,两者的再次 get() 行为也不同。

12.5 ThreadLocal 应该定义成每次请求 new 一个

通常不应该。临时创建且不保留强引用的 ThreadLocal 更容易出现弱 Key 被回收、Value 暂时滞留的问题。常见模式是受控的 private static final Key,再严格管理 Value 生命周期。

12.6 使用线程池就不能用 ThreadLocal

可以使用,但必须把设置和清理放在任务生命周期边界,并验证异常、超时和取消路径。框架如果会异步切线程,还要确认上下文是否需要显式传播。

12.7 InheritableThreadLocal 会在每次提交任务时复制

错误。继承发生在线程创建时,不是任务提交时。线程池工作线程的创建时机与任务边界通常不一致。

12.8 ThreadLocal 可以代替方法参数

不应该无条件替代。显式参数更易读、更易测试。只有跨越较深调用链、属于请求级横切上下文的数据才适合考虑 ThreadLocal。

12.9 虚拟线程会自动清理 ThreadLocal

线程结束后整个线程状态可回收,但在线程运行期间仍然要控制对象大小和作用域。虚拟线程支持 ThreadLocal,不代表 ThreadLocal 没有空间成本。

12.10 只在成功路径调用 remove 就够了

错误。异常路径最容易被忽略,所以必须使用 try/finally。在 Web 框架中通常由最外层 Filter、Interceptor 或任务包装器负责。

十三、怎样排查 ThreadLocal 相关问题

13.1 先用单线程池稳定复现串线

并发问题随机出现时,先把线程池缩成一个工作线程,让两个任务确定复用同一个 Thread。第一个任务写值但不清理,第二个任务不写直接读。如果能稳定读到旧值,生命周期问题就被证实了。

13.2 检查每个 set 是否有对称 remove

代码审查时按下面的顺序找:

ThreadLocal.set
    -> 是否立即进入 try
    -> finally 是否 remove
    -> 异步任务是否切换线程
    -> 框架异常/超时路径是否仍会执行清理

不要只搜索 remove() 的总数量。关键是同一生命周期中是否对称,以及嵌套设置时是否需要恢复旧值。

13.3 用堆转储查看引用链

线上出现疑似滞留时,可以先观察类实例数量,再在合适的维护窗口生成堆转储:

jcmd <pid> GC.class_histogram
jcmd <pid> GC.heap_dump /path/to/heap.hprof

堆转储可能很大,也可能给进程和磁盘带来压力,不要在不了解影响时直接对高负载生产实例执行。

在 Eclipse MAT、VisualVM 或其他堆分析工具中,重点查看:

Thread
  -> threadLocals
  -> table
  -> ThreadLocalMap$Entry
  -> value

如果 Entry 的 referent 已经是 null,但 Value 仍被工作线程持有,就是 stale Entry 滞留;如果 Key 仍然有效,则更可能是业务忘记清理或生命周期设计过长。

13.4 自动化测试要验证“任务结束后的状态”

测试不要只断言业务结果,还要在同一工作线程上提交一个后续任务,确认上下文已经为空。本文完整示例中的安全测试就是这种思路。

同时覆盖:

  • 正常返回;
  • 抛出异常;
  • 提前返回;
  • 超时或取消;
  • 线程池复用;
  • 多个 ThreadLocal 同时存在。

十四、总结:一人一柜,离场清空

最后回到那家健身房:

  1. ThreadLocal 像一把定位私人储物柜的钥匙,同一个 Key 在不同线程中对应不同 Value;
  2. 数据真正存放在 Thread 的 ThreadLocalMap 中,不在 ThreadLocal 对象里;
  3. ThreadLocalMap 使用开放寻址和线性探测,不是普通 HashMap;
  4. Entry 的 Key 是弱引用,Value 是强引用,Key 被 GC 后可能留下 stale Entry;
  5. getsetremove 会在访问路径上顺路清理 stale Entry,但不能把它当成确定的生命周期保证;
  6. 线程池工作线程长期存活,忘记清理既可能占内存,也可能让下一任务读到旧数据;
  7. set(null) 不等于 remove()
  8. InheritableThreadLocal 在线程创建时继承,不会在每次线程池任务提交时重新复制;
  9. 虚拟线程支持 ThreadLocal,但不应该池化虚拟线程,也要控制每线程状态大小;
  10. JDK 25 ScopedValue 更适合词法作用域内的不可变上下文传递。

一句话记忆:

线程一人一柜,Map 跟线程走;Key 弱 Value 强,池化一定 remove。

参考资料

本文完整示例以 JDK 25 编译运行。源码章节使用了便于理解的结构示意和伪代码;涉及生产环境时,请以目标 JDK 版本源码、框架文档和实际压测结果为准。


  • 个人小游戏

个人小游戏

在这里插入图片描述

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐