ThreadLocal 内存泄漏与优化使用
ThreadLocal 是 Java 开发中的「双刃剑」——用对了是神器,用错了就是内存泄漏的温床。
很多人知道 ThreadLocal 有内存泄漏风险,但具体是怎么回事?「弱引用保护」听起来很安全,为什么还会泄漏?
今天,我们来彻底搞清楚 ThreadLocal 的内存泄漏原理,以及正确的使用姿势。
ThreadLocal 的基本原理
先来看 ThreadLocal 的结构:
public class ThreadLocal<T> {
// ThreadLocalMap 是 ThreadLocal 的内部类
static class ThreadLocalMap {
// Entry 继承自 WeakReference<ThreadLocal<?>>
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // 弱引用 key
value = v; // 强引用 value
}
}
private Entry[] table;
}
}
// Thread 对象中持有 ThreadLocalMap
public class Thread {
ThreadLocal.ThreadLocalMap threadLocals;
}关系图如下:
Thread 对象
↓
threadLocals (ThreadLocalMap)
↓
table[] (Entry 数组)
↓
Entry: key(弱引用 ThreadLocal) + value(强引用)为什么会内存泄漏?
问题的关键在于 Entry 的 value 是强引用。
假设这样的场景:
public class ThreadLocalLeakDemo {
private static ThreadLocal<byte[]> local = new ThreadLocal<>();
private static final int MB = 1024 * 1024;
public static void main(String[] args) {
// 线程池场景
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.submit(() -> {
// 设置一个大对象
local.set(new byte[10 * MB]); // 10MB
// 业务逻辑
doSomething();
// 业务结束了,但 ThreadLocal 没有 remove
// local.remove(); // 忘记调用
});
// 线程不会退出(核心线程),ThreadLocalMap 一直存在
pool.shutdown();
}
}分析泄漏过程:
- ThreadLocal 对象使用完毕,栈上的局部变量
local消亡 - 但 Thread 对象是线程池创建的,生命周期很长,不会消亡
- ThreadLocalMap 中 Entry 的 key(ThreadLocal)因为是弱引用,下一次 GC 就会被回收
- Entry 的 value 是强引用,依然指向 10MB 的数组
- 结果:key 没了,value 还在,这条 Entry 永远无法被清理
Entry 的状态:
key: null (被 GC 回收了) → value: 10MB 数组 ← 无法访问,也无法回收!这就是内存泄漏的完整链条。
弱引用保护了什么?
你可能会问:Entry 的 key 为什么要用弱引用?弱引用保护了什么?
弱引用的设计是为了保护 ThreadLocal 对象本身。如果没有弱引用,ThreadLocal 对象就会因为被 Entry 引用而无法被 GC,导致 ThreadLocal 对象本身泄漏(以及它引用的整个对象图)。
但弱引用只保护了 key,没有保护 value。value 依然需要手动 remove()。
为什么不直接用弱引用保护 value?
如果 Entry 的 value 也改成弱引用会怎样?
// 假设的弱引用 value 设计
static class Entry extends WeakReference<ThreadLocal<?>> {
WeakReference<Object> valueRef; // value 也是弱引用
}问题是:value 经常是有用的对象。如果 value 被 GC 回收了,ThreadLocal 的值就「莫名其妙」丢了,你拿到的可能是 null——这会引入更难排查的 bug。
所以 JDK 的设计是:key 用弱引用保护 ThreadLocal,value 靠手动 remove。
ThreadLocalMap 的自动清理机制
JVM 也不是完全不管。ThreadLocalMap 有两个清理机制:
1. 探测式清理(expungeStaleEntry)
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 删除 stale Slot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 重新哈希,清理沿途遇到的其他 stale Entry
Entry e = tab[nextIndex(staleSlot, len)];
while (e != null) {
int index = e.get().hashCode() & (len - 1);
if (index != staleSlot) {
// 当前 slot 不是正确的位置
tab[index] = null; // 删除旧位置的引用
rehash(e); // 重新插入
}
e = nextEntry(e);
}
return staleSlot;
}2. 延迟清理(cleanSomeSlots)
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) { // key 被 GC 了
expungeStaleEntry(i);
removed = true;
}
n >>= 1; // 只扫描 log(n) 个位置
} while (n != 0);
return removed;
}但问题是:这些清理是「惰性」的,只在特定操作(如 get、set)时触发。如果 ThreadLocal 只是创建了但从未使用过,清理机制永远不会运行。
正确使用 ThreadLocal
原则一:用完必 remove
public class CorrectUsage {
private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
try {
return dateFormat.get().format(date);
} finally {
dateFormat.remove(); // 无论如何都要 remove
}
}
}原则二:在 finally 块中清理
public class UserContextFilter implements Filter {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
try {
User user = authenticate(req);
currentUser.set(user); // 设置用户上下文
chain.doFilter(req, resp);
} finally {
currentUser.remove(); // 请求结束后清理
}
}
}原则三:使用 try-with-resources 自动清理
public class ThreadLocalHolder<T> implements AutoCloseable {
private final ThreadLocal<T> threadLocal;
public ThreadLocalHolder(ThreadLocal<T> threadLocal, T value) {
this.threadLocal = threadLocal;
threadLocal.set(value);
}
public T get() {
return threadLocal.get();
}
@Override
public void close() {
threadLocal.remove();
}
}
// 使用
try (ThreadLocalHolder<Connection> holder = new ThreadLocalHolder<>(connectionHolder, conn)) {
Connection conn = holder.get();
conn.execute();
} // 自动 remove原则四:线程池场景更要小心
public class ThreadPoolUsage {
private static final ThreadLocal<byte[]> buffer = ThreadLocal.withInitial(() -> new byte[1024]);
public void process() {
try {
byte[] buf = buffer.get();
// 业务逻辑
} finally {
buffer.remove(); // 线程会被复用,必须清理
}
}
}内存泄漏的排查
如果已经产生了内存泄漏,可以用以下方式排查:
// 方式 1: 通过 Thread dump 查看 ThreadLocal
jstack -l <pid> | grep ThreadLocal
// 方式 2: MAT 分析堆内存
// 导出堆: jmap -dump:format=b,file=heap.hprof <pid>
// 用 MAT 打开,查看 ThreadLocalMap 中的 Entry总结
ThreadLocal 内存泄漏的完整链条:
ThreadLocal 对象创建
↓
Entry<key, value> 加入 ThreadLocalMap
↓
ThreadLocal 对象使用完毕,无强引用
↓
弱引用 key 在 GC 时被回收
↓
value 依然是强引用,无法回收
↓
Thread 不结束,ThreadLocalMap 一直存在
↓
内存泄漏!避免泄漏的方法:
- 用完必 remove(最重要)
- 在 finally 块中清理
- 线程池场景更要小心
- 定期检查和监控
ThreadLocal 是工具,不是银弹。用对了简化代码,用错了制造泄漏。
留给你的问题
InheritableThreadLocal 可以让子线程继承父线程的值,但 InheritableThreadLocal 也有内存泄漏风险吗?如果有,和普通 ThreadLocal 有什么区别?
提示:考虑 InheritableThreadLocal 的实现方式和线程继承的场景。
