Lesson 18 · 并发编程
ThreadLocal 原理与内存泄漏:弱引用的坑
线上 OOM 事故:200 万个幽灵对象
凌晨三点,监控报警。某电商核心服务的 JVM Heap 使用率从 40% 一路飙升到 96%,Full GC 频繁触发但回收不了多少空间。最终服务 OOM 宕机。
// 服务运行 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。
弱引用只能保证 ThreadLocal 实例本身被回收,但 Entry 中的 Value 是强引用——只要线程不死,Value 就永远无法被 GC。这就是 ThreadLocal 内存泄漏的核心矛盾,后面第 6 站会详细分析。
ThreadLocal 四大经典场景
ThreadLocal 的本质是线程隔离的存储——每个线程拥有自己独立的变量副本,互不干扰。它在生产中有四大典型用途:
| 场景 | 问题 | ThreadLocal 方案 |
|---|---|---|
| 数据库连接 | 一个请求可能需要多次 DB 操作,每次都新建连接太贵 | 一个线程绑定一个 Connection,整个请求复用 |
| SimpleDateFormat | SDF 不是线程安全的,共享实例会日期错乱 | 每个线程持有自己的 SDF 实例 |
| 请求上下文传播 | 用户信息需要从 Controller 传到 Service 再到 DAO | Spring Security 的 SecurityContextHolder 底层用 ThreadLocal |
| 链路追踪 TraceId | 日志需要携带 traceId 串联完整调用链 | MDC(Mapped Diagnostic Context)底层用 ThreadLocal 存 traceId |
/* ─── 场景 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
内部实现:Thread → ThreadLocalMap → Entry[]
ThreadLocal 的数据并不存储在 ThreadLocal 对象本身中,而是存储在每个 Thread 对象内部的 ThreadLocalMap 中。理解这个结构是理解一切问题的前提:
如果 Map 挂在 ThreadLocal 上(Map<Thread, Value>),那么所有线程共享一个 Map,需要处理并发问题(加锁),而且 Thread 对象会作为 Key 被强引用,导致线程无法被回收。
把 Map 挂在 Thread 上(Map<ThreadLocal, Value>),天然线程隔离无需加锁,而且 ThreadLocal 作为弱引用 Key 可以被 GC。这是 Josh Bloch 和 Doug Lea 的设计选择。
源码剖析:set() 与 get() 的完整流程
面试高频题:"说说 ThreadLocal 的 set/get 原理"。来看 JDK 源码(带关键注释):
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();
}
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;
}
ThreadLocalMap 不用 HashMap 的链表解决冲突,而是用开放寻址:冲突时向后找下一个空槽。
原因:Entry 数量通常很少(一个线程一般就几个 ThreadLocal),开放寻址在小表上更高效,且避免了链表节点的额外内存开销。
每个 ThreadLocal 实例有一个
threadLocalHashCode,由一个 AtomicInteger 累加 0x61c88647(黄金比例的哈希增量)生成。这保证了同一线程上多个 ThreadLocal 的 hash 值在数组上均匀分散,大幅减少线性探测的次数。弱引用的设计考量:两害相权取其轻
面试官最爱追问:"ThreadLocal 的 Entry 为什么用弱引用做 Key?" 这需要从生命周期管理的角度来分析。
// 强引用:只要引用存在,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 孤立)
内存泄漏详解:脏 Entry 与被动清理
ThreadLocal 内存泄漏的完整发生条件: ThreadLocal 实例不再被外部引用 + 线程仍然存活(线程池)+ 后续没有 get/set 触发清理。
JDK 设计者意识到了这个问题,所以在 ThreadLocalMap 的 get()、set()、remove() 方法中加入了被动清理机制:
// 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()。解决方案:remove()、InheritableThreadLocal 与 TTL
方案一:永远在 finally 中调用 remove()——这是最基本也是最重要的规则。
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——解决父子线程传递问题。
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 只在创建子线程时拷贝一次。线程池中的线程是预先创建并复用的,不会为每个任务重新创建线程。所以:
1. 任务 A 设置了 ThreadLocal 值,线程 T1 执行完后值还留在 T1 的 Map 中
2. 任务 B 复用线程 T1,读到的可能是任务 A 留下的脏数据
3. 子线程在创建时拷贝的值,之后父线程再修改,子线程看不到
方案三:TransmittableThreadLocal(TTL)——阿里巴巴开源,解决线程池场景的上下文传递。
// 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:任务执行后,恢复执行线程之前的值
*/
总结:正确使用清单与高频踩坑表
- 始终用 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。"
/* ─── 配合 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);
}
}