ThreadLocal 原理与内存泄漏
ThreadLocal 的 API 只有 get、set、remove 三个方法,看起来简单,但它是线上内存泄漏和"脏数据串号"的高发区。问题的根源在于数据实际存在 Thread 对象里,而不是 ThreadLocal 里,并且 key 是弱引用。本文从 ThreadLocalMap 的结构讲起,把泄漏的每一个环节还原出来,并给出线程池场景下的正确写法。
一、数据到底存在哪里
ThreadLocal 本身不存数据,它只是一个"钥匙"。真正的存储结构挂在 Thread 上:
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}
ThreadLocalMap 是 ThreadLocal 的静态内部类,用数组实现,结构与 HashMap 类似但没有链表:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 强引用 value
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v;
}
}
private Entry[] table;
private int size;
private int threshold;
}
关系图如下:
Thread ──► threadLocals (ThreadLocalMap)
│
▼
Entry[] table
┌──────────────────────────────┐
│ Entry │
│ key ──弱引用──► ThreadLocal │ ◄── 栈上的 tl 变量强引用它
│ value ──强引用──► 实际对象 │
└──────────────────────────────┘
ThreadLocal 的 set 就是拿到当前线程的 map 再写入:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
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;
}
}
return setInitialValue();
}
这也解释了为什么 ThreadLocal 天然线程隔离:每个线程有自己的 map,key 是同一个 ThreadLocal 对象,但 value 各存各的。
二、哈希与冲突处理
ThreadLocalMap 用 threadLocalHashCode 作为哈希值,每次创建 ThreadLocal 时递增一个魔数 0x61c88647:
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
这个魔数是黄金分割数 (√5 - 1) / 2 乘以 2 的 32 次方,能让哈希值在 2 的幂长度的数组中均匀分布,减少冲突。斐波那契散列在 ThreadLocalMap 这种开放寻址结构里效果很好。
冲突处理采用开放寻址法中的线性探测:位置被占用就往后找下一个空位。查找时如果遇到 key 不匹配,也继续往后探测,直到找到或遇到空槽。
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i); // 遇到 key 被回收的槽,顺手清理
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
注意 replaceStaleEntry 和 cleanSomeSlots:它们在写入路径上顺带清理 key 为 null 的"陈旧条目",这是 ThreadLocalMap 自我修复的一部分。
开放寻址带来一个副作用:数组不能装得太满,否则探测链变长、性能下降。ThreadLocalMap 的阈值是 len * 2 / 3,比 HashMap 的 0.75 更保守,且扩容时容量同样翻倍。rehash() 会先全量清理陈旧条目,如果清理后仍超过阈值的 3/4 才真正扩容:
private void rehash() {
expungeStaleEntries(); // 先全量清理
if (size >= threshold - threshold / 4)
resize();
}
这个"先清理再扩容"的顺序很关键:很多所谓的泄漏,其实是 map 里堆了大量 key 已死的条目,清理一次就能回收,没必要扩容。
三、为什么 key 是弱引用
如果 key 用强引用,ThreadLocal 对象即使外部不再使用,也会被 ThreadLocalMap 的 Entry 一直引用,永远无法回收。改成弱引用后,当外部强引用消失,GC 就能回收 ThreadLocal,Entry 的 key 变成 null。
但弱引用只解决了 key 的回收,value 仍然是强引用。这就产生了经典泄漏链:
Thread(线程池中长期存活)
└─► ThreadLocalMap
└─► Entry
├─ key: null(ThreadLocal 已被 GC 回收)
└─ value: 强引用一个 10 MB 的缓存对象 ← 永远无法回收
ThreadLocalMap 只有在后续 get/set/remove 时才会顺带清理 key 为 null 的槽位。如果线程长时间不再访问这个 map,这些 value 就一直挂着。
在线程池里,线程是复用的,几乎不会结束,于是泄漏会持续累积。这就是"ThreadLocal 内存泄漏"的完整成因。
四、脏数据:比泄漏更隐蔽的问题
线程池复用线程时,上一个任务留下的 ThreadLocal 值会被下一个任务读到:
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.execute(() -> {
UserContext.set(new User("alice"));
// 忘记清理
});
pool.execute(() -> {
// 可能跑在同一个线程上,读到 alice
System.out.println(UserContext.get()); // 脏数据
});
这类问题在请求上下文中尤其危险:用户 A 的请求处理完没有 remove,用户 B 的请求复用了同一线程,读到了 A 的身份信息,造成越权。
五、正确用法
public final class UserContext {
private static final ThreadLocal<User> HOLDER = new ThreadLocal<>();
private UserContext() {}
public static void set(User user) {
HOLDER.set(user);
}
public static User get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove(); // 必须用 remove,不是 set(null)
}
}
使用模板:
try {
UserContext.set(user);
handleRequest();
} finally {
UserContext.clear(); // 无论如何都清理
}
remove() 的实现会真正删除槽位并做一次清理:
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear(); // 清空弱引用
expungeStaleEntry(i); // 清理 value 并整理数组
return;
}
}
}
set(null) 不能替代 remove():它只是把 value 置为 null,Entry 仍然存在,key 仍然被弱引用,槽位没有释放。
remove 与 set(null) 在 ThreadLocalMap 层面的差异可以这样验证:
public class ThreadLocalProbe {
private static final ThreadLocal<byte[]> TL = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
TL.set(new byte[8 * 1024 * 1024]); // 8 MB
TL.set(null); // Entry 仍在,value 变 null
System.gc();
Thread.sleep(500);
System.out.println("after set(null), entry still in map");
TL.set(new byte[8 * 1024 * 1024]);
TL.remove(); // 槽位被清理
System.gc();
Thread.sleep(500);
System.out.println("after remove, entry gone");
}
}
两次调用后 value 都能被回收,因为 set(null) 已经把强引用断开;区别在于 Entry 对象本身是否残留。在长期存活的线程里,大量残留的 Entry 会撑大 table,并让探测链变长,最终表现为 map 不断扩容、查询变慢。所以"释放引用"和"清理结构"是两件事,remove 两件都做。
六、InheritableThreadLocal 与线程池的冲突
InheritableThreadLocal 会在创建子线程时把父线程的值复制过去:
private static final InheritableThreadLocal<String> TRACE =
new InheritableThreadLocal<>();
TRACE.set("trace-001");
new Thread(() -> System.out.println(TRACE.get())).start(); // trace-001
但复制只发生在 Thread 构造时(Thread.init 中的 inheritThreadLocals 判断)。线程池的线程是提前创建并复用的,提交任务时不会重新复制,因此 InheritableThreadLocal 在线程池里基本失效。
需要跨线程池传递上下文时,用阿里开源的 TransmittableThreadLocal(TTL),它在任务提交时捕获快照、执行前恢复、执行后还原:
TransmittableThreadLocal<String> ctx = new TransmittableThreadLocal<>();
ExecutorService pool = TtlExecutors.getTtlExecutorService(
Executors.newFixedThreadPool(4));
Spring 环境下也可以自定义 TaskDecorator 手动复制 MDC 等上下文。
七、并发场景分析
| 场景 | 风险 |
| --- | --- |
| Web 请求上下文 | 忘记 remove,线程复用时串号、越权 |
| 线程池中的大对象缓存 | value 无法回收,堆内存持续增长 |
| 异步任务(CompletableFuture) | 上下文不会自动传递,读到 null |
| 子线程创建 | InheritableThreadLocal 只复制一次 |
| 静态 ThreadLocal 持有连接 | 连接未关闭,泄漏连接和内存 |
ThreadLocal 适合存放"与线程绑定、生命周期短、不需要跨线程"的数据,例如日期格式化器(SimpleDateFormat 非线程安全)、请求上下文、事务连接。不适合缓存大对象,也不适合需要在线程池间传递的数据。
JDK 8 之后 SimpleDateFormat 的替代方案是 DateTimeFormatter,它本身不可变且线程安全,可以直接作为静态常量共享,不必再借助 ThreadLocal。这也是判断"是否真的需要 ThreadLocal"的一个标准:如果目标对象是不可变的,就没必要每个线程存一份。
八、常见坑 / 最佳实践
finally中必须remove()。这是唯一能彻底避免泄漏和脏数据的方式,set(null)无效。- 用
static final修饰ThreadLocal。避免每次创建新实例导致 map 中堆积大量Entry。 - 不要用
ThreadLocal缓存大对象。线程池线程长期存活,value 会一直占着堆内存。 - 异步任务显式传递上下文。
CompletableFuture、@Async都不会自动继承ThreadLocal,用 TTL 或TaskDecorator。 InheritableThreadLocal不能解决线程池传递问题,它只在Thread构造时复制一次。- 框架层统一清理。拦截器或过滤器在
finally中调用remove,比依赖每个业务方法自己清理可靠。 - 排查泄漏时看堆转储。用
jmap -dump后分析ThreadLocalMap的Entry,key 为 null 但 value 很大的条目就是泄漏点。
九、小结
- 数据存在
Thread的threadLocals字段中,ThreadLocal只是访问用的 key。 ThreadLocalMap用开放寻址法解决冲突,哈希增量0x61c88647是斐波那契散列魔数。- key 是弱引用,value 是强引用,key 被回收后 value 无法释放,形成泄漏。
- 线程池复用线程会让泄漏持续累积,也会让上一个任务的值被下一个任务读到。
remove()是唯一正确的清理方式,set(null)不能释放槽位。- 跨线程池传递上下文需要
TransmittableThreadLocal或自定义TaskDecorator。
- 点赞
- 收藏
- 关注作者
评论(0)