Lesson 18 · 并发编程

ThreadLocal 原理与内存泄漏:弱引用的坑

高级·⭐ 必问·#并发·#核心·#内存

第 1 站

线上 OOM 事故:200 万个幽灵对象

凌晨三点,监控报警。某电商核心服务的 JVM Heap 使用率从 40% 一路飙升到 96%,Full GC 频繁触发但回收不了多少空间。最终服务 OOM 宕机。

incident.log · 事故现场日志
// 服务运行 3 天后,运维导出 Heap Dump(4.2GB)
// MAT (Memory Analyzer Tool) 分析结果:

Dominator Tree:
  ThreadLocalMap$Entry[]          retained: 3.1 GB
    └── Entry[0..1999999]         retained: 2.8 GB
         └── UserContext × 2000000  ← 200 万个用户上下文对象!
              ├── userId: Long
              ├── tenantId: String
              └── permissions: List<String>

// 根因:Filter 中 set 了 ThreadLocal,但忘了 remove
// 线程池中的线程被复用,Entry 永远不会被清理

事故根因:开发者在请求 Filter 中用 ThreadLocal 存储了用户上下文信息,但没有在 finally 块中调用 remove()。由于 Tomcat 使用线程池,线程不会被销毁,ThreadLocalMap 中的 Entry 永远不会被清理,UserContext 对象越积越多,最终 OOM。

为什么不是强引用导致 Key(ThreadLocal 对象)无法回收?Entry 的 Key 不是弱引用吗?

弱引用只能保证 ThreadLocal 实例本身被回收,但 Entry 中的 Value 是强引用——只要线程不死,Value 就永远无法被 GC。这就是 ThreadLocal 内存泄漏的核心矛盾,后面第 6 站会详细分析。
第 2 站

ThreadLocal 四大经典场景

ThreadLocal 的本质是线程隔离的存储——每个线程拥有自己独立的变量副本,互不干扰。它在生产中有四大典型用途:

场景问题ThreadLocal 方案
数据库连接一个请求可能需要多次 DB 操作,每次都新建连接太贵一个线程绑定一个 Connection,整个请求复用
SimpleDateFormatSDF 不是线程安全的,共享实例会日期错乱每个线程持有自己的 SDF 实例
请求上下文传播用户信息需要从 Controller 传到 Service 再到 DAOSpring Security 的 SecurityContextHolder 底层用 ThreadLocal
链路追踪 TraceId日志需要携带 traceId 串联完整调用链MDC(Mapped Diagnostic Context)底层用 ThreadLocal 存 traceId
UsageScenarios.java · 四种场景示例
/* ─── 场景 1:数据库连接管理(类似 Spring 的实现思路) ─── */
public class DBUtil {
    private static final ThreadLocal<Connection> connHolder = new ThreadLocal<>();

    public static Connection getConnection() {
        Connection conn = connHolder.get();
        if (conn == null) {
            conn = dataSource.getConnection();
            connHolder.set(conn);
        }
        return conn;
    }

    public static void close() {
        Connection conn = connHolder.get();
        if (conn != null) { conn.close(); connHolder.remove(); }
    }
}

/* ─── 场景 2:SimpleDateFormat 线程安全 ─── */
private static final ThreadLocal<SimpleDateFormat> sdfHolder =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

String formatted = sdfHolder.get().format(new Date());

/* ─── 场景 3:请求上下文(Spring Security 的思路) ─── */
public class UserContext {
    private static final ThreadLocal<UserInfo> holder = new ThreadLocal<>();
    public static void set(UserInfo user) { holder.set(user); }
    public static UserInfo get() { return holder.get(); }
    public static void clear() { holder.remove(); }
}

/* ─── 场景 4:MDC TraceId ─── */
MDC.put("traceId", UUID.randomUUID().toString());
// logback 日志格式: %d [%X{traceId}] %-5level %msg%n
第 3 站

内部实现:Thread → ThreadLocalMap → Entry[]

ThreadLocal 的数据并不存储在 ThreadLocal 对象本身中,而是存储在每个 Thread 对象内部的 ThreadLocalMap 中。理解这个结构是理解一切问题的前提:

ThreadLocal 内部数据结构全景 Thread (当前线程) name: "http-nio-8080-1" threadLocals: ThreadLocalMap ThreadLocalMap size: 3 threshold: 10 Entry[] table Entry[16] [0] = Entry(userCtx) [1] = null [7] = Entry(sdf) 单个 Entry 的结构 ThreadLocalMap.Entry extends WeakReference<ThreadLocal<?>> key = ThreadLocal 实例(弱引用) value = 实际存储值(强引用) 关键引用链 Thread ──(强引用)──▶ ThreadLocalMap ──(强引用)──▶ Entry[] ──(强引用)──▶ Value Entry ──(弱引用)──▶ ThreadLocal(Key 可被 GC 回收,但 Value 不能!)
图 1ThreadLocal 内部结构:Thread 持有 ThreadLocalMap,Map 内部是 Entry[] 数组,每个 Entry 的 Key 是弱引用指向 ThreadLocal,Value 是强引用指向实际值
为什么 Map 挂在 Thread 上而不是 ThreadLocal 上?

如果 Map 挂在 ThreadLocal 上(Map<Thread, Value>),那么所有线程共享一个 Map,需要处理并发问题(加锁),而且 Thread 对象会作为 Key 被强引用,导致线程无法被回收。

把 Map 挂在 Thread 上(Map<ThreadLocal, Value>),天然线程隔离无需加锁,而且 ThreadLocal 作为弱引用 Key 可以被 GC。这是 Josh Bloch 和 Doug Lea 的设计选择。

第 4 站

源码剖析:set() 与 get() 的完整流程

面试高频题:"说说 ThreadLocal 的 set/get 原理"。来看 JDK 源码(带关键注释):

ThreadLocal.java · set() 方法源码
public void set(T value) {
    // 1. 获取当前线程
    Thread t = Thread.currentThread();

    // 2. 获取该线程的 ThreadLocalMap
    ThreadLocalMap map = getMap(t);  // return t.threadLocals;

    if (map != null) {
        // 3. 以 this(当前 ThreadLocal 实例)为 key 存入 map
        map.set(this, value);
    } else {
        // 4. 首次使用,为线程创建 ThreadLocalMap
        createMap(t, value);
    }
}

// ThreadLocalMap.set() 内部逻辑(简化):
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    // 用 ThreadLocal 的 threadLocalHashCode 计算槽位
    int i = key.threadLocalHashCode & (len - 1);

    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();  // 弱引用获取 key

        if (k == key)        // 找到同一个 ThreadLocal → 更新 value
            { e.value = value; return; }
        if (k == null)       // key 已被 GC → 替换过期 Entry
            { replaceStaleEntry(key, value, i); return; }
    }
    // 没找到 → 新建 Entry
    tab[i] = new Entry(key, value);
    // 检查是否需要扩容(threshold = len * 2/3)
    if (++size >= threshold) rehash();
}
ThreadLocal.java · get() 方法源码
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);

    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T) e.value;
            return result;
        }
    }
    // map 为空 或 没找到 Entry → 调用 initialValue()
    return setInitialValue();
}

private T setInitialValue() {
    T value = initialValue();  // 默认返回 null
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}
Hash 冲突处理:线性探测法(非链地址法)

ThreadLocalMap 不用 HashMap 的链表解决冲突,而是用开放寻址:冲突时向后找下一个空槽。
原因:Entry 数量通常很少(一个线程一般就几个 ThreadLocal),开放寻址在小表上更高效,且避免了链表节点的额外内存开销。
threadLocalHashCode 是怎么算出来的?为什么不用普通的 hashCode()?

每个 ThreadLocal 实例有一个 threadLocalHashCode,由一个 AtomicInteger 累加 0x61c88647(黄金比例的哈希增量)生成。这保证了同一线程上多个 ThreadLocal 的 hash 值在数组上均匀分散,大幅减少线性探测的次数。
第 5 站

弱引用的设计考量:两害相权取其轻

面试官最爱追问:"ThreadLocal 的 Entry 为什么用弱引用做 Key?" 这需要从生命周期管理的角度来分析。

WeakRefDemo.java · Java 四种引用类型回顾
// 强引用:只要引用存在,GC 绝不回收
Object obj = new Object();   // obj 是强引用

// 软引用:内存不足时才回收(适合做缓存)
SoftReference<Object> soft = new SoftReference<>(obj);

// 弱引用:下次 GC 必定回收(不管内存够不够)
WeakReference<Object> weak = new WeakReference<>(obj);

// 虚引用:随时可回收,无法通过它获取对象(仅用于跟踪回收)
PhantomReference<Object> phantom = new PhantomReference<>(obj, queue);

假设 Entry 的 Key 用强引用

引用类型ThreadLocal 实例能否被 GC?后果
强引用(假设)不能。Entry.key 强引用 ThreadLocal,只要线程活着,ThreadLocal 就无法回收ThreadLocal 对象 + Value 都泄漏
弱引用(实际)能。当外部不再持有 ThreadLocal 强引用时,下次 GC 会回收 ThreadLocal 实例ThreadLocal 本身可回收,但 Value 仍有泄漏风险(见第 6 站)
弱引用不是万能的——它只解决了一半问题

弱引用保证了:当你的代码中不再持有 ThreadLocal<UserContext> 这个变量的引用时(比如它是一个局部变量,方法结束后就没了),ThreadLocal 实例本身可以被 GC 回收。

但问题是:Entry 中的 value 字段是强引用。即使 key 变成了 null(ThreadLocal 被回收了),value 依然被 Entry 强引用着。这就是著名的 "key 为 null 的脏 Entry" 问题。

引用关系总结:
Thread ──强引用──▶ ThreadLocalMap ──强引用──▶ Entry ──强引用──▶ Value(不可回收!)
Entry ──弱引用──▶ ThreadLocal(可回收,但回收后 key=null,Value 孤立)
第 6 站

内存泄漏详解:脏 Entry 与被动清理

ThreadLocal 内存泄漏的完整发生条件: ThreadLocal 实例不再被外部引用 + 线程仍然存活(线程池)+ 后续没有 get/set 触发清理

内存泄漏时的引用链状态 ① 正常使用时 Thread Entry ThreadLocal UserContext static ThreadLocal<> TL 外部持有强引用 → ThreadLocal 不会被 GC 一切正常 ✓ ② ThreadLocal 被 GC 后(内存泄漏!) Thread(线程池) Entry(脏) 弱 ✗ 已被 GC 回收 null(已回收) 强! UserContext 泄漏! Key = null,无法定位 Value 被强引用,无法 GC 泄漏条件:ThreadLocal 被 GC + 线程池中线程不销毁 + 没有后续 get/set 触发清理 线程池中线程长期存活,Value 对象永远无法被垃圾回收 → 内存持续增长 → OOM
图 2内存泄漏引用链:ThreadLocal 被 GC 后,Entry 的 key 变为 null,但 value 仍被强引用,线程池中线程不死则 value 永远无法回收

JDK 设计者意识到了这个问题,所以在 ThreadLocalMap 的 get()set()remove() 方法中加入了被动清理机制

ThreadLocalMap.java · 被动清理逻辑(简化)
// getEntry 时会顺手清理脏 Entry
private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];

    if (e != null && e.get() == key) {
        return e;  // 直接命中
    } else {
        // 线性探测过程中,遇到 key=null 的脏 Entry 会调用清理
        return getEntryAfterMiss(key, i, e);
        // getEntryAfterMiss 内部调用 expungeStaleEntry(i)
    }
}

// expungeStaleEntry:清理指定位置的脏 Entry 及其后续的连续段
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 1. 清除脏 Entry 的 value 引用
    tab[staleSlot].value = null;  // ← 解除强引用,value 可被 GC
    tab[staleSlot] = null;        // ← 清除整个 Entry
    size--;

    // 2. 继续向后扫描,重新放置被挤偏的 Entry 或清理更多脏 Entry
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {  // 又一个脏 Entry
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // 重新 hash 这个 Entry,放到正确位置
        }
    }
    return i;
}
被动清理够用吗?什么时候会失效?

够用:如果你的 ThreadLocal 是 static final(常见写法),它永远不会被 GC,Key 不会变 null,不存在泄漏问题。

不够用:如果 ThreadLocal 是局部变量或方法参数中创建的(用完就没人引用了),且线程池中的线程后续不再调用 get/set(比如该业务逻辑不再触发),那脏 Entry 就永远不会被清理。

结论:被动清理是"保险丝",不是"安全网"。你必须主动调用 remove()
第 7 站

解决方案:remove()、InheritableThreadLocal 与 TTL

方案一:永远在 finally 中调用 remove()——这是最基本也是最重要的规则。

CorrectPattern.java · ThreadLocal 正确使用模板
public class UserContextFilter implements Filter {
    private static final ThreadLocal<UserContext> USER_HOLDER = new ThreadLocal<>();

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp,
                         FilterChain chain) throws IOException {
        try {
            UserContext ctx = parseUser(req);
            USER_HOLDER.set(ctx);  // 设置上下文
            chain.doFilter(req, resp);
        } finally {
            USER_HOLDER.remove();  // ← 必须在 finally 中清理!
            // 不能用 remove() 以外的方法,不能用 clear()
            // 不能漏掉,不能放在 try 块中(异常时会跳过)
        }
    }
}

方案二:InheritableThreadLocal——解决父子线程传递问题。

InheritableDemo.java · 子线程继承父线程的 ThreadLocal
ThreadLocal<String> tl = new InheritableThreadLocal<>();
tl.set("main-thread-value");

new Thread(() -> {
    // 子线程可以读到父线程的值!
    System.out.println(tl.get());  // 输出: main-thread-value
}).start();

// 原理:Thread.init() 时,如果父线程的 inheritableThreadLocals 不为空,
// 会把整个 Map 拷贝给子线程(浅拷贝,共享 value 引用)
InheritableThreadLocal 在线程池中为什么不灵?

InheritableThreadLocal 只在创建子线程时拷贝一次。线程池中的线程是预先创建并复用的,不会为每个任务重新创建线程。所以:

1. 任务 A 设置了 ThreadLocal 值,线程 T1 执行完后值还留在 T1 的 Map 中
2. 任务 B 复用线程 T1,读到的可能是任务 A 留下的脏数据
3. 子线程在创建时拷贝的值,之后父线程再修改,子线程看不到

方案三:TransmittableThreadLocal(TTL)——阿里巴巴开源,解决线程池场景的上下文传递。

TTLDemo.java · TransmittableThreadLocal 用法
// Maven: com.alibaba:transmittable-thread-local:2.14.x

// 1. 使用 TTL 替代 ThreadLocal
TransmittableThreadLocal<UserContext> ctx = new TransmittableThreadLocal<>();

// 2. 包装线程池(关键步骤!)
ExecutorService pool = TtlExecutors.getTtlExecutorService(
    Executors.newFixedThreadPool(10)
);

// 3. 提交任务时,TTL 会自动 capture → replay → restore
ctx.set(new UserContext("user-123"));

pool.submit(() -> {
    // 线程池线程能正确读到提交时的值!
    UserContext user = ctx.get();  // user-123
    processRequest(user);
});

/*
 * TTL 的三步机制:
 * ① capture:任务提交时,快照当前线程所有 TTL 的值
 * ② replay:任务执行前,把快照值设置到执行线程
 * ③ restore:任务执行后,恢复执行线程之前的值
 */
第 8 站

总结:正确使用清单与高频踩坑表

ThreadLocal 正确使用清单
  • 始终用 static final 声明 ThreadLocal,避免 ThreadLocal 实例本身被 GC 导致 key 变 null
  • 始终在 finally 中调用 remove(),确保线程池场景下不泄漏
  • 不要用 ThreadLocal.set(null) 代替 remove()——set(null) 只是把 value 设为 null,Entry 还在
  • 优先使用 withInitial() 工厂方法,确保首次 get() 有合理的默认值
  • 线程池场景需要跨线程传递上下文时,使用 TransmittableThreadLocal
踩坑场景现象根因解决方案
忘了 remove()线程池 OOM,Heap 中大量业务对象Value 被 Entry 强引用,线程不死不回收finally 中 remove()
ThreadLocal 声明为局部变量get() 返回 null 或读到脏数据ThreadLocal 实例被 GC,key 变 null声明为 static final
线程池 + InheritableThreadLocal子线程读不到父线程最新值线程复用,不是每次新建使用 TTL
SimpleDateFormat 共享日期格式化结果错乱SDF 内部 Calendar 不是线程安全的ThreadLocal 包装或换 DateTimeFormatter
ThreadLocal 存大对象内存占用异常每个线程都有一份拷贝减小对象体积,或考虑其他方案

面试回答模板:

"ThreadLocal 的 Entry 使用弱引用作为 Key,是为了当外部不再持有 ThreadLocal 引用时,ThreadLocal 实例本身可以被 GC 回收。但 Value 是强引用,如果线程池中的线程不销毁且没有调用 remove(),就会出现 key 为 null 但 value 无法回收的内存泄漏。ThreadLocalMap 在 get/set 时会被动清理脏 Entry,但这不够可靠,所以最佳实践是:1) ThreadLocal 声明为 static final;2) 在 finally 中调用 remove();3) 线程池场景使用 TransmittableThreadLocal。"

TTLProduction.java · 生产环境 TTL 完整配置示例
/* ─── 配合 Spring + 线程池的完整配置 ─── */
@Configuration
public class AsyncConfig {

    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("async-ttl-");
        // 用 TTL 装饰,确保 @Async 方法能正确传递上下文
        executor.setTaskDecorator(new TtlTaskDecorator());
        executor.initialize();
        return executor;
    }
}

/* ─── 自定义 TaskDecorator(Spring 集成) ─── */
public class TtlTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        // TtlRunnable 会自动 capture/replay/restore
        return TtlRunnable.get(runnable);
    }
}

/* ─── 使用方式 ─── */
@Service
public class OrderService {
    @Async("taskExecutor")
    public CompletableFuture<Void> processAsync() {
        // 即使在不同线程执行,也能拿到正确的用户上下文
        UserContext user = UserContextHolder.get();
        log.info("processing order for user: {}", user.getUserId());
        return CompletableFuture.completedFuture(null);
    }
}