ThreadLocal 是什么

ThreadLocal 用于保存线程私有变量。

同一个 ThreadLocal 可以被多个线程访问,但每个线程读取和修改的都是自己保存的值,线程之间默认互不影响。

常见用途包括:

  • 保存当前用户信息;
  • 保存 TraceId;
  • 保存租户或请求上下文;
  • 在线程内部隐式传递参数。
private static final ThreadLocal<String> USER_CONTEXT =
        new ThreadLocal<>();

调用 set() 时,数据并不是直接存入 ThreadLocal 对象,而是存入当前线程内部的 ThreadLocalMap:

Thread
  └── ThreadLocalMap
        ├── ThreadLocal A → Value A
        └── ThreadLocal B → Value B

一个线程可以使用多个 ThreadLocal,但这些数据都保存在当前线程自己的 ThreadLocalMap 中。

ThreadLocal 为什么会内存泄漏

ThreadLocalMap 中的 Entry 可以简化为:

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

其中:

  • Key,也就是 ThreadLocal,是弱引用;
  • Value 是强引用。

如果外部不再引用某个 ThreadLocal,GC 可以回收 Key,此时 Map 中可能留下:

key = null
value = 业务对象

只要线程仍然存活,Value 就可能继续被 ThreadLocalMap 引用。

在线程池中,工作线程会长期存在并被反复复用,因此未清理的 Value 可能长期无法释放。更危险的是,下一个任务可能读到上一个任务遗留的用户信息、TraceId 或租户信息。JDK 文档也指出,线程本地变量会一直保留到线程结束或调用 remove(),在线程池中可能从一个任务泄漏到另一个任务。

因此,应当始终使用:

try {
    USER_CONTEXT.set(userId);

    // 业务逻辑
    process();
} finally {
    USER_CONTEXT.remove();
}

为什么通常定义为 static final

一般建议将 ThreadLocal 定义为:

private static final ThreadLocal<User> USER_CONTEXT =
        new ThreadLocal<>();

static final 可以让类持续持有这个 ThreadLocal,避免它作为局部变量使用后失去外部强引用,从而使 Entry 的 Key 变为 null。

但这并不能代替 remove()。

即使 Key 一直存在,Value 仍然会被线程持有。在线程池中,如果任务执行结束后不清理,依然会出现:

  • 内存长期占用;
  • 线程复用导致的脏数据;
  • 用户、租户或链路信息串用。

因此可以记为:

static final:保证 ThreadLocal 入口稳定

finally remove:清理当前线程中的业务数据

InheritableThreadLocal

普通 ThreadLocal 的值不会自动传递给其他线程。

如果父线程通过 new Thread() 创建子线程,可以使用 InheritableThreadLocal:

private static final InheritableThreadLocal<String> CONTEXT =
        new InheritableThreadLocal<>();

子线程创建时,会获取父线程中对应变量的初始值。其核心扩展方法是:

protected T childValue(T parentValue)

默认实现直接返回 parentValue,因此父线程和子线程可能持有同一个对象引用。只有需要不同的继承行为时,才需要重写 childValue()。

如果保存的是 String、Integer 等不可变对象,通常不需要重写:

private static final InheritableThreadLocal<String> TRACE_ID =
        new InheritableThreadLocal<>();

如果保存的是可变对象,并且希望父子线程相互隔离,可以创建一个副本:

private static final InheritableThreadLocal<Map<String, String>> CONTEXT =
        new InheritableThreadLocal<>() {
            @Override
            protected Map<String, String> childValue(
                    Map<String, String> parentValue) {
                return parentValue == null
                        ? null
                        : new HashMap<>(parentValue);
            }
        };

这里的 new HashMap<>(parentValue) 只是复制 Map 容器。

如果 Map 中的 Value 本身也是可变对象,父子线程仍然可能共享这些 Value,需要根据业务情况继续复制。

为什么 InheritableThreadLocal 不适合线程池

InheritableThreadLocal 只在线程创建时传递一次数据。

但线程池中的工作线程通常已经提前创建,后续任务只是在反复复用这些线程。因此,提交新任务时,工作线程不会重新继承提交线程的上下文。

这可能导致:

  • 读取不到当前任务提交者的上下文;
  • 读取到工作线程创建时的旧值;
  • 不同任务之间发生上下文污染。

因此:

new Thread() 创建子线程:
可以考虑 InheritableThreadLocal

线程池复用工作线程:
不能依赖 InheritableThreadLocal

TransmittableThreadLocal

为了解决线程池中的上下文传递问题,阿里巴巴开源了 TransmittableThreadLocal,简称 TTL。

TTL 传递的不是“线程创建时”的上下文,而是任务提交时的上下文。

它可以被理解为三个步骤:

Capture:任务提交时捕获上下文

Replay:任务执行前将上下文放入工作线程

Restore:任务结束后恢复工作线程原来的上下文

阿里巴巴 TTL 官方文档将这个流程称为 Capture、Replay、Restore 模式。

基本定义如下:

private static final TransmittableThreadLocal<String> CONTEXT =
        new TransmittableThreadLocal<>();

但只把 ThreadLocal 换成 TTL 还不够,还需要包装线程池或任务。

包装线程池

推荐统一包装 ExecutorService:

ExecutorService originalExecutor =
        Executors.newFixedThreadPool(10);

ExecutorService ttlExecutor =
        TtlExecutors.getTtlExecutorService(originalExecutor);

之后通过包装后的线程池提交任务:

try {
    CONTEXT.set("request-001");

    ttlExecutor.submit(() -> {
        System.out.println(CONTEXT.get());
    });
} finally {
    CONTEXT.remove();
}

官方提供的实际方法名是:

TtlExecutors.getTtlExecutorService(executorService)

而不是 TransmittableExecutorService。

包装单个任务

也可以只包装某个 Runnable:

Runnable task = TtlRunnable.get(() -> {
    System.out.println(CONTEXT.get());
});

executor.submit(task);

如果同一个原始 Runnable 被多次提交,每次都应重新调用 TtlRunnable.get(),因为包装时捕获的是当次上下文。

通常情况下,统一包装线程池更不容易遗漏。

TTL 的 copy 方法

TTL 提供了:

public T copy(T parentValue)

copy() 用于决定任务创建或提交时,应该如何复制当前上下文。

默认实现直接返回原对象引用,因此 copy() 也不是必须重写。官方 API 明确说明,默认行为是返回源线程中 Value 的引用;只有需要不同传递行为时才覆盖该方法。

不可变对象一般不需要处理:

private static final TransmittableThreadLocal<String> TRACE_ID =
        new TransmittableThreadLocal<>();

如果保存的是可变对象,并且希望异步任务读取任务提交时的独立快照,可以重写 copy():

private static final TransmittableThreadLocal<Map<String, String>> CONTEXT =
        new TransmittableThreadLocal<>() {
            @Override
            public Map<String, String> copy(
                    Map<String, String> parentValue) {
                return parentValue == null
                        ? null
                        : new HashMap<>(parentValue);
            }
        };

例如:

  1. 主线程中的配置是 A;
  2. 主线程提交异步任务;
  3. 主线程把配置改成 B;
  4. 异步任务稍后开始运行。

如果希望异步任务仍然使用提交时的配置 A,就需要通过 copy() 创建快照。

三种 ThreadLocal 的区别

类型使用场景传递时机
ThreadLocal当前线程内部保存数据不跨线程传递
InheritableThreadLocal父线程创建新的子线程子线程创建时
TransmittableThreadLocal线程池和异步任务任务提交、执行时

可以简单记为:

线程内部:
ThreadLocal

真正创建新子线程:
InheritableThreadLocal

线程池复用线程:
TransmittableThreadLocal

最佳实践

  1. ThreadLocal 通常定义为 private static final。
  2. set() 后必须在 finally 中执行 remove()。
  3. 在线程池中尤其要注意任务之间的数据污染。
  4. InheritableThreadLocal 的正确方法名是 childValue(),不是 init()。
  5. TransmittableThreadLocal 的复制方法是 copy(),不是 clone()。
  6. childValue() 和 copy() 并非必须重写。
  7. 传递不可变对象时,通常可以共享引用。
  8. 传递可变对象时,需要考虑防御性复制和线程安全。
  9. TTL 必须配合 TtlExecutors、TtlRunnable 或其他官方包装方式使用。
  10. 能够清晰地显式传参时,应优先考虑显式传参,避免引入隐藏依赖。

总结

ThreadLocal 的数据保存在当前线程的 ThreadLocalMap 中,而不是直接保存在 ThreadLocal 对象里。

它最重要的使用原则是:

try {
    threadLocal.set(value);
    // 业务代码
} finally {
    threadLocal.remove();
}

InheritableThreadLocal 解决的是创建子线程时的数据继承,TTL 解决的是线程池复用线程时的上下文传递。

对于 childValue() 和 copy(),不应简单记成“必须重写”,更准确的说法是:

不可变对象:
通常可以使用默认的引用传递

可变对象:
根据是否需要隔离或快照,决定是否复制