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

0 阅读4分钟

面试考察点

  1. ThreadLocal 机制理解:面试官不仅仅是想知道你会不会用 ThreadLocal,更是想知道你是否清楚 ThreadLocal 的数据隔离特性——它只对当前线程可见,子线程天然拿不到父线程的数据。
  2. 方案演进认知:考察你是否了解从 ThreadLocal → InheritableThreadLocal → TransmittableThreadLocal(阿里开源)这条技术演进线,以及每种方案的适用场景和局限性。
  3. 线程池场景的坑:这块是重点。真实项目中线程是复用的(线程池),InheritableThreadLocal 在线程池场景下会 "串数据",考察你是否踩过这个坑。

核心答案

先给结论:父子线程传递数据有三种方案,从简单到完善依次是 InheritableThreadLocal、手动传递、TransmittableThreadLocal(阿里开源,推荐)。

深度解析

一、为什么 ThreadLocal 不能跨线程?

先搞清楚问题出在哪。ThreadLocal 的数据存储在每个线程自己的 ThreadLocalMap 里,别的线程天然访问不了: ThreadLocal 的核心问题:每个线程有自己独立的 ThreadLocalMap,父子线程之间天然隔离,互不相通。

所以子线程通过 tl.get() 拿到的是 null,拿不到父线程设置的值。这在链路追踪、日志 traceId 传递、用户上下文传递等场景中都是大问题。

二、方案一:InheritableThreadLocal

JDK 自带方案,思路很简单——创建子线程时,把父线程的 InheritableThreadLocal 数据拷贝一份给子线程:// 创建 InheritableThreadLocal

InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();

itl.set("用户ID: 12345");

new Thread(() -> {
// 子线程可以直接拿到父线程设置的值
    System.out.println(itl.get()); // 输出: 用户ID: 12345
}).start();

原理在哪?在 Thread 的构造方法里:

// Thread 构造方法(简化)
public Thread(Runnable target) {
    init(null, target, "Thread-" + nextThreadNum(), 0);
}

private void init(...) {
    // ...
    Thread parent = currentThread();
    // 如果父线程的 inheritableThreadLocals 不为空,就拷贝给子线程
    if (parent.inheritableThreadLocals != null) {
        this.inheritableThreadLocals = ThreadLocal.createInheritedMap(
            parent.inheritableThreadLocals
        );
    }
}

看到没?在 new Thread() 的那一刻做了一次快照拷贝。这就埋下了两个坑:

  • 时机问题:拷贝发生在创建线程时,创建之后父线程再改值,子线程看不到
  • 线程池问题:线程池中线程是复用的,不会每次都 new Thread(),所以根本不会触发拷贝。更严重的是,复用的线程可能还保留着上一次任务的上下文数据,导致 "串数据"
// InheritableThreadLocal 在线程池下的灾难
ExecutorService pool = Executors.newFixedThreadPool(2);
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();

// 第一次请求
itl.set("用户A");
pool.submit(() -> {
    System.out.println(itl.get()); // "用户A" ✅ 第一次碰巧对了
});

// 第二次请求,换了用户
itl.set("用户B");
pool.submit(() -> {
    // ⚠️ 线程是复用的,不会重新创建,拿到的还是 "用户A"!
    System.out.println(itl.get()); // "用户A" ❌ 串数据了!
});

这在生产环境是致命的——用户 A 看到了用户 B 的数据,直接就是安全事故。

三、方案二:TransmittableThreadLocal(推荐)

阿里的 TransmittableThreadLocal(简称 TTL)专门解决了线程池场景下的上下文传递问题。它的思路很精妙:

上图展示了 TTL 的核心机制,整体分三步:

  • 捕获(capture) :任务提交时,抓取当前线程所有 TTL 的值,打包到 TtlRunnable 中
  • 恢复(replay) :线程池中的线程执行任务前,先把捕获的值设置到当前线程,同时 备份 线程原有的值
  • 还原(restore) :任务执行完后,用备份值恢复线程原有上下文,保证下一个任务不受污染

使用方式也很简单:

// 1. 用 TTL 替代 InheritableThreadLocal
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();

ExecutorService pool = Executors.newFixedThreadPool(2);

// 2. 用 TtlRunnable 包装任务(或者用 TtlExecutors 包装线程池)
ttl.set("用户A");
pool.submit(TtlRunnable.get(() -> {
    System.out.println(ttl.get()); // "用户A" ✅
}));

ttl.set("用户B");
pool.submit(TtlRunnable.get(() -> {
    System.out.println(ttl.get()); // "用户B" ✅ 线程池场景也正确!
}));

更优雅的方式是直接包装线程池,之后提交任务就完全无感知了:

面试高频追问

  1. ThreadLocal 会导致内存泄漏吗?
    会。ThreadLocalMap 的 key 是 ThreadLocal 对象的弱引用,value 是强引用。如果 ThreadLocal 对象被回收了,key 变成 null,但 value 还在,就泄漏了。不过 ThreadLocal 在 get()/set()/remove() 时会顺带清理 key 为 null 的 entry,所以最佳实践是 用完一定调 .remove()

  2. TTL 对性能有影响吗?
    有,但很小。每次提交任务需要做一次 capture,执行前 replay,执行后 restore,本质上是几次 HashMap 操作。和业务逻辑比起来,这点开销基本可以忽略。阿里内部压测表明对吞吐量的影响在 1% 以内。

  3. CompletableFuture 怎么传递上下文?
    CompletableFuture 底层也用的是 ForkJoinPool 或指定线程池,同样面临上下文丢失的问题。TTL 提供了 TtlCompletableFuture 来适配,原理一样。