首页 / Java 学习笔记 / 18

JAVA · Vol.II · DAY 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 内存泄漏的核心矛盾,后面第 11 站会详细分析。

在正式开始之前,先抛几个你工作中大概率遇到过、但未必想透过的问题:

  • 为什么说 ThreadLocal 用完必须 remove(),光靠 GC 不行?
  • 为什么日志里的 traceId 偶尔会"串号",跑到别人的请求上?
  • 子线程为什么读不到父线程的 ThreadLocal?那线程池里怎么传递上下文?
  • 同样是存数据,ThreadLocal 和 synchronized 到底谁管什么?

带着这些问题,我们从最底层开始,把 ThreadLocal 的存储结构、哈希设计、弱引用与内存泄漏、线程池透传方案一条条拆开。

第 2 站

一句话本质:线程的私人储物柜

先给 ThreadLocal 下个定义:线程本地存储(Thread-Local Storage)——它给每个线程保存一份独立的变量副本。同一个 ThreadLocal 对象,线程 A 往里 set 的值,线程 B 永远 get 不到,反之亦然。

更衣室类比

想象一家健身房的更衣室:储物柜的编号规则全公司统一(同一个 ThreadLocal 对象),但每个柜子只归一个人用(每个线程自己的 ThreadLocalMap)。你把自己的包放进 7 号柜,隔壁的人打开自己的 7 号柜,看到的只能是自己的东西——柜子编号相同,柜内世界却互不相干。这就是"线程隔离":同一把钥匙,各开各的锁,各取各的物

那问题来了:既然要保证"各线程互不干扰",用 synchronized 加锁不就行了?这俩是完全不同的两码事:

维度synchronizedThreadLocal
解决的问题互斥:多个线程竞争同一份数据,串行访问隔离:每个线程各持一份数据,并行访问
手段锁。抢不到就阻塞等待(时间换安全)副本。每人一份,不用抢(空间换安全)
变量形态一份共享变量,所有人读写它每个线程一份私有副本
并发度串行,吞吐受锁竞争影响完全并行,无竞争
典型场景多线程读写同一计数器、同一缓存每个线程要独立的连接、上下文、格式器
一句话记忆:synchronized 是"排队用一个洗手间",ThreadLocal 是"每人一间更衣室"。
前者保证"不会乱",后者保证"不用等"——解决的是两个不同层面的问题。

下一站看它在生产里到底被用来干什么。

第 3 站

四大经典场景:连接、上下文、格式器、链路追踪

ThreadLocal 在生产中有四大典型用途,本质都是同一件事:把"只属于当前线程"的状态,随手带在身边

场景问题ThreadLocal 方案
数据库连接 / 事务上下文一个请求可能要多次 DB 操作,每次都新建连接太贵;事务要跨多个 DAO 保持同一个连接一个线程绑定一个 Connection,整个请求复用;Spring 的 DataSourceTransactionManager 就是靠 ThreadLocal 把连接绑到当前线程上的
SimpleDateFormatSDF 内部持有 Calendar,不是线程安全的,共享实例会日期错乱每个线程持有自己的 SDF 实例(或直接换 JDK 8 的 DateTimeFormatter
请求上下文传播用户信息需要从 Controller 传到 Service 再到 DAO,层层传参太啰嗦Spring Security 的 SecurityContextHolder 底层用 ThreadLocal
链路追踪 TraceId日志需要携带 traceId 串联完整调用链SLF4J 的 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

看到没,四个场景全是"一个线程内带着走的状态"。理解了"线程隔离"这四个字,下一站我们亲手跑一个 Demo 验证它。

第 4 站

亲手跑一遍:先感受"线程隔离"再谈原理

理论说再多不如跑一遍。下面这段代码可以直接复制运行,它同时演示了线程隔离线程池脏数据两个现象:

ThreadLocalDemo.java · 可直接运行
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalDemo {

    // 注意:生产里 ThreadLocal 必须是 static final,原因见第 17 站
    private static final ThreadLocal<Integer> counter = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        // ① 主线程设置自己的值
        counter.set(100);

        // ② 子线程读不到主线程的值(默认不继承!)
        Thread t = new Thread(() -> {
            System.out.println("子线程 get() = " + counter.get());      // null
            counter.set(200);                                           // 各存各的
            System.out.println("子线程 set 后 get() = " + counter.get()); // 200
        });
        t.start();
        t.join();

        // ③ 主线程的值没有被污染
        System.out.println("主线程 get() = " + counter.get());          // 100

        // ④ 线程池场景:线程复用,值会"残留"给下一个任务
        ExecutorService pool = Executors.newFixedThreadPool(1);
        pool.submit(() -> {
            counter.set(999);
            System.out.println("任务A get() = " + counter.get());        // 999
            // 注意:没有 remove()!
        });
        pool.submit(() -> System.out.println("任务B get() = " + counter.get())); // 999 ← 脏数据!
        pool.shutdown();
    }
}
运行输出 · 对照理解
子线程 get() = null
子线程 set 后 get() = 200
主线程 get() = 100
任务A get() = 999
任务B get() = 999   ← 任务 B 读到了任务 A 留下的值!
看到 ①②③ 你是不是以为 ThreadLocal 只是"各线程各存一份"?那 ④ 又是怎么回事——任务 B 为什么读到了 999?
点破:线程隔离 ≠ 任务隔离

④ 里线程池只有一个线程,任务 A 和任务 B 是先后跑在同一个线程上的。线程没死,它 ThreadLocalMap 里的值就一直挂着,任务 B 自然读到了任务 A 的残留。这就是生产里最常见的两类事故——脏数据内存泄漏的源头,也是"必须 remove()"的根本原因。先记住这个结论,第 13 站我们看真实的线上事故。

第 5 站

内部实现:Thread 持有一张 ThreadLocalMap

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 是强引用指向实际值

对应到源码,就是 Thread 类里躺着的两个字段:

Thread.java · 两个 ThreadLocalMap 字段
// 每个线程都有一张自己的 map(懒加载:第一次 set 才创建)
ThreadLocal.ThreadLocalMap threadLocals = null;

// 专供 InheritableThreadLocal 用的另一张 map(第 15 站细讲)
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
为什么 Map 挂在 Thread 上而不是 ThreadLocal 上?

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

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

第 6 站

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

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

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

    // 2. 获取该线程的 ThreadLocalMap("当前线程取 map")
    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);
    }
}

// createMap 的真相:map 是挂在当前线程对象上的
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}
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;withInitial() 可自定义
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

两个方法的共同点:一切从"当前线程"出发——先 Thread.currentThread() 拿到线程,再从线程上取 map,最后以 this 为 key 操作。这就是为什么 ThreadLocal 天然线程安全:每个线程操作的都只是自己的那张 map,根本没有共享数据,也就不需要锁

追问来了:map.set(this, value) 里,key 是 ThreadLocal 实例,那它怎么散列到数组槽位?撞车了怎么办?为什么扩容阈值是 2/3 而不是 HashMap 的 0.75?

这三个问题分别对应 ThreadLocal 最精妙的三处设计——魔法数字 0x61c88647(第 8 站)、开放定址法(第 9 站)、remove() 的讲究(第 7 站)。我们一站一站来。

第 7 站

remove():为什么"删除"比"插入"更讲究

规范要求 set/get/remove 三个流程都要吃透。remove() 的代码很短,但藏着一个只有懂开放定址法的人才看得懂的细节:

ThreadLocal.java / ThreadLocalMap.java · remove() 源码
// ThreadLocal.remove()
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

// ThreadLocalMap.remove()
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len - 1);

    // 线性探测找到 key 对应的 Entry
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();              // ① 清掉弱引用(Reference.clear()),key 变 null
            expungeStaleEntry(i);   // ② 删除该槽位,并把后续被"挤偏"的 Entry 重新放置
            return;
        }
    }
}

注意第 ② 步:删掉一个元素后,不能就这么留个空位走人。因为线性探测法里,一个 Entry 可能因为冲突被"挤"到了离它真实槽位很远的地方;如果中间删掉一个元素留下空洞,后面按探测链查找的 Entry 就会"断链"——明明存在却找不到。所以 expungeStaleEntry() 删除槽位后,会继续往后扫描,把后面被挤偏的 Entry 重新 hash、挪回更靠前的位置,保证探测链不断。

常见误区:set(null) 能代替 remove() 吗?

不能。set(null) 只是把 value 覆盖成 null,Entry 对象还躺在数组里(key 的弱引用还在)。后果:① 占着槽位不还,探测链变长;② 它仍然强引用着 key(弱引用还存在时,只要外部还有强引用,ThreadLocal 就回收不了);③ 下次 set 走到这个槽,还得先处理它。清理的唯一正解是 remove()——既清 value 又清 key,还顺手重排探测链。

核心要点
  • remove() = 定位 Entry → e.clear() 断弱引用 → expungeStaleEntry() 删槽 + 重排后续元素;
  • 线性探测的删除必须重排,否则探测链断裂、元素"丢失";
  • 清 ThreadLocal 一律用 remove(),别用 set(null) 糊弄。
第 8 站

魔法数字 0x61c88647:黄金分割散列

每个 ThreadLocal 实例都有个 threadLocalHashCode,它是这样算出来的:

ThreadLocal.java · 哈希增量源码
// 每个 ThreadLocal 实例在构造时固定自己的 hash
private final int threadLocalHashCode = nextHashCode();

// 魔数:2^32 × (√5 − 1) / 2 ≈ 2654435769 = 0x61C88647
private static final int HASH_INCREMENT = 0x61c88647;

// 全局递增计数器:每 new 一个 ThreadLocal,就加上一次魔数
private static AtomicInteger nextHashCode = new AtomicInteger();

private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

0x61c88647 这个数字不是随手写的,它是黄金分割(Golden Ratio)在哈希里的经典应用:2³² × (√5 − 1) / 2 ≈ 2654435769,十六进制就是 0x61C88647。按这个步长递增得到的序列,称为斐波那契散列(Fibonacci Hashing),它有个神奇的性质:

任意连续生成的多个 hash,在 2 的幂大小的表上,分布都极其均匀
——后一个 ThreadLocal 的落点与前一个之间,永远隔着一个"黄金角", 像钟表指针按黄金角旋转,扫过的刻度均匀铺满整个表盘。

为什么 ThreadLocal 需要这个?想想场景:一个请求的线程上,可能同时挂着 UserContexttraceIdSimpleDateFormatConnection……好几个 ThreadLocal,它们几乎同时被 new 出来。如果 hash 是随机的,撞车概率高;而"递增魔数 + 黄金分割"保证这些相邻创建的实例在数组上散得很开,大幅减少线性探测的次数(HashMap 靠扰动函数,ThreadLocal 靠黄金角步长,殊途同归)。

那撞车了怎么办?ThreadLocalMap 可没有链表。答案在下一站——开放定址法。
第 9 站

开放定址法:线性探测与 2/3 扩容阈值

ThreadLocalMap 处理哈希冲突的方式和 HashMap 完全不同:HashMap 用链地址法(冲突了挂链表),ThreadLocalMap 用开放定址法(Open Addressing)——冲突了就线性探测(Linear Probing),一个槽位一个槽位地往后找空位:

线性探测:冲突就往后找空槽(表长度 8) Entry[] table —— 下标 0 ~ 7 TL-1 TL-2 TL-3 TL-2 想落 2 号槽 (已被 TL-1 占)→ 顺延到 3 号 TL-3 想落 3 号槽 (已被 TL-2 占)→ 顺延到 4 号 开放定址的代价:表越满,探测链越长 所以 ThreadLocalMap 的扩容阈值压到 2/3(HashMap 是 0.75),且删除必须重排(第 7 站) 好处:省内存(无链表节点)、Entry 数量少时命中快、便于整段清理脏 Entry
图 2线性探测:目标槽被占就向后顺延,直到找到空位;代价是表越满探测链越长

对比一下两种冲突解决策略,面试被追问"为什么 ThreadLocalMap 不用链表"时可以这样答:

维度HashMap(链地址法)ThreadLocalMap(开放定址法)
冲突处理链表 / 红黑树挂桶向后探测空槽(线性探测)
装载因子0.752/3(更保守)
删除直接摘节点不能直接置 null,必须重排探测链
适用规模大、动态增长、海量 key小:一个线程通常只有几个 ThreadLocal
内存每个节点要存 next 指针无链表节点,Entry 更紧凑
额外好处——连续存储,方便整段扫描清理"脏 Entry"

再看扩容阈值。源码里阈值是这样定的:

ThreadLocalMap.java · 扩容阈值与 rehash
private static final int INITIAL_CAPACITY = 16;   // 初始容量 16

// 阈值 = 长度的 2/3
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

// set() 末尾:先做启发式清理,size 仍超阈值就 rehash
if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();

// rehash:先全表清一遍脏 Entry,再决定要不要真正扩容
private void rehash() {
    expungeStaleEntries();                    // 全表清理脏 Entry(白捡空间)
    if (size >= threshold - threshold / 4)  // 清理后仍 ≥ 3/4 × 阈值(即 1/2 长度)
        resize();                             // 才真正扩容翻倍(新阈值 = 新长度 × 2/3)
}
为什么是 2/3 而不是 HashMap 的 0.75?

HashMap 用链地址法,桶里挂多长都能存,装载因子可以放宽;开放定址法不行——表越满,冲突概率和探测链长度呈指数恶化,接近满时几乎退化成 O(n) 扫描。所以 ThreadLocalMap 把阈值压到 2/3,宁可多浪费 1/3 空间,也要保证探测链短。而且 2/3 的余量还给"脏 Entry 清理时的重排"留了操作空间。

还有个细节:rehash 时先全表清理脏 Entry 再判断——很多"该扩容"的时机,清完脏 Entry 空间就够了,根本不用扩容。这是内存和 CPU 的双重节约。

第 10 站

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

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

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 仍有泄漏风险(见第 11 站)
弱引用不是万能的——它只解决了一半问题

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

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

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

内存泄漏详解:脏 Entry 与三大条件

ThreadLocal 内存泄漏的完整发生条件,缺一不可:

① ThreadLocal 实例不再被外部强引用② 线程仍然存活(线程池复用)③ 后续没有 get/set/remove 触发清理
= Value 永远无法被 GC → 内存持续增长 → OOM
内存泄漏时的引用链状态 ① 正常使用时 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
图 3内存泄漏引用链:ThreadLocal 被 GC 后,Entry 的 key 变为 null,但 value 仍被强引用,线程池中线程不死则 value 永远无法回收

注意一个容易混淆的点:static final 的 ThreadLocal 反而不会泄漏 ThreadLocal 实例本身——因为它一直被类强引用着,key 永远不可能是 null。这种写法下泄漏风险来自哪里?来自value 一直不清理:每个请求 set 一次新对象(比如新的 UserContext),旧对象虽然没被"脏 Entry"困住,但如果你从不 remove,下一次 set 会覆盖引用,旧值才能被回收——看起来问题不大;可一旦 value 里装着集合、大对象,或者线程上绑了多个这种 ThreadLocal,积少成多同样会撑爆堆。更隐蔽的坑是线程复用时的脏数据串号,第 13 站专门讲。

第 12 站

被动清理:JDK 的"保险丝"与它的边界

JDK 设计者当然知道弱引用会留下脏 Entry,所以在 ThreadLocalMap 里埋了被动清理机制——在 get/set/remove/rehash 的过程中"顺手"清理,一共有四层:

方法触发时机干什么
expungeStaleEntry(i)get/set/remove 命中脏槽时清掉 i 处的脏 Entry,并向后扫描、重排后续被挤偏的 Entry,遇到脏 Entry 一并清掉
replaceStaleEntry(...)set() 撞上脏槽时用新值"顶替"脏 Entry 的位置(就地复用槽位),同时向右清理一段
cleanSomeSlots(...)每次 set() 成功插入后启发式扫描:从当前位置向后扫 log2(n) 个槽,碰运气清理脏 Entry
expungeStaleEntries()rehash() 扩容前全表扫描,把所有脏 Entry 一次性清干净
ThreadLocalMap.java · 被动清理逻辑(简化)
// getEntry 时如果没直接命中,会走 getEntryAfterMiss:
// 线性探测过程中遇到 key=null 的脏 Entry,调用 expungeStaleEntry 清理
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 {
        return getEntryAfterMiss(key, i, e);  // 探测 + 顺路清理
    }
}

// expungeStaleEntry:清理脏 Entry + 重排后续元素(核心逻辑)
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 1. 解除 value 的强引用 —— value 终于可以被 GC 了
    tab[staleSlot].value = null;
    tab[staleSlot] = null;        // 清掉整个槽位
    size--;

    // 2. 继续向后扫描:重放被挤偏的 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 挪到更靠前的位置(保持探测链完整)
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                while (tab[h] != null) h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}
被动清理够用吗?什么时候会失效?

够用:如果你的 ThreadLocal 是 static final(常见写法),它永远不会被 GC,Key 不会变 null,配合 remove() 就没有泄漏。

不够用:如果 ThreadLocal 是局部变量或方法参数中创建的(用完就没人引用了),且线程池中的线程后续不再调用 get/set(比如该业务逻辑不再触发),那脏 Entry 就永远不会被清理——因为所有清理都挂在"下一次操作"上,你不操作,它就不清理。

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

真实事故:traceId 串号与用户数据串号

事故一:日志 traceId 串号

某业务团队的日志平台突然告警:同一个 traceId 同时出现在两个完全不同的用户请求里,排查链路被污染,整个团队花了两个下午定位问题。根因就一句话:MDC.put() 之后忘了 MDC.remove()

TraceIdLeak.java · 串号复现
// 错误写法:任务结束没清 MDC(MDC 底层就是 ThreadLocal)
public void handle(Request req) {
    MDC.put("traceId", UUID.randomUUID().toString());   // 请求入口放 traceId

    asyncExecutor.submit(() -> {
        // 线程池线程执行 —— 上个任务留下的 traceId 还在!
        log.info("处理请求");   // 日志里带着"别人的" traceId
    });

    // 忘了在 finally 里 MDC.remove() → traceId 残留在当前线程
    // 下一个请求复用这个线程 → 新 traceId 覆盖失败?不,是读到了旧值……
}

// 正确写法:finally 里清理,配套第 14 站的模板
try {
    MDC.put("traceId", UUID.randomUUID().toString());
    // ... 业务逻辑 ...
} finally {
    MDC.remove();   // 或者 MDC.clear()
}
事故二:用户数据串号(比 OOM 更可怕)

更严重的版本:一个网关服务用 ThreadLocal 存当前登录用户,请求处理完没 remove。线程池的同一个线程先后处理用户 A、用户 B 的请求——用户 B 可能在某个分支里读到用户 A 的上下文,轻则数据错乱,重则越权访问,这是安全事故级别的问题。这类事故的共性特征:偶发、难以复现、只在并发量上来时才出现,因为是否串号取决于"线程是否恰好被复用"。

复盘结论
  • ThreadLocal 的隔离是"线程级"的,不是"任务级"的——线程池复用让"上一个任务"和"下一个任务"共享同一个线程的存储;
  • 只要 set 了 ThreadLocal,就要在任务结束时 remove,两者必须成对出现;
  • 排查线索:Heap Dump 里大量同类型业务对象(内存泄漏),或日志 traceId / 用户 ID 对不上(串号)。
第 14 站

方案一:finally + remove() 的正确模板

永远在 finally 中调用 remove()——这是最基本也是最重要的规则。无论业务代码是正常返回还是抛异常,finally 保证清理一定执行:

CorrectPattern.java · ThreadLocal 正确使用模板
public class UserContextFilter implements Filter {
    // ① 静态字段持有:避免 ThreadLocal 实例被 GC(见第 17 站)
    private static final ThreadLocal<UserContext> USER_HOLDER = new ThreadLocal<>();

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp,
                         FilterChain chain) throws IOException, ServletException {
        try {
            UserContext ctx = parseUser(req);
            USER_HOLDER.set(ctx);       // ② 设置上下文
            chain.doFilter(req, resp);  // ③ 业务处理(Controller → Service → DAO 都能拿到)
        } finally {
            USER_HOLDER.remove();       // ④ 必须在 finally 中清理!
            // 不能放在 try 里(异常会跳过);不能漏;线程池场景更不能省
        }
    }
}
三个"必须"
  • 必须 static final:防止 ThreadLocal 实例本身被 GC,key 变 null;
  • 必须 finally + remove():异常也拦不住清理;
  • 必须成对出现:set 和 remove 中间隔的代码越少越好,最好封装在 Filter/Interceptor/AOP 里统一处理。
第 15 站

方案二:InheritableThreadLocal 与它的局限

子线程默认读不到父线程的 ThreadLocal(第 4 站 Demo 里验证过)。如果业务上需要"父线程的值传给子线程",用 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 浅拷贝给子线程(值对象共享引用)

对应源码,子线程创建那一刻发生了"搬家":

Thread.java · init() 中的继承逻辑
// 创建子线程时(Thread.init()),父线程的 inheritableThreadLocals 被拷贝
if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
    this.inheritableThreadLocals =
        ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}

// 拷贝是"浅拷贝":每个 Entry 的 value 走 childValue() 转换,默认原样返回
// 注意:value 对象本身是共享的 —— 子线程改了对象内部状态,父线程也看得到
InheritableThreadLocal 在线程池中为什么不灵?

关键:继承发生在创建子线程的那一刻,且只发生一次。线程池里的线程是预先创建并复用的,不会为每个任务重新创建线程。所以:

1. 任务 A 设置了 ThreadLocal 值,线程 T1 执行完后值还留在 T1 的 Map 中,任务 B 复用 T1 会读到 A 的残留(脏数据);
2. 即使你用 InheritableThreadLocal,父线程的修改也不会同步到已经创建好的池线程里——继承是"一次性快照",不是"持续透传"。

结论:父子线程直连场景用 InheritableThreadLocal;线程池场景必须换方案三——TransmittableThreadLocal

第 16 站

方案三:TransmittableThreadLocal(TTL)

阿里开源的 TransmittableThreadLocal(TTL,Maven 坐标 com.alibaba:transmittable-thread-local)专门解决线程池场景的上下文透传。它的核心是三步机制:

① capture(捕获):任务提交时,把当前线程所有 TTL 的值快照下来
② replay(回放):任务真正执行前,把快照值"放"到执行线程上
③ restore(恢复):任务执行完,还原执行线程原来的值(防串号)
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);
});
TTLProduction.java · 生产环境 TTL 完整配置示例(Spring)
/* ─── 配合 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);
    }
}
TTL 与 InheritableThreadLocal 怎么选?

① 只是"父子线程直连"、不经过线程池 → InheritableThreadLocal 够用;② 涉及线程池、异步、MQ 消费、分布式框架 → TTL;③ 三种方案共用时注意:TTL 继承自 InheritableThreadLocal,两者可以协同(TTL 会透传 TTL 值,ITL 值走 ITL 的继承逻辑)。生产里统一用 TTL 最省心,避免混用导致"有些值传过去了、有些没传"。

第 17 站

生产实践清单与高频踩坑表

ThreadLocal 正确使用清单(阿里规约同款要求)
  • 始终用 static final 声明 ThreadLocal,避免 ThreadLocal 实例本身被 GC 导致 key 变 null;
  • 始终在 finally 中调用 remove(),线程池场景下尤其不能省;
  • 不要用 ThreadLocal.set(null) 代替 remove()——set(null) 只是把 value 设为 null,Entry 还在;
  • 优先使用 withInitial() 工厂方法,确保首次 get() 有合理的默认值;
  • 线程池场景需要跨线程传递上下文时,使用 TransmittableThreadLocal
  • value 里别放大对象:每个线程一份副本,线程数 × 对象大小就是真实内存占用。
踩坑场景现象根因解决方案
忘了 remove()线程池 OOM,Heap 中大量同类型业务对象Value 被 Entry 强引用,线程不死不回收finally 中 remove()
线程池复用 + 未清理traceId 串号、用户上下文串号(数据错乱/越权)上一个任务的值残留在线程的 Map 里任务结束 remove() / 用 TTL 自动 restore
ThreadLocal 声明为局部变量get() 返回 null 或读到脏数据ThreadLocal 实例被 GC,key 变 null声明为 static final
线程池 + InheritableThreadLocal子任务读不到父线程最新值线程复用,不是每次新建;继承只发生一次使用 TTL
SimpleDateFormat 共享日期格式化结果错乱SDF 内部 Calendar 不是线程安全的ThreadLocal 包装或换 DateTimeFormatter
ThreadLocal 存大对象内存占用异常每个线程都有一份拷贝(N 线程 = N 份)减小对象体积,或考虑其他方案
第 18 站

面试高频追问:五个必问点

追问 1:ThreadLocalMap 为什么不用 HashMap 的链地址法?

三层原因:① 规模小——一个线程通常只挂几个 ThreadLocal,开放定址在小表上探测成本低,且省掉链表节点的内存;② 弱引用 Entry 需要"扫描式"清理——脏 Entry 的清理要向后连续扫描重排,连续数组比链表更适合这种整段操作;③ 开放定址配合黄金分割散列,冲突率天然低。反过来,HashMap 面向海量 key,开放定址在接近满载时探测链会指数恶化,所以必须用链地址法。

追问 2:为什么 ThreadLocal 必须是 static final?

如果是局部变量:方法执行完,外部强引用消失,ThreadLocal 实例被 GC,Entry 的 key 变 null → 脏 Entry,value 泄漏。static final 让 ThreadLocal 实例永生,key 永远有效,泄漏风险只剩"value 没清理"这一条,可控得多。这也是为什么规范把它列为强制项。

追问 3:ThreadLocal 和 synchronized 的区别?

见第 2 站的对比表,一句话版本:synchronized 是"多个线程互斥访问一份数据"(时间换安全),ThreadLocal 是"每个线程各持一份数据"(空间换安全)。前者解决"竞争",后者解决"共享"。两者互补,不是替代关系——比如连接池场景:池本身要 synchronized 保证并发安全,而"当前线程拿哪个连接"用 ThreadLocal 记录。

追问 4:Netty 的 FastThreadLocal 为什么更快?

Netty 的 FastThreadLocal 换了个思路:放弃哈希,直接数组索引。每个 FastThreadLocal 构造时拿一个全局递增的 index,线程上的 InternalThreadLocalMap 本质是一个数组,set/get 就是 indexedVariables[index] = value——O(1)、无哈希冲突、无线性探测,还支持线程退出时自动清理(FastThreadLocalRunnable)。代价是每个线程的数组按最大 index 分配(懒扩容),适合 Netty 这种"每个 handler 一个 FastThreadLocal、高频访问"的场景。面试提到这个,说明你见过高并发框架的取舍。

追问 5:remove() 和 set(null) 有什么区别?

remove() 走 e.clear() + expungeStaleEntry():断弱引用、删槽位、重排探测链,彻底释放;set(null) 只是把 value 覆盖成 null,Entry 还在、key 的弱引用还在、槽位还占着。清理只能靠 remove(),这是阿里规约和 JDK 注释都强调的点。

本系列追问地图

  • 原理层:存储结构(第 5 站)→ set/get/remove 流程(第 6、7 站)→ 哈希与冲突(第 8、9 站);
  • 内存层:弱引用设计(第 10 站)→ 泄漏三条件(第 11 站)→ 被动清理边界(第 12 站);
  • 生产层:事故复盘(第 1、13 站)→ 三种透传方案(第 14–16 站)→ 使用清单(第 17 站)。
总结

这一篇你掌握了什么

核心知识点回顾

  • 本质:线程本地存储(Thread-Local Storage)——同一把钥匙(ThreadLocal 实例),每个线程开自己那扇柜门(ThreadLocalMap),互不相干;
  • 结构:数据存在 Thread.threadLocals(ThreadLocalMap)里,key 是 ThreadLocal 实例的弱引用,value 是强引用;map 挂在线程上,天然无锁;
  • 流程:set/get 都是"当前线程取 map → 以 this 为 key 存取";remove() = 定位 → e.clear() → 删槽 + 重排探测链;
  • 哈希threadLocalHashCode 按黄金分割魔数 0x61c88647 递增(斐波那契散列),相邻实例散列均匀;
  • 冲突:开放定址法(Open Addressing)线性探测,删除必须重排;扩容阈值 2/3,rehash 先清脏再扩容;
  • 泄漏:key 被 GC 后 value 仍被强引用 + 线程池线程存活 + 无后续操作触发清理 → value 泄漏;被动清理(expungeStaleEntry 系列)只是"保险丝",必须主动 remove();
  • 透传:父子线程直连用 InheritableThreadLocal(创建时浅拷贝一次);线程池场景用 TransmittableThreadLocal(capture → replay → restore);
  • 最佳实践:static final 声明 + finally 中 remove() + 用 withInitial() + 线程池场景用 TTL。
面试 30 秒总结(背下来)
"ThreadLocal 是线程本地存储:每个线程内部有一张 ThreadLocalMap,以 ThreadLocal 实例为 key 存值,天然线程隔离、无需加锁。Entry 的 key 用弱引用,是为了外部不再持有 ThreadLocal 时实例本身可被 GC;但 value 是强引用,线程池线程不销毁、又不 remove() 时,就会出现 key 为 null 而 value 无法回收的内存泄漏,还会造成线程复用下的脏数据串号。ThreadLocalMap 用开放定址法解决冲突、阈值 2/3,get/set 时会被动清理脏 Entry,但不可靠。所以最佳实践三条:static final 声明、finally 里 remove()、线程池透传用 TransmittableThreadLocal。"

一句话总结

ThreadLocal = 用空间换隔离(每线程一份副本),用弱引用换可控(key 可回收),用主动 remove() 换安全(防泄漏防串号)。记住一件事:线程隔离是"线程级"的,不是"任务级"的——只要线程会被复用,清理就永远是你的责任。

Comments · 评论