面试题 / Java

ThreadLocal

ThreadLocal 有什么用?

通常情况下,我们创建的变量可以被任何一个线程访问和修改。这在多线程环境中可能导致数据竞争和线程安全问题。那么,如果想让每个线程都有自己的专属本地变量,该如何实现呢?

JDK 中提供的 ThreadLocal 类正是为了解决这个问题。ThreadLocal 类允许每个线程绑定自己的值,可以将其形象地比喻为一个“存放数据的盒子”。每个线程都有自己独立的盒子,用于存储私有数据,确保不同线程之间的数据互不干扰。

当你创建一个 ThreadLocal 变量时,每个访问该变量的线程都会拥有一个独立的副本。这也是 ThreadLocal 名称的由来。线程可以通过 get() 方法获取自己线程的本地副本,或通过 set() 方法修改该副本的值,从而避免了线程安全问题。

举个简单的例子:假设有两个人去宝屋收集宝物。如果他们共用一个袋子,必然会产生争执;但如果每个人都有一个独立的袋子,就不会有这个问题。如果将这两个人比作线程,那么 ThreadLocal 就是用来避免这两个线程竞争同一个资源的方法。

public class ThreadLocalExample {
    private static ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

    public static void main(String[] args) {
        Runnable task = () -> {
            int value = threadLocal.get();
            value += 1;
            threadLocal.set(value);
            System.out.println(Thread.currentThread().getName() + " Value: " + threadLocal.get());
        };

        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");

        thread1.start(); // 输出: Thread-1 Value: 1
        thread2.start(); // 输出: Thread-2 Value: 1
    }
}

⭐️ThreadLocal 原理了解吗?

Thread类源代码入手。

public class Thread implements Runnable {
    //......
    //与此线程有关的ThreadLocal值。由ThreadLocal类维护
    ThreadLocal.ThreadLocalMap threadLocals = null;

    //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}

从上面Thread类 源代码可以看出Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 setget方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()set()方法。

ThreadLocal类的set()方法

public void set(T value) {
    //获取当前请求的线程
    Thread t = Thread.currentThread();
    //取出 Thread 类内部的 threadLocals 变量(哈希表结构)
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 将需要存储的值放入到这个哈希表中
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //......
}

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。

ThreadLocal 数据结构如下图所示:

ThreadLocal 数据结构

ThreadLocalMapThreadLocal的静态内部类。

ThreadLocal内部类

⭐️ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocal 内存泄漏的根本原因在于其内部实现机制。

通过上面的内容我们已经知道:每个线程维护一个名为 ThreadLocalMap 的 map。 当你使用 ThreadLocal 存储值时,实际上是将值存储在当前线程的 ThreadLocalMap 中,其中 ThreadLocal 实例本身作为 key,而你要存储的值作为 value。

ThreadLocalset() 方法源码如下:

public void set(T value) {
    Thread t = Thread.currentThread(); // 获取当前线程
    ThreadLocalMap map = getMap(t);   // 获取当前线程的 ThreadLocalMap
    if (map != null) {
        map.set(this, value);         // 设置值
    } else {
        createMap(t, value);          // 创建新的 ThreadLocalMap
    }
}

ThreadLocalMapset()createMap() 方法中,并没有直接存储 ThreadLocal 对象本身,而是使用 ThreadLocal 的哈希值计算数组索引,最终存储于类型为static class Entry extends WeakReference<ThreadLocal<?>>的数组中。

int i = key.threadLocalHashCode & (len-1);

ThreadLocalMapEntry 定义如下:

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

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocalMapkeyvalue 引用机制:

  • key 是弱引用ThreadLocalMap 中的 key 是 ThreadLocal 的弱引用 (WeakReference<ThreadLocal<?>>)。 这意味着,如果 ThreadLocal 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 ThreadLocalMap 中对应的 key 变为 null
  • value 是强引用:即使 key 被 GC 回收,value 仍然被 ThreadLocalMap.Entry 强引用存在,无法被 GC 回收。

ThreadLocal 实例失去强引用后,其对应的 value 仍然存在于 ThreadLocalMap 中,因为 Entry 对象强引用了它。如果线程持续存活(例如线程池中的线程),ThreadLocalMap 也会一直存在,导致 key 为 null 的 entry 无法被垃圾回收,即会造成内存泄漏。

也就是说,内存泄漏的发生需要同时满足两个条件:

  1. ThreadLocal 实例不再被强引用;
  2. 线程持续存活,导致 ThreadLocalMap 长期存在。

虽然 ThreadLocalMapget(), set()remove() 操作时会尝试清理 key 为 null 的 entry,但这种清理机制是被动的,并不完全可靠。

如何避免内存泄漏的发生?

  1. 在使用完 ThreadLocal 后,务必调用 remove() 方法。 这是最安全和最推荐的做法。 remove() 方法会从 ThreadLocalMap 中显式地移除对应的 entry,彻底解决内存泄漏的风险。 即使将 ThreadLocal 定义为 static final,也强烈建议在每次使用后调用 remove()
  2. 在线程池等线程复用的场景下,使用 try-finally 块可以确保即使发生异常,remove() 方法也一定会被执行。

为什么 Entry 的 key 要设计为弱引用?

这是一个经典的面试追问。很多同学知道 ThreadLocalMap 的 key 是弱引用,但不清楚为什么要这样设计,以及如果换成强引用会怎样。

我们先来看完整的引用链路。当一个线程使用 ThreadLocal 时,涉及以下引用关系:

强引用(栈/静态变量)──→ ThreadLocal 实例

Thread ──→ ThreadLocalMap ──→ Entry ─── key(WeakReference)──┘

                              └─── value(强引用)──→ 实际存储的对象

理解了这条引用链路,我们来对比两种设计方案:

假设 key 使用强引用(实际没有采用):

当业务代码中的 ThreadLocal 引用被置为 null(例如方法执行结束、对象被回收),此时虽然业务代码已经不再需要这个 ThreadLocal,但由于 ThreadLocalMap 的 Entry 对 key 持有强引用ThreadLocal 实例仍然无法被 GC 回收。只要线程不终止,这个 ThreadLocal 和它对应的 value 都会一直存在于内存中,造成 key 和 value 都无法回收的内存泄漏。

key 使用弱引用(实际采用的方案):

当业务代码中的 ThreadLocal 引用被置为 null 后,由于 Entry 的 key 是弱引用,ThreadLocal 实例在下次 GC 时会被回收,key 变为 null。此时虽然 value 仍然存在(强引用),但 ThreadLocalMap 在执行 get()set()remove() 等操作时,会主动探测并清理这些 key 为 null 的 “stale entry”(过期条目),从而释放 value 对象。

也就是说,弱引用的设计是一种”兜底”防御机制——即便开发者忘记调用 remove(),JVM 的 GC 配合 ThreadLocalMap 的自清理逻辑,仍然有机会回收泄漏的数据。而如果使用强引用,一旦忘记 remove(),就完全没有任何补救机会了。

需要注意的是,这种自清理机制是被动触发的(只在 get/set/remove 操作时顺便清理),并不能保证所有过期条目都被及时清理。因此,弱引用只是降低了内存泄漏的风险,并没有彻底消除它,手动调用 remove() 仍然是必须的。

线程池场景下的特殊风险

上面提到内存泄漏的条件之一是”线程持续存活”。在使用 new Thread() 创建线程的场景下,线程执行完毕后会被销毁,其持有的 ThreadLocalMap 也会随之被 GC 回收,泄漏的影响相对有限。

但在线程池场景下,问题会被严重放大。线程池中的核心线程默认不会被销毁,它们会被反复复用来执行不同的任务。这意味着:

  1. 内存泄漏持续累积:每个任务如果使用了 ThreadLocal 却没有清理,其 value 就会一直残留在该线程的 ThreadLocalMap 中。随着任务不断提交和执行,泄漏的数据会越积越多,最终可能导致 OOM。
  2. 数据污染(脏数据):上一个任务设置的 ThreadLocal 值,如果没有被清理,下一个被分配到同一线程的任务就能读取到这个残留值。这可能导致严重的业务逻辑错误,比如用户 A 的请求读取到了用户 B 的身份信息。

美团技术团队的真实事故案例:

美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》一文中就记录了一次因 ThreadLocal 使用不当引发的线上事故:在一个依赖 ThreadLocal 传递用户上下文的 Web 应用中,由于使用了线程池处理请求,且没有在请求结束后清理 ThreadLocal,导致后续请求复用了同一线程时,读取到了前一个请求遗留的用户信息,造成了用户数据串号的严重问题。

阿里巴巴 Java 开发手册的强制规约

正因为线程池 + ThreadLocal 的组合如此容易踩坑,《阿里巴巴 Java 开发手册》在”并发处理”章节中对此做出了强制级别的要求:

【强制】 必须回收自定义的 ThreadLocal 变量记录的当前线程的值,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄露等问题。尽量在代理中使用 try-finally 块进行回收。

正确的使用模式如下:

// 定义为 static final,避免重复创建 ThreadLocal 实例
private static final ThreadLocal<UserContext> userContextHolder = new ThreadLocal<>();

public void processRequest(HttpServletRequest request) {
    try {
        // 在 try 块中设置值
        UserContext context = buildUserContext(request);
        userContextHolder.set(context);

        // 执行业务逻辑
        doBusinessLogic();
    } finally {
        // 在 finally 块中必须清理,确保无论是否发生异常都会执行
        userContextHolder.remove();
    }
}

这里有三个关键要点:

  1. ThreadLocal 声明为 static final:确保整个应用只有一个 ThreadLocal 实例,避免因重复创建导致旧实例失去强引用后 key 被回收,加剧内存泄漏。
  2. try-finally 保证 remove() 一定被执行:即使业务逻辑抛出异常,finally 块也能确保 ThreadLocal 被清理。
  3. 在使用完毕后立即清理,而不是在下次使用前设置:在使用前 set() 虽然可以覆盖旧值解决脏数据问题,但无法解决上一次任务遗留 value 的内存占用问题。只有在用完后 remove(),才能同时避免内存泄漏和数据污染。

⭐️如何跨线程传递 ThreadLocal 的值?

为什么 ThreadLocal 在异步场景下会失效?

ThreadLocal 的值不在 ThreadLocal 对象中,而是存储在 Thread 里:

Thread → ThreadLocalMap → Entry(ThreadLocal, value)

ThreadLocal 数据结构如下图所示:

ThreadLocal 数据结构

异步执行往往意味着任务会从当前线程切换到另一个线程(例如线程池中的工作线程)执行。由于不同线程各自维护独立的 ThreadLocalMap,默认情况下 ThreadLocal 的上下文无法在异步执行中自动传递。

如何跨线程传递 ThreadLocal 的值?

为了解决这个问题,业界有两套主流的解决方案,一套是 JDK 原生的,另一套是阿里巴巴开源的。

  1. InheritableThreadLocal :JDK1.2 提供的一个类,继承自 ThreadLocal 。使用 InheritableThreadLocal 时,会在创建子线程时,令子线程继承父线程中的 ThreadLocal 值,但是无法支持线程池场景下的 ThreadLocal 值传递。
  2. TransmittableThreadLocalTransmittableThreadLocal (简称 TTL) 是阿里巴巴开源的工具类,继承并加强了InheritableThreadLocal类,可以在线程池的场景下支持 ThreadLocal 值传递。项目地址:https://github.com/alibaba/transmittable-thread-local

InheritableThreadLocal 原理

InheritableThreadLocal 实现了创建异步线程时,继承父线程 ThreadLocal 值的功能。该类是 JDK 团队提供的,通过改造 JDK 源码包中的 Thread 类来实现创建线程时,ThreadLocal 值的传递。

InheritableThreadLocal 的值存储在哪里?

Thread 类中添加了一个新的 ThreadLocalMap ,命名为 inheritableThreadLocals ,该变量用于存储需要跨线程传递的 ThreadLocal 值。如下:

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

如何完成 ThreadLocal 值的传递?

通过改造 Thread 类的构造方法来实现,在创建 Thread 线程时,拿到父线程的 inheritableThreadLocals 变量赋值给子线程即可。相关代码如下:

// Thread 的构造方法会调用 init() 方法
private void init(/* ... */) {
	// 1、获取父线程
    Thread parent = currentThread();
    // 2、将父线程的 inheritableThreadLocals 赋值给子线程
    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
        this.inheritableThreadLocals =
        	ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}

InheritableThreadLocal 的方案有什么问题?

这个方案的缺陷在于它的一次性,也就是它只在线程创建时发生一次复制。然而,现在的开发中,我们会大量使用线程池,但线程池里的线程是被复用的。

想象一下,任务A在线程1中执行,把它的 ThreadLocal 值传给了线程池里的子线程2。任务A结束后,线程1去休息了。接着,任务B来了,它在线程3中执行,线程池又复用了刚才那个子线程2来执行任务B的一部分。此时,子线程2的ThreadLocal里还残留着任务A传给它的脏数据,而任务B(在线程3里)的上下文却完全没有传递过来。这就导致了数据污染和上下文丢失。

TransmittableThreadLocal 原理

JDK 默认没有支持线程池场景下 ThreadLocal 值传递的功能,因此阿里巴巴开源了一套工具 TransmittableThreadLocal 来实现该功能。

由于阿里巴巴无法改动 JDK 源码,TTL 巧妙地利用了装饰器模式对任务(Runnable/Callable)或线程池(Executor)进行增强,将上下文的传递时机从“线程创建时”延迟到了“任务提交与执行时”。

TTL 的核心逻辑可以概括为三个阶段(CRR):

  • Capture(捕获):在提交任务(如调用 execute)的一瞬间,TtlRunnable 会调用 TransmittableThreadLocal.Transmitter.capture()。它通过内部维护的 holder 集合,抓取当前父线程中所有活跃的 TTL 变量并存入快照。
  • Replay(回放):在线程池的工作线程执行 run() 方法前,调用 replay()。它将快照中的值 set 到当前工作线程中,并备份该线程原有的旧值。
  • Restore(恢复):任务执行结束后,调用 restore()。它根据备份将工作线程恢复到执行前的状态,防止上下文污染或内存泄漏。

这张图是 TTL 官方提供的 CRR 整个过程的时序图:

TTL 官方提供的 CRR 整个过程的时序图

不太好理解吧?可以看下我绘制的这张 CRR 时序图,更清晰直观一些:

sequenceDiagram
    participant P as 父线程(Submitter)
    participant W as TTL 包装器(TtlRunnable / Agent)
    participant C as 线程池工作线程(Worker)

    Note over P: 1. set context = "A"
    P->>W: 2. 提交任务(Capture)
    Note right of W: 捕获父线程中所有活跃的 TTL 变量快照

    W->>C: 3. 执行任务 run()
    Note over C: 4. Replay
    Note right of C: 备份工作线程原有 TTL 值<br/>并设置 Capture 得到的值

    Note over C: 5. 业务逻辑执行<br/>get context = "A"

    Note over C: 6. Restore
    Note right of C: 恢复工作线程原有 TTL 值<br/>防止上下文污染

    C-->>P: 7. 任务执行结束

也就是说,TTL 的本质是在任务提交时 Capture 上下文,在任务执行前 Replay 上下文,在任务结束后 Restore 线程状态,从而安全地支持线程池中的 ThreadLocal 传递。

TTL 提供了两种主要的接入方式,可根据侵入性要求和改造成本进行选择。

1. 显式包装(手动接入)

使用 TtlRunnable.get(Runnable)TtlCallable.get(Callable) 对任务进行包装,使用 TtlExecutors.getTtlExecutor(Executor)getTtlExecutorService(...) 对线程池进行包装。这种接入方式清晰可控,但需要业务代码配合,存在一定侵入性。

下面这段代码展示了 TTL 通过 CRR,在支持线程池复用和拒绝策略的前提下,安全地传递并隔离 ThreadLocal 上下文。

public class TtlContextHolder {
    private static final Logger log = LoggerFactory.getLogger(TtlContextHolder.class);

    // 1. 使用 static final 确保 TTL 实例不被重复创建,防止内存泄漏
    // 重写 copy 方法(可选):如果是引用类型,建议实现深拷贝
    private static final TransmittableThreadLocal<String> CONTEXT = new TransmittableThreadLocal<String>() {
        @Override
        public String copy(String parentValue) {
            // 默认是直接返回引用,如果是可变对象(如 Map),请在这里 new 新对象
            return parentValue;
        }
    };

    // 2. 线程池初始化:确保只被 TtlExecutors 包装一次
    private static final ExecutorService TTL_EXECUTOR_SERVICE;

    static {
        ExecutorService rawExecutor = new ThreadPoolExecutor(
                2, 4, 60L, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(1000), (Runnable r) -> new Thread(r, "ttl-worker-" + r.hashCode()),
                new ThreadPoolExecutor.CallerRunsPolicy() // 关键:TTL 完美支持此拒绝策略
        );
        // 包装原始线程池
        TTL_EXECUTOR_SERVICE = TtlExecutors.getTtlExecutorService(rawExecutor);
    }

    public static void main(String[] args) throws Exception {
        try {
            // 3. 在父线程中设置上下文
            CONTEXT.set("value-set-in-parent");
            log.info("父线程上下文: {}", CONTEXT.get());

            // 4. 使用 Lambda 简化任务提交
            TTL_EXECUTOR_SERVICE.submit(() -> {
                log.info("异步任务(Runnable)读取上下文: {}", CONTEXT.get());
                // 模拟业务逻辑
                // 注意:子线程修改是否影响父线程,取决于 copy() 是否做了深拷贝
                CONTEXT.set("value-modified-in-child");
            });

            Future<String> future = TTL_EXECUTOR_SERVICE.submit(() -> {
                log.info("异步任务(Callable)读取上下文: {}", CONTEXT.get());
                return "Success";
            });

            future.get();

            // 5. 验证父线程上下文是否被污染
            log.info("父线程最终上下文: {}", CONTEXT.get());

        } finally {
            // 6. 清理当前线程(父线程)的上下文,子线程的上下文由 TTL 的 Restore 机制自动恢复
            CONTEXT.remove();
        }
    }
}

输出:

09:06:31.438 INFO  [main] TtlContextHolder - 父线程上下文: value-set-in-parent
09:06:31.452 INFO  [ttl-worker-1663166483] TtlContextHolder - 异步任务(Runnable)读取上下文: value-set-in-parent
09:06:31.453 INFO  [ttl-worker-841283083] TtlContextHolder - 异步任务(Callable)读取上下文: value-set-in-parent
09:06:31.453 INFO  [main] TtlContextHolder - 父线程最终上下文: value-set-in-parent

如果你想要测试这段代码,记得引入 TTL 的 Maven 依赖;

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.4</version>
</dependency>

2. 无侵入接入(Java Agent)

通过 Java Agent 在类加载阶段对线程池相关类进行 字节码增强,自动织入 TTL 的上下文传递逻辑,实现业务代码零改造的上下文透传。这种方式业务代码无需感知 TTL 的存在,但实现复杂度相对较高。

TTL Agent 默认修饰了以下 JDK 执行器组件:

  1. 标准线程池java.util.concurrent.ThreadPoolExecutorjava.util.concurrent.ScheduledThreadPoolExecutor
  2. ForkJoin 体系java.util.concurrent.ForkJoinTask(从而透明支持了 CompletableFuture 和 Java 8 并行流 Stream)。
  3. 遗留组件java.util.TimerTask(自 v2.7.0 起支持,v2.11.2 起默认开启)。

在 Java 启动参数中加入 -javaagent 配置:

# 基础配置
java -javaagent:path/to/transmittable-thread-local-2.x.y.jar \
     -cp classes \
     com.your.app.Main

应用场景

  1. 压测流量标记: 在压测场景中,使用 ThreadLocal 存储压测标记,用于区分压测流量和真实流量。如果标记丢失,可能导致压测流量被错误地当成线上流量处理。
  2. 上下文传递:在分布式系统中,传递链路追踪信息(如 Trace ID)或用户上下文信息。

总结

ThreadLocal 的值默认是无法跨线程传递的,因为它的值是存在每个 Thread 对象自己ThreadLocalMap 里的,父子线程是两个不同的对象。

为了解决这个问题,主要有两种方案:

  1. JDK的 InheritableThreadLocal:它会在创建子线程的时候,把父线程的值复制一份给子线程。但它的问题是,在线程池场景下会失效。因为线程池会复用线程,这会导致线程拿到的可能是上一个任务传下来的脏数据
  2. 阿里的 TransmittableThreadLocal (TTL):这是我们项目里用的方案,它专门解决线程池的问题。它的原理是,在提交任务到线程池时,它会把父线程的 ThreadLocal捕获下来,和任务绑定在一起。等线程池里的某个线程要执行这个任务时,它再把捕获的值设置到这个线程上,任务执行完再清理掉。

简单说,InheritableThreadLocal是跟线程绑定的,只在创建时有效;而TTL是跟任务绑定的,完美支持线程池。


来源引用