线程池里用 InheritableThreadLocal 传值,怎么就丢了?

你肯定遇到过这种怪事:代码里明明用了 InheritableThreadLocal,信心满满地觉得子线程能自动继承主线程的变量,结果一跑,值没了,或者串了。

我第一次碰到这问题时, debug 了一下午,最后发现是线程池的锅。今天我就把当时挖出来的东西从头捋一遍,包括 Java 源码里到底是怎么拷贝的,还有阿里那个 TransmittableThreadLocal 到底是怎么救场的。

先看 InheritableThreadLocal 是怎么“继承”的

每个 Java 线程,也就是 Thread 类的实例,肚子里都揣着两个 ThreadLocalMap

  • 一个叫 threadLocals,专门给普通的 ThreadLocal 存变量。
  • 另一个叫 inheritableThreadLocals,给 InheritableThreadLocal 用。

这两个 map 平时都是懒加载的,你第一次 set 的时候才会创建。

当我们 new 一个子线程的时候,事情就发生在 Threadinit() 方法里。这个方法会在子线程的构造函数中被调用,关键的一段逻辑是这样的(我摘了简化版的源码):

if (parent.inheritableThreadLocals != null) {
    this.inhertableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}

也就是说,如果父线程(也就是创建子线程的那个线程)的 inheritableThreadLocals 里面有东西,就把整个 map 拷贝一份,作为子线程的 inheritableThreadLocals

createInheritedMap 是怎么拷贝的呢?它实际上是调用了 ThreadLocalMap 的一个特殊构造方法:

private ThreadLocalMap(ThreadLocalMap parentMap) {
    // ... 一些初始化代码 ...
    for (int i = 0; i < len; i++) {
        Entry e = parentMap.table[i];
        if (e != null) {
            ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
            if (key != null) {
                // 注意这一行:调用了 childValue 方法
                Object value = key.childValue(e.value);
                Entry c = new Entry(key, value);
                // ... 把新 entry 放到子线程的 map 里 ...
            }
        }
    }
}

重点就是这个 childValue 方法。它是 InheritableThreadLocal 里定义的一个 protected 方法,默认实现就是直接返回传进来的值:

protected T childValue(T parentValue) {
    return parentValue;
}

所以默认情况下,子线程拿到的就是父线程当时的那个值的“原样副本”。如果你需要子线程拿到的不是同一个对象引用(比如深拷贝),你可以自己继承 InheritableThreadLocal 并重写 childValue

继承的时机:只有线程诞生的那一刻

理解这个机制的关键在于:这个拷贝动作,只发生在线程被 new 出来的那一瞬间。线程创建完以后,父线程再怎么修改 InheritableThreadLocal 的值,已经存在的子线程完全不知情。

这在以前每次 new Thread 的场景下是合理的,因为子线程一旦跑起来,它就不再需要父线程的后续更新了。

线程池为什么让 InheritableThreadLocal 失效

现在大家都用线程池了。线程池里的线程是什么时候创建的?是在你调用 Executors.newFixedThreadPool 或者 new ThreadPoolExecutor 的时候,或者池子里需要增加线程的时候创建的。总之,它们是在你提交任务之前就已经存在了。

假设主线程启动后先创建了一个固定大小的线程池,此时池子里有 5 个线程,它们都继承自主线程当时的状态。如果当时主线程的 InheritableThreadLocal 还没有设置值,那这 5 个线程里的值就是 null。

然后主线程往池子里提交任务。注意,提交任务并不会导致线程重新创建,所以这 5 个线程不会再去主线程那里重新继承。它们只会用自己出生时拷贝到的值——也就是 null。

更坑的是,如果之前某个任务在这条线程里自己调用了 set,那这个值就会一直留在线程里。下一个任务再跑的时候,读到的不是主线程当前的值,而是上一个任务留下的“垃圾”。这就不仅是丢失了,简直是串了。

一个让你看得见摸得着的例子

InheritableThreadLocal<String> context = new InheritableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1);

context.set("第一次的值");
pool.submit(() -> System.out.println(Thread.currentThread().getName() + ": " + context.get()));
// 输出: pool-1-thread-1: 第一次的值

context.set("第二次的值");
pool.submit(() -> System.out.println(Thread.currentThread().getName() + ": " + context.get()));
// 输出: pool-1-thread-1: 第一次的值   <-- 还是旧的,不是“第二次的值”

第二个任务里你明明在主线程改成了“第二次的值”,但池子里那个线程根本不认,它只认自己出生时拷贝到的“第一次的值”。如果之前没有 set 过,那它甚至输出 null。

阿里的 TransmittableThreadLocal 是怎么解决的

既然问题出在“继承只在线程创建时发生”,那我们就换个思路:不在线程创建时拷贝,而在任务提交时拷贝。这就是 TransmittableThreadLocal (TTL) 的核心思想。

TTL 内部有一个非常巧妙的设计:它用了一个 holder,这个 holder 本身是一个 InheritableThreadLocal,但它里面存的是一个 WeakHashMap<TransmittableThreadLocal<Object>, ?>

private static final InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>> holder = ...;

这个 holder 就像一个“全局注册表”。你每次对 TTL 变量调用 getset 方法的时候,TTL 会自动把这个变量注册到当前线程的 holder 中。也就是说,任何时候,holder 里存的 key 就是当前线程用到的所有 TTL 变量。这样,在需要传递上下文的时候,TTL 就知道该传递哪些变量了。

真正干活的是下面这套机制,我管它叫 CRR:

  1. Capture(捕获):当你提交一个任务(比如用 TtlRunnable 包装过的任务)的时候,TTL 会立刻把当前父线程的 TTL 上下文全部捕获下来。具体做法就是遍历父线程 holder 里的每一个 TTL 变量,把它们的值读出来,存成一个快照。
  2. Replay(重放):当线程池里的某个线程开始执行这个任务之前,TTL 会把刚刚捕获的那个快照,重新设置到这个线程里面。这时候,子线程就有了父线程提交任务那一刻的最新值。
  3. Restore(恢复):任务执行完毕后,为了防止这个线程被复用的时候把脏数据带到下一个任务,TTL 会自动把线程的 TTL 上下文恢复成执行之前的样子。也就是说,这次任务对 TTL 做的任何修改,都不会影响到后续任务。

怎么用 TransmittableThreadLocal

引入依赖之后,有两种用法。

一种是显式地包装你的线程池:

TransmittableThreadLocal<String> ctx = new TransmittableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(2);
pool = TtlExecutors.getTtlExecutorService(pool);  // 这一行是关键

ctx.set("主线程的值");
pool.submit(() -> System.out.println(ctx.get()));  // 输出正常

ctx.set("主线程改的新值");
pool.submit(() -> System.out.println(ctx.get()));  // 这次也能输出新值了

另一种是用 Java Agent,启动的时候加上 -javaagent:path/to/transmittable-thread-local.jar,它会动态修改线程池的字节码,让所有提交的任务自动具备传递能力。这种方式不需要改业务代码,但需要配置 JVM 参数。

最后说几句实在话

  • InheritableThreadLocal 不是有 bug,它只是设计于线程池普及之前的年代。它的“继承”语义是“创建时继承”,而不是“提交时继承”。这个差异导致它和线程池天然不兼容。
  • 如果你用 TTL,也别忘了在任务结束后 remove,或者依赖 TTL 的 restore 机制。虽然 TTL 会恢复,但如果你自己在任务里又往别的 ThreadLocal 里塞了东西,TTL 管不了那些。
  • 如果只是一个简单的参数传递,其实直接通过构造方法或者方法参数传进去,比任何 ThreadLocal 都清晰。ThreadLocal 是方便,但它让数据流动变得隐晦,容易藏 bug。