父子线程之间如何共享、传递数据?
结论先行:普通 ThreadLocal 不传递给子线程。JDK 提供 InheritableThreadLocal,在创建子线程时把父线程的值浅拷贝过去;但它在线程池里会失效——因为线程是复用的,创建时机和任务执行时机对不上。生产上要用 TransmittableThreadLocal(TTL)或手动装饰任务。
1. 三种方案对比
| 方案 | 传递时机 | 线程池可用 | 主要问题 |
|---|---|---|---|
ThreadLocal | 不传递 | — | 子线程完全读不到 |
InheritableThreadLocal | 子线程创建时复制一次 | ❌ | 线程复用后拿到的是旧值/别人的值 |
TransmittableThreadLocal | 任务提交时捕获、执行前回放 | ✅ | 需引入依赖,注意清理 |
手动装饰 Runnable | 提交时把上下文塞进任务对象 | ✅ | 代码侵入,容易漏 |
2. 为什么线程池会让继承失效
场景一:直接 new Thread(InheritableThreadLocal 可用)
父线程 set("A") ──▶ new Thread() ──▶ 拷贝 "A" 到子线程 ──▶ 子线程读到 "A"
场景二:线程池(继承机制失效)
t0: 提交任务1,父线程 set("A") → 池里创建 worker,拷贝 "A"
t1: 提交任务2,父线程 set("B") → worker 已存在,不再拷贝 → 任务2 读到 "A" ❌
根本原因:InheritableThreadLocal 的拷贝发生在 Thread 构造阶段(Thread.init 里复制 inheritableThreadLocals),而线程池的 worker 在第一个任务时就创建好了,之后所有任务都复用它。
3. 代码验证
public class InheritDemo {
// 关键:用 inheritableThreadLocals 字段承载
private static final InheritableThreadLocal<String> CTX = new InheritableThreadLocal<>();
public static void main(String[] args) throws Exception {
CTX.set("父线程的值");
Thread child = new Thread(() -> System.out.println("子线程读到: " + CTX.get()));
child.start(); // 输出:父线程的值
child.join();
// 线程池场景:只有第一个任务能拿到提交时的值
ExecutorService pool = Executors.newSingleThreadExecutor();
CTX.set("任务A");
pool.submit(() -> System.out.println("任务A: " + CTX.get())); // 任务A
CTX.set("任务B");
pool.submit(() -> System.out.println("任务B: " + CTX.get())); // 仍是 任务A ❌
pool.shutdown();
}
}
4. 生产方案:TransmittableThreadLocal
// 1. 用 TTL 代替 ThreadLocal / InheritableThreadLocal
private static final TransmittableThreadLocal<Map<String, String>> CTX =
new TransmittableThreadLocal<>();
// 2. 用 TtlExecutors 包装线程池(关键:包装后才会在提交时捕获快照)
ExecutorService pool = TtlExecutors.getTtlExecutorService(
new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500)));
CTX.set(Map.of("traceId", "abc123"));
pool.submit(() -> {
System.out.println(CTX.get()); // 一定拿得到 abc123,与线程复用无关
});
TTL 的工作方式可以概括为"提交时快照,执行时回放,执行后还原":
submit(task)
├── capture():把当前线程的 TTL 值拍一份快照挂到 task 上
├── 进入 worker 线程:replay(快照)
├── 执行 task
└── restore():恢复 worker 线程原有的值,避免污染下一个任务
不想引依赖怎么办?Spring 提供了 TaskDecorator,效果类似:
@Bean("bizExecutor")
public ThreadPoolTaskExecutor bizExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(500);
ex.setTaskDecorator(runnable -> {
String traceId = MDC.get("traceId"); // 提交线程的上下文
return () -> {
MDC.put("traceId", traceId); // 带到执行线程
try { runnable.run(); } finally { MDC.remove("traceId"); }
};
});
ex.initialize();
return ex;
}
5. 常见追问
追问一:InheritableThreadLocal 是深拷贝还是浅拷贝?
浅拷贝:只是把父线程 map 里的 Entry 引用复制一份到子线程的 map,value 还是同一个对象。所以父子线程拿到的是同一个可变对象,并发修改仍需自己保证安全。
追问二:虚拟线程支持继承吗?
支持。Thread.ofVirtual() 默认继承 inheritableThreadLocal,可以用 Thread.ofVirtual().inheritInheritableThreadLocals(false) 关掉——因为百万级虚拟线程如果都复制一份上下文,内存开销会很难看。
追问三:为什么说"上下文传递"是线程池的隐藏坑? 因为它不报错,只在特定链路(异步、并行、线程池切换)上悄悄拿到 null 或旧值。典型表现是日志里 traceId 丢失、多租户场景串号。排查时先看异步边界——凡是从线程池取线程执行的地方,都要检查上下文是否被传递。
追问四:MDC 是 ThreadLocal 吗?
是,Logback/SLF4J 的 MDC 底层就是 ThreadLocal<Map> 或 InheritableThreadLocal(取决于实现)。所以日志里的 traceId 在异步线程中丢失,本质就是本文说的问题。
小结
父子传值记住"创建时拷贝 vs 提交时拷贝"这条分界线:new Thread 用 InheritableThreadLocal 就够,线程池必须换成"提交时捕获"的方案(TTL 或 TaskDecorator),并且一定在 finally 里清理,否则下一个任务会读到上一个任务的上下文。