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()

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

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 必须配合 TtlExecutorsTtlRunnable 或其他官方包装方式使用。
  10. 能够清晰地显式传参时,应优先考虑显式传参,避免引入隐藏依赖。

总结

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

它最重要的使用原则是:

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

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

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

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

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