承接的问题
第 6 章讲的是线程怎么停下来。这一章换个方向:线程活着的时候,怎么把「当前请求的上下文」绑在它身上。
ThreadLocal 是 Java 里最常用的线程封闭手段。它好用,也好错:几乎每个写过 Web 应用的人都听过「ThreadLocal 会内存泄漏」,但多数人记住的是一句口诀「用完记得 remove」,说不清漏在哪、漏多少、什么情况下不会漏。
这一节把三件事拆开:
- 值到底存在哪个对象里,
ThreadLocal本身存不存在值; - 弱引用 key 与强引用 value 的组合,为什么会产生「key 已经没了、value 还活着」的条目;
- 线程池为什么把这种条目从「几个字节」放大成「几十 MB」。
下一节讲跨线程继承,再下一节讲 JDK 25 里替代它的 ScopedValue。
值到底存在哪里
先纠正一个常见误解:ThreadLocal 对象不持有值。它只是一个 key,值存在线程自己的一个哈希表里。
三者关系是:
| 对象 | 持有者 | 内容 |
|---|---|---|
Thread | 线程对象自己 | 字段 threadLocals,类型 ThreadLocal.ThreadLocalMap |
ThreadLocalMap | 每个线程一个 | Entry[] table、size、threshold |
Entry | Map 的数组元素 | extends WeakReference<ThreadLocal<?>>,另有强引用字段 value |
用 javap 在本机 JDK 25 上确认:
$ javap -p java.lang.ThreadLocal
public class java.lang.ThreadLocal<T> {
private final int threadLocalHashCode;
private static final java.util.concurrent.atomic.AtomicInteger nextHashCode;
private static final int HASH_INCREMENT;
public T get();
public void set(T);
public void remove();
...
}
$ javap -p 'java.lang.ThreadLocal$ThreadLocalMap'
class java.lang.ThreadLocal$ThreadLocalMap {
private static final int INITIAL_CAPACITY;
private java.lang.ThreadLocal$ThreadLocalMap$Entry[] table;
private int size;
private int threshold;
private void set(java.lang.ThreadLocal<?>, java.lang.Object);
private void remove(java.lang.ThreadLocal<?>);
private int expungeStaleEntry(int);
private void resize();
}
$ javap -p 'java.lang.ThreadLocal$ThreadLocalMap$Entry'
class java.lang.ThreadLocal$ThreadLocalMap$Entry extends java.lang.ref.WeakReference<java.lang.ThreadLocal<?>> {
java.lang.Object value;
}
三个实现细节值得记住:
- 没有链表。
ThreadLocalMap用开放寻址:哈希冲突时线性探测下一个槽位,而不是像HashMap那样挂链表。 - 哈希是自增的。每个
ThreadLocal实例构造时拿一个threadLocalHashCode,每次递增一个固定常量(黄金分割数0x61c88647),让 key 在 2 的幂长度的表里分布均匀。 - 容量是 2 的幂。初始 16,扩容后翻倍;阈值是容量的三分之二。
本机用反射读出这三个字段:
after set #1: tableLen=16 size=1 threshold=10
after set #6: tableLen=16 size=6 threshold=10
after set #10: tableLen=32 size=10 threshold=21
after set #12: tableLen=32 size=12 threshold=21
第 10 次写入触发了扩容(16 × 2/3 ≈ 10),表和阈值一起翻倍。这段数字本身不稀奇,但它说明一件事:ThreadLocalMap 是一个长期存活、只增不减的数组——删条目不会缩容。这是后面泄漏放大的结构基础。
弱引用 key 与强引用 value
Entry 的设计是整条泄漏路径的起点:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 强引用
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v;
}
}
为什么 key 要用弱引用?因为反过来的写法会漏得更彻底:如果 Entry 强引用 ThreadLocal,那么只要线程活着,ThreadLocal 对象就永远无法回收,即使业务代码早就把它丢弃了。用弱引用,至少 key 能被 GC 清掉。
但 value 是强引用。于是出现这个状态:key 被回收成 null,value 还挂在 Entry 上。这种条目叫 stale entry(陈旧条目)。它已经不可能被 get 命中了——没有 key 就没法定位——但它的 value 仍然占用堆。
关键在于:清理由谁做? 不是 GC,是 ThreadLocalMap 自己的几个方法:
| 操作 | 时机会 | 清理范围 |
|---|---|---|
get | 探测路径上遇到 stale entry | 只清探测链上的一段 |
set | 命中 stale 或需要扩容 | cleanSomeSlots 启发式清理 |
remove | 显式调用 | 清掉指定 key 的条目 |
也就是说,没有后续操作,就没有清理。一个长时间不再访问该 ThreadLocal 的线程,会让 stale entry 连同 value 一起无限期保留。
实测。让池线程依次创建 6 个局部 ThreadLocal,每个塞 4 MiB,方法返回后除了 Entry 里的弱引用再无强引用,然后 System.gc():
static void setFresh(int n) {
// 局部 ThreadLocal:方法返回后,除了 Entry 里的弱引用,没有任何强引用
ThreadLocal<byte[]> tl = new ThreadLocal<>();
tl.set(new byte[4 * 1024 * 1024]);
System.out.println("iter " + n + " set on " + Thread.currentThread().getName());
}
static Object mapOf(Thread t) throws Exception {
Field f = Thread.class.getDeclaredField("threadLocals");
f.setAccessible(true);
return f.get(t);
}
static Object[] tableOf(Object map) throws Exception {
Field f = map.getClass().getDeclaredField("table");
f.setAccessible(true);
return (Object[]) f.get(map);
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
for (int i = 0; i < 6; i++) {
int n = i;
pool.submit(() -> setFresh(n)).get();
}
Thread worker = Thread.getAllStackTraces().keySet().stream()
.filter(t -> t.getName().startsWith("pool-")).findFirst().orElseThrow();
System.gc();
Thread.sleep(200);
Object map = mapOf(worker); // Thread.threadLocals
Object[] table = tableOf(map); // ThreadLocalMap.table
int entries = 0, stale = 0;
long staleBytes = 0;
for (Object e : table) {
if (e == null) continue;
entries++;
WeakReference<?> ref = (WeakReference<?>) e;
Field vf = e.getClass().getDeclaredField("value");
vf.setAccessible(true);
Object val = vf.get(e);
if (ref.get() == null && val != null) { // key 没了,value 还在
stale++;
if (val instanceof byte[] b) staleBytes += b.length;
}
}
System.out.println("tableLen=" + table.length + " entries=" + entries
+ " stale(value alive, key cleared)=" + stale
+ " staleMiB=" + (staleBytes / 1024 / 1024));
pool.shutdown();
}
本机输出(--add-opens java.base/java.lang=ALL-UNNAMED -Xmx512m):
iter 0 set on pool-1-thread-1
...
iter 5 set on pool-1-thread-1
tableLen=16 entries=6 stale(value alive, key cleared)=6 staleMiB=24
6 个条目全部是 stale:key 已经被 GC 清成 null,24 MiB 的 value 一个都没少。这里的结论不是「弱引用没用」,而是弱引用只解决了 key 的泄漏,没解决 value 的泄漏。
顺便说一个反直觉的观察:这 6 个 value 在下一次 set 时可能被 expungeStaleEntry 顺带清掉。我在 -Xmx24m 下重跑同一段代码,GC 压力变大、set 更频繁,最后只剩 2 个 stale 条目、8 MiB。泄漏是否显现,取决于后续操作是否恰好触发了清理——这解释了为什么这类问题常常「测不出来」,却在线上慢慢涨。
线程池为什么把泄漏放大
如果线程创建后很快结束,线程对象和它的 ThreadLocalMap 会一起被回收,泄漏无从谈起。线程池改变了两件事:
- 线程生命周期被拉长到进程级。核心线程默认不超时,
ThreadLocalMap跟着线程活到底。 - 线程被复用。同一个线程会服务成百上千个任务,每个任务都有机会往里塞值。
于是「一个线程留一份值」变成「N 个线程各留一份值」。用 8 个线程的固定池,每个任务塞 4 MiB,一共跑 24 个任务(每个线程轮 3 次),对比 remove 与不 remove:
static final ThreadLocal<byte[]> CACHE = new ThreadLocal<>();
for (int round = 0; round < 3; round++) {
for (int i = 0; i < 8; i++) {
pool.submit(() -> {
CACHE.set(new byte[4 * 1024 * 1024]); // 每线程 4 MiB
if (remove) CACHE.remove();
}).get();
}
}
本机输出:
$ java --add-opens java.base/java.lang=ALL-UNNAMED -Xmx512m -cp out LeakDemo
all 24 tasks done, remove=false, poolSize=8
poolThreads=8 withMap=8 retainedMiB=32
$ java --add-opens java.base/java.lang=ALL-UNNAMED -Xmx512m -cp out LeakDemo remove
all 24 tasks done, remove=true, poolSize=8
poolThreads=8 withMap=8 retainedMiB=0
32 MiB 是最坏情况的上界:8 个线程各留一份 4 MiB,remove 之后归零。注意这里每个线程只有一个 ThreadLocal;如果框架里每个请求都创建一个新 ThreadLocal(比如按租户、按 traceId 动态建),泄漏量会按条目数继续累加,直到触发扩容。
还有一个更隐蔽的版本:ThreadLocal 是静态字段、长期存活,看起来「key 不会失效,所以不会 stale」。但这种写法一旦 value 是大对象,池线程会一直持有最后一任任务的 value——不 stale,但同样不释放。这就是很多连接、缓冲区、用户对象被池线程钉住的原因。
remove 的正确时机
结论很直接:谁 set,谁负责在 finally 里 remove。位置只有两个是对的:
// 一、业务代码自己成对使用
CACHE.set(payload);
try {
handle(payload);
} finally {
CACHE.remove(); // 必须放在 finally
}
// 二、框架层统一包装,业务代码不碰 ThreadLocal
public final class ContextualTask implements Runnable {
private final String memberId;
private final Runnable delegate;
public ContextualTask(String memberId, Runnable delegate) {
this.memberId = memberId;
this.delegate = delegate;
}
@Override
public void run() {
MemberContext.set(memberId);
try {
delegate.run();
} finally {
MemberContext.remove(); // 而不是 set(null)
}
}
}
几个容易写错的点:
| 写法 | 问题 |
|---|---|
finally { ctx.set(null); } | 条目还在,只是 value 变 null;下次 get 返回 null 而不是初始值 |
| 只在正常路径 remove | 异常路径漏掉,恰好是出错时最容易积压的路径 |
| 在任务开头 remove | 清掉的是上一个任务的值,自己的值仍然留下 |
用 get() != null 判断是否需要 remove | get() 会触发 setInitialValue(),反而创建条目 |
| 把 remove 写在另一个线程 | ThreadLocalMap 属于线程,删不到目标线程的条目 |
remove() 的实现是「找到 key 对应的槽位、把 value 置空、把 key 的弱引用清空、再 expunge 一段探测链」。它比 set(null) 多做了一步断开引用,这是两者唯一的、也是关键的差别。
在 libraryd-conc 里落地
借阅服务里最典型的上下文是「当前会员」。把它做成静态 ThreadLocal 并在入口统一清理:
public final class MemberContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private MemberContext() { }
public static void set(String memberId) {
CURRENT.set(memberId);
}
public static String current() {
return CURRENT.get();
}
public static String require() {
String id = CURRENT.get();
if (id == null) {
throw new LoanException(ErrorCode.CONFLICT, "缺少会员上下文");
}
return id;
}
public static void clear() {
CURRENT.remove();
}
}
配套的任务包装器:
public final class MemberScopedTask implements Runnable {
private final String memberId;
private final Runnable delegate;
public MemberScopedTask(String memberId, Runnable delegate) {
this.memberId = memberId;
this.delegate = delegate;
}
@Override
public void run() {
MemberContext.set(memberId);
try {
delegate.run();
} finally {
MemberContext.clear();
}
}
}
在 PooledLoanService 里提交任务时统一包一层:
public CompletableFuture<Reservation> reserveAsync(String isbn, String memberId) {
CompletableFuture<Reservation> future = new CompletableFuture<>();
executor.execute(new MemberScopedTask(memberId, () -> {
try {
future.complete(reserve(isbn, MemberContext.require()));
} catch (Throwable t) {
future.completeExceptionally(t);
}
}));
return future;
}
这段代码同时回答了两个问题:上下文怎么进线程(set),以及怎么保证不残留(finally remove)。第 13 章讲 CompletableFuture 的上下文传递时,会看到同样的包装思路。
常见错误与边界
一、什么时候不要用 ThreadLocal。 异步链路(值需要跨线程池传递)、任务粒度细且数量巨大(虚拟线程场景,第 14.3 节展开)、需要不可变快照的场景,都不适合。前者用显式参数或 ScopedValue,后者见 7.3。7.3 会说明它在 JDK 25 已转正,但配套的 StructuredTaskScope 仍是 preview。
二、不要用它缓存大对象。 缓存的语义是「有上限、可淘汰」,ThreadLocalMap 两样都没有。用它缓存 byte[]、Connection、解析后的文档,等于把对象生命周期绑在线程上。
三、InheritableThreadLocal 不是解药。 它在线程创建时复制一次,线程池复用时会串值,7.2 节用实测展开。
四、虚拟线程改变了成本模型。 平台线程数量有限(几百个),一份泄漏乘以几百还可控;虚拟线程可以有几百万个,每个都带 ThreadLocalMap 的话,内存会以完全不同的量级增长。JDK 25 对虚拟线程的 ThreadLocal 做了延迟分配等优化,但「每个线程一份值」的基本语义没变。
五、ThreadLocal 不是同步工具。 它保证的是「每个线程看到自己的副本」,不是「多线程安全地共享一个值」。用它来代替锁,只会把共享状态藏起来,问题留到更难排查的地方。
小结
ThreadLocal只是 key,值存在Thread.threadLocals指向的ThreadLocalMap里;表是开放寻址的 2 的幂数组,初始 16、阈值 2/3,只扩容不缩容。Entry继承WeakReference<ThreadLocal>,key 弱引用、value 强引用。key 被 GC 后留下 stale entry,value 依然占堆。- 清理只发生在
get/set/remove的探测路径上,没有后续操作就没有清理。实测 6 个失效 key 留下 24 MiB。 - 线程池把「每线程一份」放大成「N 线程各一份」,8 线程 × 4 MiB 实测 32 MiB;
remove后为 0。 remove要放在finally,并且与set在同一个线程;set(null)不等于remove。
下一节看「让子线程自动继承」这条看起来更省事的路,为什么在线程池上必然断裂。
阅读导航:上一节 6.3 shutdown hook 与释放顺序 | 下一节 7.2 InheritableThreadLocal 与线程池断裂
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。