《Java 并发编程·线程与内存模型》7.1 ThreadLocal 的实现与内存泄漏

ThreadLocal 的哈希表、弱引用 key 与强引用 value 如何组合成一条泄漏路径。用反射读出 ThreadLocalMap 的 table 与 Entry 实测:6 个已失效的 ThreadLocal 仍留下 24 MiB 值,8 线程池不 remove 保留 32 MiB,remove 后为 0。

承接的问题

第 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
EntryMap 的数组元素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 会一起被回收,泄漏无从谈起。线程池改变了两件事:

  1. 线程生命周期被拉长到进程级。核心线程默认不超时,ThreadLocalMap 跟着线程活到底。
  2. 线程被复用。同一个线程会服务成百上千个任务,每个任务都有机会往里塞值。

于是「一个线程留一份值」变成「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 判断是否需要 removeget() 会触发 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 与线程池断裂

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收