⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/mianshiguanfuzixianchengzhijianruhegongxiangchuandishuju/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

这道题面试官真正想筛什么

「父子线程怎么传数据」是 Java 中高级岗的高频面试题——但它真正想筛的不是 ThreadLocal 的用法,是 3 件事:

  • 是不是真的踩过线程池上下文丢失的坑——只看过博客的人会答 InheritableThreadLocal,真做过链路追踪 / 多租户系统的人才会立刻答 TransmittableThreadLocal;
  • 对 JDK 设计选型的理解深度——为什么 ThreadLocal 是线程隔离的?为什么 InheritableThreadLocal 在线程池下会”串数据”?这些回答得清楚 = 真懂 JDK 源码;
  • 生产事故复盘能力——「用户 A 看到了用户 B 的数据」这种事故根因怎么分析?追到底就是 InheritableThreadLocal + 线程池组合用错了。

下面分 3 档给答案——30 分 / 60 分 / 90 分。

30 分答案:3 种方案的名字过基础线

最简洁的标准答案——30 分天花板:

父子线程传递数据有 3 种方案——从简单到完善依次是 InheritableThreadLocal、手动传参、TransmittableThreadLocal(阿里开源,生产推荐)。

方案 原理 线程池安全 推荐
InheritableThreadLocal 创建子线程时拷贝父线程数据 ❌ 不安全 仅简单场景
手动传参 构造方法 / Runnable 传入 ✅ 安全 简单但侵入性强
TransmittableThreadLocal 包装 Runnable,执行前恢复上下文 ✅ 安全 ✅ 生产推荐

到这里只能拿 30 分——面试官立刻会追:「那为什么 InheritableThreadLocal 在线程池下不安全?具体怎么”串数据”?」——答不上来这个,30 分都没拿满。

60 分答案:讲清三种方案的演进逻辑

要拿 60 分必须讲清为什么会有这三种方案——从 ThreadLocal → InheritableThreadLocal → TTL 是一条演进线——每一步都解决了上一步的具体痛点。

起点:ThreadLocal 为什么不能跨线程?

先搞清楚问题出在哪——ThreadLocal 的数据存在每个线程自己的 ThreadLocalMap 里——别的线程天然访问不了:

每个线程持有独立的 ThreadLocalMap——父子线程之间天然隔离、互不相通。

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

方案 1:InheritableThreadLocal——能跨 new Thread(),但坑大

JDK 自带方案——思路很直白:创建子线程时把父线程的数据拷一份给子线程。

InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
itl.set("用户ID: 12345");

new Thread(() -> {
System.out.println(itl.get()); // 输出: 用户ID: 12345
}).start();

原理在 Thread 构造方法里:

private void init(...) {
Thread parent = currentThread();
if (parent.inheritableThreadLocals != null) {
this.inheritableThreadLocals = ThreadLocal.createInheritedMap(
parent.inheritableThreadLocals
);
}
}

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

  • 时机问题:拷贝发生在创建线程时——创建之后父线程再改值,子线程看不到;
  • 线程池问题(P0 级别):线程池中线程复用——不会每次都 new Thread(),所以根本不会触发拷贝。更要命的是——复用的线程会保留上一个任务的上下文数据——直接”串数据”。
// InheritableThreadLocal 在线程池下的灾难
// 用单线程池 + 等第一个任务跑完,强制复现"线程被复用"这个核心问题
ExecutorService pool = Executors.newSingleThreadExecutor();
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();

// 第一次请求
itl.set("用户A");
Future<?> f1 = pool.submit(() -> {
System.out.println(itl.get()); // "用户A" ✅ 第一次拷贝拿到了
});
f1.get(); // 等第一个任务执行完——确保下一个任务复用同一条 worker 线程

// 第二次请求,换了用户
itl.set("用户B");
pool.submit(() -> {
// ⚠️ worker 线程是被复用的,不会重新创建——
// 也不会重新触发 InheritableThreadLocal 拷贝
// 所以拿到的还是上一次残留的 "用户A"!
System.out.println(itl.get()); // "用户A" ❌ 串数据了!
});

生产环境的这种”串数据”是 P0 级事故——用户 A 看到了用户 B 的数据——直接就是安全合规事故。

多线程池下的非确定性更可怕——如果是 newFixedThreadPool(2),第二次请求有时候会拿到全新的 worker(输出 null)、有时候复用已有 worker(输出”用户A”),这种”偶尔出 bug”的 case 比稳定 bug 更难排查。

方案 2:TransmittableThreadLocal——专门治线程池场景

阿里的 TransmittableThreadLocal(简称 TTL)专门解决线程池下的上下文传递问题——精妙之处在于把”拷贝”从 new Thread() 时机搬到了 submit() 时机。

整体分 3 步:

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

使用方式(用 traceId 链路追踪做示例——和上面的”用户串数据”不一样):

TransmittableThreadLocal<String> traceId = new TransmittableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(2);

// 用 TtlRunnable 包装任务
traceId.set("trace-req-001");
pool.submit(TtlRunnable.get(() -> {
log.info(traceId.get() + " 异步发短信"); // "trace-req-001" ✅
}));

traceId.set("trace-req-002");
pool.submit(TtlRunnable.get(() -> {
log.info(traceId.get() + " 异步发邮件"); // "trace-req-002" ✅ 线程池场景也正确!
}));

更优雅的方式——直接包装线程池——之后 submit 任务就完全无感知(用配置中心刷新场景示例):

ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(pool);
TransmittableThreadLocal<String> configCtx = new TransmittableThreadLocal<>();

// 模拟两次配置版本切换
configCtx.set("config-v123");
ttlPool.submit(() -> {
log.info("当前配置版本:" + configCtx.get()); // "config-v123" ✅
});

configCtx.set("config-v124");
ttlPool.submit(() -> {
log.info("当前配置版本:" + configCtx.get()); // "config-v124" ✅
});

这就是生产级方案——阿里内部大规模使用,Dubbo / SkyWalking / SOFA 等开源框架都依赖它做上下文传递。

三种方案对比

维度 ThreadLocal InheritableThreadLocal TransmittableThreadLocal
父→子传递 ❌ 不支持 ✅ 支持 ✅ 支持
线程池安全 — ❌ 串数据(P0 风险) ✅ 安全
使用复杂度 低 低 低(包装线程池后无感)
额外依赖 无 无 需要 transmittable-thread-local
生产推荐 仅当前线程 仅 new Thread() 场景 ✅ 通用推荐

90 分答案:TTL 在生产是怎么接入的

90 分的差异化在 3 个点——TTL 的实际接入姿势 + 性能影响 + CompletableFuture 的处理。

实战 1:链路追踪(traceId)的标准接入

链路追踪场景里 TTL 是事实标准——SkyWalking / MDC / Sleuth 全都用类似机制。

public class TraceContext {
private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>();

public static void set(String traceId) {
TRACE_ID.set(traceId);
MDC.put("traceId", traceId); // 让 logback 也能拿到
}

public static String get() {
return TRACE_ID.get();
}

public static void clear() {
TRACE_ID.remove();
MDC.remove("traceId");
}
}

关键 trick:在 OncePerRequestFilter 里 set,finally 里一定 clear——避免 servlet 容器线程复用时残留。

实战 2:多租户上下文的传递

多租户系统里每个请求都带 tenantId——业务方法里通过 ThreadLocal 拿到。异步调用 / 线程池跑批时必须传过去——否则 SQL 拼出来就漏了 WHERE tenant_id = ?——直接跨租户数据泄露。

public class TenantContext {
private static final TransmittableThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();

public static void set(Long tenantId) { TENANT_ID.set(tenantId); }
public static Long get() { return TENANT_ID.get(); }
public static void clear() { TENANT_ID.remove(); }
}

// 业务调用时
@Async("ttlAsyncExecutor") // 必须用 TTL 包装的线程池
public void processOrder(Long orderId) {
Long tenantId = TenantContext.get(); // 跨线程拿到原请求的 tenantId
// ...
}

配套必须:项目里所有 @Async 的 Executor 都要用 TtlExecutors.getTtlExecutorService() 包装——漏一个就是事故。CompletableFuture 不要走默认 commonPool——下面 “实战 3” 单独讲怎么处理。

实战 3:CompletableFuture 怎么传 TTL?

核心红线:别用 CompletableFuture 的默认 ForkJoinPool.commonPool()——它是 JVM 全局共享、没法直接拿来包 TTL,靠它跑业务异步几乎一定会丢上下文。两种正确解法:

// 方案 1:所有 CompletableFuture 显式传入 TTL 包装的业务线程池(推荐)
ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(myBizPool);
CompletableFuture.supplyAsync(() -> doWork(), ttlPool);

// 方案 2:用 TtlAgent 全局织入(更狠,零侵入)
// 启动时加 -javaagent:transmittable-thread-local-agent.jar
// 业务代码不用改,所有线程池(含 commonPool)自动具备 TTL 传递

生产推荐方案 1——显式传业务池,调用链可读、问题可排查;agent 方式威力大但调试时不直观,复杂业务排错麻烦——除非全公司统一基础设施才考虑。

性能影响:常规业务可忽略

每次提交任务做一次 capture / replay / restore——本质是几次 HashMap 操作。和业务逻辑比起来——这点开销基本可以忽略。TTL 官方文档的说法是”TPS 略有下降,常规业务场景不会构成性能瓶颈”——这是面试时该给的稳妥说法(别背”1% 以内”这种硬数字——版本不同 / 场景不同会有差异,被追问就翻车)。

直接掉分的 4 种答法

按踩到概率从高到低:

  • ❌ 「直接用 InheritableThreadLocal 就行」——直接进 reject 池。这答案在线程池场景是 P0 级事故的种子——面试官会追问”线程池下会怎样”——答不上来直接结束;
  • ❌ 「ThreadLocal 内存泄漏?所以不能用」——不对。ThreadLocal 内存泄漏的根因是 ThreadLocalMap 的 entry 没清理——不是 ThreadLocal 本身不能用——只要养成 try-finally remove() 习惯就没事;
  • ❌ 「全局静态变量 + synchronized 也行」——这答案显得完全没理解线程隔离的价值——会引出”那并发性能怎么办”的反追问;
  • ❌ 「让 Runnable 自己带参数就行」——理论上对,但侵入性极强——业务代码里所有要传上下文的方法都要改签名——实际生产里没人这么干。

高频追问怎么接

追问 1:ThreadLocal 会内存泄漏吗?

会。ThreadLocalMap 的 key 是 ThreadLocal 对象的弱引用、value 是强引用。如果 ThreadLocal 对象被回收了,key 变 null 但 value 还在——就泄漏了。

不过 ThreadLocal 在 get() / set() / remove() 时会顺带清理 key 为 null 的 entry。最佳实践——用完一定调 .remove()——最好放到 try-finally 里。

追问 2:TTL 性能影响有多大?

不构成常规业务瓶颈,通常可忽略。每次提交任务做 capture / replay / restore——本质是几次 HashMap 操作——和业务逻辑比起来开销可忽略。官方说法是”TPS 略有下降,常规场景不会有性能问题”——具体数字会随版本 / JDK / 场景变,别背死。

追问 3:CompletableFuture 怎么传上下文?

CompletableFuture 底层用 ForkJoinPool 或指定线程池——同样面临上下文丢失。两种解法:① 手动传 TtlExecutors 包装的线程池;② 用 TtlAgent 全局织入——生产推荐方案 ①。

追问 4:用 ThreadLocal 还要做什么”防御”?

3 件事必做:

  • try-finally remove()——防内存泄漏;
  • Filter 入口 set,出口 remove——防 Servlet 容器线程复用残留;
  • 所有 Executor 都用 TtlExecutors 包装——防异步链路上下文丢失。

我的判断

父子线程传递数据这道题——核心矛盾从来不是 JDK API 怎么用,是线程池场景下的上下文丢失谁来负责。

InheritableThreadLocal 是 JDK 的”自然演进”——但它停在了”new Thread() 时机”——这在 J2EE 时代够用,到了线程池 + 异步无处不在的现代 Java 项目里直接成了 P0 事故的种子。

阿里的 TransmittableThreadLocal 把”拷贝时机”挪到了 submit() 时机——精确解决了这个问题——捕获 / 恢复 / 还原三步机制。这是生产级答案——也是面试时和别人拉开差距的地方。

答这道题的关键不是答出 3 个方案的名字——是答出”为什么从 InheritableThreadLocal 演进到 TTL”。能把演进逻辑讲清楚 = 90 分稳了。

文章目录
  1. 1. 这道题面试官真正想筛什么
  2. 2. 30 分答案:3 种方案的名字过基础线
  3. 3. 60 分答案:讲清三种方案的演进逻辑
    1. 3.1. 起点:ThreadLocal 为什么不能跨线程?
    2. 3.2. 方案 1:InheritableThreadLocal——能跨 new Thread(),但坑大
    3. 3.3. 方案 2:TransmittableThreadLocal——专门治线程池场景
    4. 3.4. 三种方案对比
  4. 4. 90 分答案:TTL 在生产是怎么接入的
    1. 4.1. 实战 1:链路追踪(traceId)的标准接入
    2. 4.2. 实战 2:多租户上下文的传递
    3. 4.3. 实战 3:CompletableFuture 怎么传 TTL?
    4. 4.4. 性能影响:常规业务可忽略
  5. 5. 直接掉分的 4 种答法
  6. 6. 高频追问怎么接
    1. 6.0.1. 追问 1:ThreadLocal 会内存泄漏吗?
    2. 6.0.2. 追问 2:TTL 性能影响有多大?
    3. 6.0.3. 追问 3:CompletableFuture 怎么传上下文?
    4. 6.0.4. 追问 4:用 ThreadLocal 还要做什么”防御”?
  • 7. 我的判断