线上排查一个用户操作日志丢失的问题时,链路信息(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 一样跑不掉。