父子线程之间如何共享、传递数据?

#Java#面试八股

父子线程之间如何共享、传递数据?

结论先行:普通 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 里清理,否则下一个任务会读到上一个任务的上下文。

笔记加载中…