ThreadLocal 内存泄漏
ThreadLocal 用着顺手,但有一个隐患——内存泄漏。
很多人知道要用 remove(),但未必清楚为什么。不理解原理,就会在关键时刻踩坑。
Entry 的弱引用设计
ThreadLocalMap 的 Entry 是用弱引用(WeakReference)存储 key 的:
java
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // 弱引用
value = v;
}
}为什么要用弱引用?因为如果用强引用,ThreadLocal 对象没被使用时也无法被 GC 回收——只要 Entry 还在,ThreadLocal 就一直存在。
弱引用的设计意味着:当 ThreadLocal 对象没有强引用时,可以被 GC 回收。
泄漏的完整链路
问题出在 value 上。来看这个链条:
Thread 对象(强引用)
│
├─► ThreadLocalMap(强引用)
│ │
│ └─► Entry[] table(强引用)
│ │
│ └─► Entry
│ │
│ ├─► key: ThreadLocal(弱引用)→ 可以被 GC
│ │
│ └─► value: Object(强引用)→ 不能被 GC
│
└─► ThreadLocal(强引用)→ 手动设为 null假设你写了这样的代码:
java
ThreadLocal<byte[]> bigData = new ThreadLocal<>();
bigData.set(new byte[10 * 1024 * 1024]); // 10MB
// 用完了
bigData = null; // ThreadLocal 对象可以被 GCThreadLocal 对象被 GC 了,但 value 呢?
由于 Thread 本身持有 ThreadLocalMap,而 Thread 通常是长期存在的(线程池中的线程永不死亡),所以 Entry[] 也长期存在。value 指向的大对象,就一直无法回收。
Thread 对象 ──► ThreadLocalMap ──► Entry[] ──► Entry ──► value (10MB)
↑ 泄漏!
key (ThreadLocal) ← GC 掉了(弱引用)这就是内存泄漏的根源。
三种泄漏场景
| 场景 | 原因 | 后果 |
|---|---|---|
| 线程池复用 | 线程不死亡,ThreadLocalMap 持续存在 | value 持续累积 |
| set 后不 remove | 每次请求留下一个 value | 内存不断增长 |
| ThreadLocal 置 null | key 被 GC,value 仍存在 | value 成为孤岛 |
泄漏演示
java
public class LeakDemo {
private static final ThreadLocal<byte[]> bigData =
ThreadLocal.withInitial(() -> new byte[1024 * 1024 * 10]); // 10MB
public static void main(String[] args) throws InterruptedException {
System.out.println("初始内存");
printMemory();
for (int i = 0; i < 100; i++) {
Thread thread = new Thread(() -> {
bigData.get(); // 创建 ThreadLocal
// ⚠️ 如果不 remove(),10MB 泄漏
});
thread.start();
thread.join();
}
System.gc();
Thread.sleep(200);
System.out.println("处理完成(未清理)");
printMemory();
}
private static void printMemory() {
Runtime rt = Runtime.getRuntime();
long usedMB = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
System.out.println("内存使用: " + usedMB + " MB");
}
}每次线程结束时(join),Thread 死亡,value 才能被回收。但如果用的是线程池,Thread 不死亡,value 就持续累积。
ThreadLocalMap 的自我保护
ThreadLocalMap 不是什么都没做。它有 expungeStaleEntry() 方法,会在某些操作中清理「key 为 null」的过期 Entry:
java
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 清理过期 Entry
expungeStaleEntry 时:
// 1. 将 value 设为 null,让 GC 回收
// 2. 重新哈希,填补空位
}触发时机:
get()时,如果遇到 stale slot,会触发清理set()时,如果遇到 stale slot,会触发清理remove()时,会清理当前 Entry 并重新哈希
但这不意味着你可以不调用 remove()——清理是延迟的,不及时的清理仍然会造成泄漏。
正确清理方式
方式一:try-finally(必须掌握)
java
public class TryFinallyDemo {
private static final ThreadLocal<Connection> conn =
new ThreadLocal<>();
public static void main(String[] args) {
// ✅ 正确
try {
conn.set(getConnection());
// 业务逻辑
} finally {
conn.remove();
}
// ⚠️ 即使抛异常,finally 也会执行
}
private static Connection getConnection() {
return null; // 模拟
}
}方式二:AutoCloseable 封装(优雅)
java
public class ThreadLocalContext<T> implements AutoCloseable {
private final ThreadLocal<T> local = new ThreadLocal<>();
private final T value;
public ThreadLocalContext(T value) {
this.value = value;
local.set(value);
}
public T get() {
return local.get();
}
@Override
public void close() {
local.remove();
}
}
// 使用
try (ThreadLocalContext<String> ctx = new ThreadLocalContext<>("value")) {
String value = ctx.get();
// ...
} // 自动清理方式三:拦截器自动清理(Web 应用)
java
public class RequestContextFilter implements Filter {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
try {
// 请求开始:设置上下文
HttpServletRequest request = (HttpServletRequest) req;
currentUser.set(resolveUser(request));
chain.doFilter(req, res);
} finally {
// 请求结束:清理
currentUser.remove();
}
}
}Spring 的 RequestContextFilter(或 Interceptor)就是这样的机制。
线程池 + ThreadLocal
这是最容易泄漏的场景:
java
public class ThreadPoolLeakDemo {
private static final ThreadLocal<String> taskId = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(2);
// ⚠️ 错误示例:线程复用导致值残留
executor.submit(() -> {
taskId.set("task-A");
System.out.println("设置 task-A");
// 没有 remove,下一个任务会看到 task-A
});
Thread.sleep(100);
// 同一个线程,可能获取到上次的值
executor.submit(() -> {
String id = taskId.get();
System.out.println("获取 taskId: " + id); // 可能是 task-A,不是 null
});
Thread.sleep(100);
// ✅ 正确示例:在 finally 中清理
executor.submit(() -> {
try {
taskId.set("task-B");
System.out.println("设置 task-B");
} finally {
taskId.remove();
}
});
Thread.sleep(100);
executor.submit(() -> {
String id = taskId.get();
System.out.println("获取 taskId: " + id); // null,干净的值
});
executor.shutdown();
}
}内存泄漏检查清单
| 检查项 | 说明 |
|---|---|
| ThreadLocal 在哪里 set | 找到所有 set 点 |
| 是否在 finally 中 remove | 每一个 set 都必须有 remove |
| 线程池是否复用线程 | 是 → 必须每次任务前后清理 |
| 是否有 Filter/Interceptor 清理 | Web 应用应有统一清理机制 |
| value 占用内存多大 | 越大越容易 OOM |
要点回顾
- Entry 的 key 用弱引用,value 用强引用
- 当 ThreadLocal 置 null,key 被 GC,value 可能泄漏
- ThreadLocalMap 有
expungeStaleEntry()清理机制,但延迟且不完整 - 每次 set 后,必须在 finally 中 remove()
- 线程池场景尤其危险,线程复用且永不死亡
用一句话记住:ThreadLocal 是租房子,set() 是入住,remove() 是退房。赖着不走,后面的人就麻烦了。
相关链接
- ThreadLocal 核心概念 - 基本原理
- ThreadLocal 方法详解 - get/set/remove
- ThreadLocal 最佳实践 - 工程实践
