承接的问题
7.1 节说清了 ThreadLocal 的边界:值只属于当前线程,别的线程读不到。于是很自然会有下一个需求——子线程要能看到父线程的上下文。
InheritableThreadLocal 看起来正是为此而生:父线程 set,子线程 get,不用传参。它的名字里就写着「可继承」。
这一节要说明的是:这个机制成立的前提非常窄——只在线程被创建的那一刻复制一次。在线程池里,线程的创建时刻和任务的执行时刻早就脱钩了,于是继承语义变成两种事故:读到别人的值(串值),或者读不到自己的值(丢失)。
理解这一点比记住「线程池别用 InheritableThreadLocal」重要,因为同样的推理能解释 MDC、SecurityContext、TransactionSynchronization 这些框架上下文为什么都要额外的传递机制。
复制发生在创建线程的那一刻
先看机制。Thread 的构造函数最终都会走到一个 init 方法,里面有一段逻辑:
// Thread.init 的语义(伪代码,非源码原文)
if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
createInheritedMap 遍历父线程的 inheritableThreadLocals 表,对每个有效条目调用 childValue(value),建一张新表挂到子线程上。InheritableThreadLocal.childValue 的默认实现是直接返回同一个引用:
protected T childValue(T parentValue) {
return parentValue; // 浅复制:父子共享同一个对象
}
这有两个直接后果:
- 复制的是引用,不是对象。父线程改可变对象的内容,子线程看得到;要真正的隔离得重写
childValue。 - 复制是一次性的。线程启动之后再改父线程的值,子线程不会跟着变——它手里是自己那张表的副本。
可以关掉继承:Thread.ofPlatform().inheritInheritableThreadLocals(false),或者 Thread 的带 inheritThreadLocals 参数的构造函数。Executors.defaultThreadFactory() 用的是默认继承,所以线程池默认会复制。
复制的是创建那一刻的快照
第一个实测:父线程先设为 v1,创建子线程,然后在子线程真正读取之前把父线程改成 v2。
static final InheritableThreadLocal<String> MEMBER = new InheritableThreadLocal<>();
MEMBER.set("v1");
Thread child = new Thread(() -> {
try { Thread.sleep(100); } catch (InterruptedException ignored) { }
System.out.println("child thread=" + Thread.currentThread().getName()
+ " value=" + MEMBER.get());
}, "child-1");
child.start();
MEMBER.set("v2"); // 创建之后才改
child.join();
本机输出:
child thread=child-1 value=v1
子线程读到 v1。注意时序:child.start() 之后、子线程 sleep 期间父线程改成了 v2,但子线程醒来读到的仍是 v1。因为复制发生在 new Thread(...) 的构造过程里,start() 之前就完成了。
这不是「可见性」问题,InheritableThreadLocal 也不提供任何跨线程同步语义。它就是一张在建线程时拍下来的快照。
线程池里为什么必然断裂
第二个实测,也是这一节的核心。固定大小 1 的线程池,主线程依次用三个不同的会员号提交任务:
static final InheritableThreadLocal<String> MEMBER = new InheritableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1);
MEMBER.set("member-A");
pool.submit(() -> print("req A")).get(); // 池线程在此创建,复制 member-A
MEMBER.set("member-B");
pool.submit(() -> print("req B")).get(); // 复用同一个池线程
MEMBER.set("member-C");
pool.submit(() -> print("req C")).get();
static void print(String tag) {
System.out.println(tag + " thread=" + Thread.currentThread().getName()
+ " value=" + MEMBER.get());
}
本机输出:
== 池复用:后续请求读到第一个请求的值 ==
req A thread=pool-1-thread-1 value=member-A
req B thread=pool-1-thread-1 value=member-A
req C thread=pool-1-thread-1 value=member-A
B 和 C 打印的都是 value=member-A。原因只有一句话:池线程是在 A 提交时创建的,之后再也没有被创建过。
时间线拆开看:
| 时刻 | 主线程动作 | 池线程状态 | 池线程读到的值 |
|---|---|---|---|
| t1 | set(member-A),提交 A | 不存在,被创建,复制 member-A | member-A |
| t2 | set(member-B),提交 B | 已存在,直接复用 | member-A |
| t3 | set(member-C),提交 C | 已存在,直接复用 | member-A |
这就是「串值」:B 的请求读到了 A 的身份。在真实系统里,这意味着用 A 的权限借了 B 的书,或者把 A 的日志写进了 B 的链路。
两种失败形态
InheritableThreadLocal 在线程池上的问题不只是「读到旧值」,它可以分成两类:
| 形态 | 表现 | 根因 |
|---|---|---|
| 串值(脏读) | 请求 B 读到请求 A 的值 | 池线程由 A 创建,之后一直携带 A 的快照 |
| 丢失(读不到) | 请求 B 读到 null 或初始值 | 池线程由更早的、没有设值的代码创建,或创建时父线程恰好没有值 |
| 残留(泄漏) | 无人使用却一直持有对象 | 池线程长期持有创建者上下文的引用,7.1 的泄漏在此重演 |
| 半更新 | 读到 member-A + tenant-B 的混合 | 多个 InheritableThreadLocal 在不同时刻被复制/修改 |
「丢失」比「串值」更难查:串值至少还有一个错误的值可以追,丢失往往表现为「偶尔取不到用户」,重试又好了。
初始化时机也很关键。线程池通常是懒创建:第一个任务提交时才建核心线程。如果第一个任务恰好来自一个没有设置上下文的健康检查或预热调用,那么之后所有任务都可能继承到空值。
显式传参替代
既然复制时机不可控,正确做法是让上下文的生命周期跟着任务走,而不是跟着线程走。三种写法,可靠性依次递增。
一、方法参数
最笨也最可靠。把 memberId 作为参数一路传到需要的地方:
public interface LoanService {
Reservation reserve(String isbn, String memberId); // 已有签名
}
领域方法本来就需要知道「谁在借」,把它放进参数不是负担,而是让依赖显式化。问题是跨越多层框架代码时,参数会变成一条长长的传递链。
二、提交侧捕获,执行侧设置
在提交任务的那个线程里读值,把它捕获进闭包,再在执行线程里设置和清理:
public final class ContextualRunnable implements Runnable {
private final String memberId;
private final Runnable delegate;
public ContextualRunnable(String memberId, Runnable delegate) {
this.memberId = memberId;
this.delegate = delegate;
}
@Override
public void run() {
String previous = MEMBER.get();
MEMBER.set(memberId);
try {
delegate.run();
} finally {
if (previous == null) {
MEMBER.remove();
} else {
MEMBER.set(previous); // 恢复,而不是无脑 remove
}
}
}
}
使用:
MEMBER.set("member-B");
String captured = MEMBER.get(); // 在提交线程上捕获
pool.submit(new ContextualRunnable(captured, () -> {
System.out.println("manual thread=" + Thread.currentThread().getName()
+ " value=" + MEMBER.get());
})).get();
本机输出:
== 手动透传:值随任务走,不随线程走 ==
manual thread=pool-1-thread-1 value=member-B
同一个池线程,这次读到的是 member-B。差别不在线程,在于值是从提交侧捕获、随任务对象带进来的。
finally 里恢复而不是直接 remove,是为了支持嵌套提交:外层任务设置了值,内层任务结束后应该回到外层的值,而不是把外层的上下文一起清掉。
三、显式上下文对象 + 包装 Executor
把捕获逻辑收进 Executor 装饰器,业务代码就完全看不到 ThreadLocal:
public final class ContextPropagatingExecutor implements Executor {
private final Executor delegate;
public ContextPropagatingExecutor(Executor delegate) {
this.delegate = delegate;
}
@Override
public void execute(Runnable command) {
String memberId = MEMBER.get(); // 调用方线程捕获
delegate.execute(new ContextualRunnable(memberId, command));
}
}
这三种写法的共同点是:上下文在提交侧确定,随任务传递。InheritableThreadLocal 的问题恰恰是它在创建侧确定,而创建侧和提交侧不是一回事。
在 libraryd-conc 里落地
把「当前请求」建模成一个不可变 record,显式传参,不做隐式继承:
public record RequestContext(String requestId, String memberId, String tenantId) {
public RequestContext {
Objects.requireNonNull(requestId, "requestId");
Objects.requireNonNull(memberId, "memberId");
}
}
服务层签名随之调整:
public final class PooledLoanService implements LoanService {
private final BookRepository books;
private final LoanRepository loans;
private final ExecutorService pool;
public CompletableFuture<Reservation> reserveAsync(RequestContext ctx, String isbn) {
return CompletableFuture.supplyAsync(() -> reserve(ctx, isbn), pool);
}
public Reservation reserve(RequestContext ctx, String isbn) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Reservation r = new Reservation(
UUID.randomUUID().toString(), isbn, ctx.memberId(),
Instant.now().plusSeconds(86400));
loans.save(new Loan(r.loanId(), isbn, ctx.memberId(), r.dueAt()));
return r;
}
@Override
public Reservation reserve(String isbn, String memberId) {
return reserve(new RequestContext(UUID.randomUUID().toString(), memberId, "default"), isbn);
}
}
如果确实需要给日志框架提供 MDC,用装饰器把 ctx 写进 MDC 并在 finally 清理,而不是让业务代码依赖 InheritableThreadLocal。第 13.3 节会把「上下文传递与线程池隔离」展开成完整的装饰器方案。
常见错误与边界
一、不要指望 InheritableThreadLocal 适配线程池。 池线程的创建时机由池决定,与你提交任务的时机无关。任何「依赖创建时机」的机制在池里都不可靠。
二、不要在池线程里 set 之后不清理。 这会同时造成串值和泄漏:下一个任务读到上一个任务的值,且对象一直被持有。
三、childValue 默认是浅复制。 父子共享同一个可变对象;要隔离就重写 childValue 做深复制,但深复制有成本,且容易复制出不一致的快照。
四、remove 不能替代恢复。 在嵌套提交场景里,内层清理会破坏外层上下文。要么保存并恢复旧值,要么用不可变上下文重新绑定。
五、什么时候可以继续用: 线程生命周期与请求生命周期一致的场景,比如命令行工具里「主线程设配置、启动一批工作线程后就不再改」。这时复制一次的快照恰好是想要的行为。
六、异步链路(CompletableFuture、反应式)完全不适用。 任务可能在任意线程、任意时刻执行,甚至连「父线程」都不存在。第 13 章会给出显式上下文对象的写法。
小结
InheritableThreadLocal在Thread构造时把父线程的表复制一份,复制的是引用,且只发生一次。- 复制的是创建那一刻的快照:父线程之后改值,子线程看不到(实测子线程读到
v1,父线程已是v2)。 - 线程池复用线程,创建时机与提交时机脱钩,必然产生串值。实测同一个池线程连续服务 A/B/C 三个请求,读到的都是
member-A。 - 失败形态有四种:串值、丢失、残留、半更新;后两种更隐蔽。
- 替代方案是显式上下文:方法参数、提交侧捕获 +
finally恢复、Executor装饰器。原则是「上下文随任务走,不随线程走」。 - 线程生命周期与请求生命周期一致的批处理场景,仍可谨慎使用。
下一节看 JDK 25 的第三代方案 ScopedValue:它用词法作用域取代线程绑定,并且不会被普通线程继承——这恰好堵住了这一节的串值路径。ScopedValue 本身已转正,但配套的 StructuredTaskScope 仍是 preview,用法和参数都要分开看。
阅读导航:上一节 7.1 ThreadLocal 的实现与内存泄漏 | 下一节 7.3 ScopedValue:JDK 25 的作用域值
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。