ThreadLocal 的 InheritableThreadLocal 在异步线程池中传递失效的原因与替代方案

线上排查一个用户操作日志丢失的问题时,链路信息(traceId、userId)在 Controller 里明明已经写进了 InheritableThreadLocal,可一进业务代码里通过 @Async 或手动提交的线程池任务,日志里的 traceId 就全变成 null。很多人第一反应是"继承的 ThreadLocal 没生效",于是继续在工具类上加装饰、

线上排查一个用户操作日志丢失的问题时,链路信息(traceId、userId)在 Controller 里明明已经写进了 InheritableThreadLocal,可一进业务代码里通过 @Async 或手动提交的线程池任务,日志里的 traceId 就全变成 null。很多人第一反应是”继承的 ThreadLocal 没生效”,于是继续在工具类上加装饰、加包装,结果越改越乱。这个问题的根因其实非常明确:InheritableThreadLocal 只负责在 Thread 对象创建的那一刻拷贝一次父线程的值,而线程池的线程是复用且创建时机不确定的。本文把这个机制的源码依据、失效的三种典型场景、真实报错表现,以及生产可用的替代方案(TaskDecorator、TTL、显式透传)一次说清。

一、先把机制钉死:InheritableThreadLocal 到底在什么时候传递

不要停留在”子线程能拿到父线程的值”这种模糊印象。打开 JDK 源码,Thread 的构造器里只有这么一段相关逻辑:

// java.lang.Thread#Thread(java.lang.ThreadGroup, java.lang.Runnable, java.lang.String, long, boolean)
if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
    this.inheritableThreadLocals =
        ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}

而 createInheritedMap 的实现是一个浅拷贝式的遍历:

static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
    return new ThreadLocalMap(parentMap);
}

private ThreadLocalMap(ThreadLocalMap parentMap) {
    Entry[] parentTable = parentMap.table;
    int len = parentTable.length;
    setThreshold(len);
    table = new Entry[len];

    for (Entry e : parentTable) {
        if (e != null) {
            ThreadLocal<Object> key = e.get();
            if (key != null) {
                Object value = key.childValue(e.value);
                Entry c = new Entry(key, value);
                // 计算 hash 放入新表
                table[i] = c;
            }
        }
    }
}

从这里能读出三个关键事实,后面所有问题都由它们推导而来:

  • 传递发生在 new Thread(...) 的瞬间,拷贝的是当时父线程 inheritableThreadLocals 的那张 map。线程对象创建之后,父线程再 set 什么都不会同步过去。
  • 传递方向是单向的。子线程改了自己的值,父线程完全无感知,因为拷贝后是两份独立的 ThreadLocalMap。
  • 拷贝是浅拷贝 value 引用,不是深拷贝。如果你的值是 Map、List 这类可变对象,父子线程共享同一个引用,一改就是并发问题;childValue 默认直接返回 parentValue。

对比 ThreadLocal:set 时判断 Thread.currentThread() 是不是”自己”,值存在 Thread 的 threadLocals 字段里;InheritableThreadLocal 只是把存储位置换成了 inheritableThreadLocals 字段,并在创建线程时多做一次拷贝。它没有任何”沿着调用链自动传播”的能力。

二、在线程池里必然失效的三个场景

2.1 线程池预热:任务提交时线程早就建好了

这是最常见的一种。典型写法:

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("bizExecutor")
    public Executor bizExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("biz-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

注意最后一行 executor.initialize()。这个调用会触发 ThreadPoolTaskExecutor 内部的 ThreadPoolExecutor 创建,而 Spring 的 ThreadPoolTaskExecutor 在初始化时通常会预先创建核心线程(底层 prestartAllCoreThreads 的时机取决于实现,但只要线程被创建,父线程当时是什么状态就被”拍照”了)。此时父线程(比如 Spring 主线程)的 inheritableThreadLocals 里什么都没有,于是这 8 个核心线程的 inheritableThreadLocals 里也什么都没有,并且永远不会再刷新。

后续请求进来,Controller 里 MDC.put("traceId", ...) 或 holder.set(...) 都只写到了 Tomcat 工作线程的 map 上。提交任务到 bizExecutor 时,任务只是被塞进队列,run 在已有线程上执行,Thread 构造器根本没被调用,拷贝逻辑自然不会执行。

2.2 核心线程满后扩容出的线程,继承的是”当时”的父线程

如果线程池没那么积极预热,线程是在 execute 时才按需创建的,情况会变成另一种更隐蔽的形态:第一个把线程池打满的请求,它的 traceId 被复制到了新线程里,之后所有在这个线程上跑的任务都带着这个”僵尸 traceId”,直到线程被回收。日志里就会出现一串任务全部挂着同一个错误 traceId 的诡异现象。

验证方式很简单,跑下面这段代码:

public class InheritableDemo {
    private static final InheritableThreadLocal<String> HOLDER = new InheritableThreadLocal<>();
    private static final ExecutorService POOL = Executors.newFixedThreadPool(2);

    public static void main(String[] args) throws Exception {
        // 模拟请求一:先提交一次,让线程池创建线程
        HOLDER.set("trace-001");
        POOL.submit(() -> System.out.println("first : " + HOLDER.get())).get();

        // 模拟请求二:换个 traceId,但线程已被复用
        HOLDER.set("trace-002");
        POOL.submit(() -> System.out.println("second: " + HOLDER.get())).get();

        POOL.shutdown();
    }
}

实际输出是:

first : trace-001
second: trace-001

第二次拿到的不是 trace-002,也不是 null,而是第一次创建线程时那张”照片”里的值。这比 null 更难查,因为它看起来”有值”。

2.3 用 CallerRunsPolicy 时,任务跑在调用线程上,看起来”正常”

上文配置里用了 CallerRunsPolicy,队列满时任务由提交线程直接执行,此时 Thread.currentThread() 就是父线程本身,get() 能拿到值。于是同一套代码在高压力下表现不一致:平时 traceId 是 null,压测时又有值。这种”时好时坏”会严重误导排查方向,必须先把它当成噪声识别出来。

结论先行:InheritableThreadLocal 的语义是”线程创建时的一次性继承”,而线程池的语义是”线程复用 + 创建时机与任务解耦”。这两个语义天然冲突,不是配置问题,是模型不匹配。

三、失效后的真实表现与排查手段

下面这些报错和现象在生产里都是真实存在的,对照着看能快速定位:

现象 / 日志 根因 处理动作
异步任务里 userContext.get() 返回 null,抛 NullPointerException 线程在父线程 set 之前就创建了,继承到的是空 map 改用 TaskDecorator 或 TTL,不要依赖继承
所有任务日志的 traceId 相同且不是当前请求的 线程创建时被”拍”进了某个历史请求的值,未清理 任务执行前后必须清理,或改为显式传参
同一用户在异步任务里读到别人的权限数据 值本身是可变对象(如 UserContext 实例),浅拷贝共享引用 值对象设计为不可变,任务内不修改
本地单测通过,集成测试偶发失败 线程池执行顺序或预创建时机不同,导致继承与否不确定 测试里显式断言透传逻辑,而非依赖线程创建顺序
java.lang.OutOfMemoryError: unable to create new native thread 为避免复用问题而给每个任务 new Thread,线程数失控 禁止用 new Thread 规避,回归线程池 + 透传方案

排查的第一步不是改代码,而是确认值到底丢在哪。可以在提交任务前和执行任务的第一行各打一条日志:

log.info("before submit, traceId={}", TraceHolder.get());
executor.execute(() -> {
    log.info("inside task, traceId={}, thread={}",
             TraceHolder.get(), Thread.currentThread().getName());
    // 业务
});

如果 before submit 有值、inside task 是 null,且 thread 名字是复用的池内线程,就可以完全排除”上游没 set”这个方向,直接进入透传方案选型。

四、替代方案一:TaskDecorator 做提交时透传(Spring 环境首选)

思路很直接:在任务提交的线程上把当前上下文抓下来,在执行线程上还原,执行完再清理。Spring 从 4.3 起在 ThreadPoolTaskExecutor 上提供了 setTaskDecorator,就是为此设计的。

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        // 注意:这里运行在提交线程
        String traceId = TraceHolder.get();
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();

        return () -> {
            // 这里运行在执行线程
            String previousTraceId = TraceHolder.get();
            Map<String, String> previousMdc = MDC.getCopyOfContextMap();
            try {
                TraceHolder.set(traceId);
                if (mdcContext != null) {
                    MDC.setContextMap(mdcContext);
                } else {
                    MDC.clear();
                }
                runnable.run();
            } finally {
                // 关键:恢复而不是简单 remove,避免污染复用线程
                if (previousTraceId == null) {
                    TraceHolder.remove();
                } else {
                    TraceHolder.set(previousTraceId);
                }
                if (previousMdc == null) {
                    MDC.clear();
                } else {
                    MDC.setContextMap(previousMdc);
                }
            }
        };
    }
}

接到线程池上:

@Bean("bizExecutor")
public Executor bizExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(200);
    executor.setKeepAliveSeconds(60);
    executor.setThreadNamePrefix("biz-");
    executor.setTaskDecorator(new MdcTaskDecorator());
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

几个必须注意的点:

  • decorate 方法体本身在提交线程执行,所以取值必须写在 decorate 里、而不是返回的 lambda 里,这是最常见的写错点。
  • 只对 ThreadPoolTaskExecutor 生效。如果项目里直接 new ThreadPoolExecutor,需要自己包装 Executor 或重写 beforeExecute / afterExecute。
  • finally 里要做”恢复”,不能一律 remove()。因为 CallerRunsPolicy 下任务可能跑在提交线程上,直接 remove 会把父线程自己的上下文删掉。
  • 对 CompletableFuture.runAsync(task, executor) 同样有效,因为最终也是走 executor.execute。

如果线程池是原生 ThreadPoolExecutor,可以重写钩子方法,效果等价:

public class ContextAwareExecutor extends ThreadPoolExecutor {

    public ContextAwareExecutor(int core, int max, long keepAlive, TimeUnit unit,
                                BlockingQueue<Runnable> queue) {
        super(core, max, keepAlive, unit, queue);
    }

    @Override
    public void execute(Runnable command) {
        String traceId = TraceHolder.get();
        super.execute(() -> {
            String previous = TraceHolder.get();
            try {
                TraceHolder.set(traceId);
                command.run();
            } finally {
                if (previous == null) {
                    TraceHolder.remove();
                } else {
                    TraceHolder.set(previous);
                }
            }
        });
    }
}

五、替代方案二:Alibaba TransmittableThreadLocal(TTL)

TTL 解决的问题比 InheritableThreadLocal 更彻底:它不依赖线程创建时机,而是通过装饰 Runnable / Callable 在任务提交时快照、执行时回放、执行后恢复。核心类是 TransmittableThreadLocal 和 TtlRunnable。

依赖(Maven Central 可查,实际使用时以最新稳定版为准):

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

最小可用示例:

import com.alibaba.ttl.TransmittableThreadLocal;
import com.alibaba.ttl.threadpool.TtlExecutors;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TtlDemo {
    private static final TransmittableThreadLocal<String> HOLDER =
            new TransmittableThreadLocal<>();

    public static void main(String[] args) throws Exception {
        ExecutorService raw = Executors.newFixedThreadPool(2);
        // 必须包装,否则 TTL 也退化成 InheritableThreadLocal 的行为
        ExecutorService pool = TtlExecutors.getTtlExecutorService(raw);

        HOLDER.set("trace-001");
        pool.submit(() -> System.out.println("first : " + HOLDER.get())).get();

        HOLDER.set("trace-002");
        pool.submit(() -> System.out.println("second: " + HOLDER.get())).get();

        pool.shutdown();
    }
}

输出:

first : trace-001
second: trace-002

能看到 second 已经能拿到最新的值。如果忘了用 TtlExecutors 包装,直接提交到 raw,输出会退回成和第二节一样的 trace-001,这是因为 TtlRunnable 的装饰动作没有发生。这一点是 TTL 使用中最容易踩的坑。

Spring 项目里可以用 TTL 提供的 TtlRunnable 接到 TaskDecorator:

@Bean
public TaskDecorator ttlTaskDecorator() {
    return runnable -> TtlRunnable.get(runnable, false, true);
}

三个参数的语义要记清楚:

  • 第二个参数 releaseTtlValueReferenceAfterRun,设为 true 表示任务执行完释放快照引用,避免内存滞留,一般建议 true。
  • 第三个参数 idempotent,设为 true 表示多次 get 返回同一个包装对象,防止重复包装导致快照嵌套。

如果是用线程池的 submit / execute 需要整体接管,还要注意 java.util.concurrent.ScheduledExecutorService 要用 TtlExecutors.getTtlScheduledExecutorService,否则定时任务场景仍然失效。

六、替代方案三:显式传参,把隐式上下文赶出异步边界

工程上最稳的方案往往最朴素:在线程边界处显式传参数,不在异步链路里依赖 ThreadLocal。

public record RequestContext(String traceId, Long userId, String tenantId) {
}

// 同步入口处从 ThreadLocal 取出,转成不可变对象往下传
RequestContext ctx = RequestContext.from(TraceHolder.get(), UserHolder.getUserId());

executor.execute(() -> orderService.createOrder(cmd, ctx));

这么做的好处:

  • 上下文是方法签名的一部分,调用方无法”忘记”传入,编译期可检查。
  • 不存在线程复用的脏数据问题,record 天然不可变。
  • 异步任务内部再起线程、再提交到别的池,只要把这个对象传下去就行,没有隐藏依赖。

代价是侵入性较强。折中做法是只在跨线程的那一刻显式化,池内仍然用 ThreadLocal,配合 TaskDecorator 做还原。两者不冲突,可以叠加。

七、选型对照与需要避开的写法

方案 适用场景 主要限制
InheritableThreadLocal 每次任务都新建 Thread 的场景(如手工 new Thread 的非池化用法) 线程池内必然失效,禁止用于池化线程
TaskDecorator Spring 的 ThreadPoolTaskExecutor、@Async 只对 Spring 管理的线程池生效,原生池需自写包装
TTL(TransmittableThreadLocal) 多线程池、ForkJoinPool、定时任务等复杂链路 必须用 TtlExecutors 或 TtlRunnable 装饰,漏一处就退化
显式传参 核心链路、对正确性要求高、可改签名 侵入性强,接口签名变复杂

以下几种”看起来能修好”的写法建议直接避开:

  • 把 InheritableThreadLocal 换成普通 ThreadLocal:只会从”错误的值”变成”null”,问题更明显但依然没解决。
  • 在任务里重新 set 一遍上下文:如果上下文本身是从 ThreadLocal 读的,执行线程里读到的还是 null,等于自己给自己赋值。
  • 为了透传而改成 new Thread(code) 执行:瞬间绕过线程复用,但线程创建开销和数量失控,压测时直接暴露 unable to create new native thread。
  • 只 set 不 remove:复用线程上残留上一次任务的值,串数据比丢数据更危险。

小结

InheritableThreadLocal 的传递点只有一个:Thread 构造器执行的那一刻做一次 map 拷贝。线程池把”线程创建”和”任务提交”这两个动作解耦,于是继承要么发生在上下文还没设置的时候(拿到 null),要么发生在某次历史任务上(拿到旧值),两种结果都不是调用方期望的。要在线程池里正确传递上下文,必须在提交任务时抓快照、执行任务时还原、执行结束后恢复或清理,Spring 下用 TaskDecorator,通用场景用 TTL,核心链路考虑改成显式传参。别在这条链路上叠加更多隐式状态,隐式的东西越多,异步边界就越难守。

FAQ

Q1:为什么我在 Controller 里 set 了,异步任务里还是 null?

因为异步任务的线程不是在这个请求里创建的。线程池的核心线程通常在应用启动、第一次提交任务时就已经创建,那一刻父线程的 inheritableThreadLocals 是空的,拷贝过去自然也是空。此后提交任务不会再触发拷贝,所以 get() 永远是 null。

Q2:为什么有时候有值、有时候是 null,压测时才正常?

大概率是拒绝策略 CallerRunsPolicy 在起作用。队列满时任务由提交线程直接执行,此时读的是父线程自己的 ThreadLocal,所以”看起来正常”。这不是透传生效,而是任务压根没进池子。排查时要把线程名打出来,看任务是不是跑在了 main 或 Tomcat 工作线程上。

Q3:用了 TTL 还是拿到旧值,是什么原因?

九成是漏了装饰。直接 rawExecutor.submit(task) 而不经过 TtlExecutors.getTtlExecutorService 包装,或者没把任务包成 TtlRunnable.get(task),TTL 就无法在提交时抓快照,行为退化成 InheritableThreadLocal。检查 Thread.currentThread() 上跑的是不是 TtlRunnable 即可确认。

Q4:CompletableFuture.supplyAsync(supplier, executor) 能透传吗?

能,前提是 executor 已经被正确装饰。因为 supplyAsync 内部最终调用的是 executor.execute,透传逻辑挂在这一层就自动生效。但要注意 thenApplyAsync 不传 executor 时会走公共的 ForkJoinPool.commonPool(),那个池子没有被装饰,上下文会丢,必须显式传入装饰过的 executor。

Q5:任务执行完一定要 remove 吗?

一定要。线程池的线程会复用,不清理就等于把上一个任务的值留给下一个任务,会出现”张三的订单日志里出现李四的 userId”这类串数据事故。正确做法是在 finally 里恢复成执行前的值(可能为 null),null 时调用 remove(),非 null 时 set 回去。

Q6:MDC 的传递和 ThreadLocal 是一回事吗?

机制相同,MDC 底层就是 ThreadLocal<Map<String, String>>(Logback 实现里用 InheritableThreadLocal 派生)。所以日志链路透传和业务上下文透传会遇到完全相同的问题,处理方式也一样:MDC.getCopyOfContextMap() 抓快照,执行前 MDC.setContextMap,执行后恢复。InheritableThreadLocal 在线程池里的失效,MDC 一样跑不掉。