外观
踩坑实录:ThreadLocal 在线程池里的泄漏与清理钩子
问题背景:不是 ThreadLocal 没用,而是线程还活着
一次接口服务上线后,堆内存使用量随流量缓慢爬升,Full GC 后 Old 区也只能回收一小部分。 dump 分析发现大量业务上下文对象仍然被 ThreadLocalMap$Entry 引用,而这些 Entry 所在线程全部来自 Tomcat 和业务异步任务的线程池。
很多同学记住了“ThreadLocal 的 key 是弱引用,所以不会泄漏”,但线上事故恰恰说明:弱引用只保护 key,不自动清理 value;只要线程长期存活,value 就可能长期可达。在 Web 容器、异步任务、虚拟线程调度器等场景中,线程会被反复复用,前一个请求遗留的用户信息、租户 ID、链路上下文不仅可能造成内存泄漏,还会造成数据串用。
下面用一个最小可运行示例复现问题,并给出工程上可靠的清理模式。
ThreadLocalMap、弱引用与线程池复用的关系
ThreadLocal 本身并不存储值。每个 Thread 对象内部持有一个 ThreadLocalMap,Map 的 Entry 结构可以简化理解为:
java
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 强引用:真正的业务对象
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}关系链路如下:
当局部变量中的 ThreadLocal 实例没有其他强引用时,GC 可以回收 key,Entry 会变成 key=null 的陈旧槽位。但 value 仍被 Entry 强引用,Entry 又被 ThreadLocalMap 引用,Map 被线程引用。固定大小线程池里的线程通常不会销毁,因此这些 value 会继续存活。只有后续在同一个线程上执行 get、set、remove 并触发哈希槽清理,或者线程本身退出,问题才可能缓解。
方案对比:只靠弱引用、try-finally 与统一包装
常见做法有三类:
| 方案 | 优点 | 局限 | 适用场景 |
|---|---|---|---|
| 依赖弱引用自动回收 | 无需业务代码 | 不能保证及时清理 value,依赖后续探测清理 | 仅适合短生命周期线程 |
每个任务手写 try-finally remove | 直观、成本低 | 容易漏写,多个 ThreadLocal 时清理顺序混乱 | 少量任务、临时脚本 |
| 线程池统一包装任务 | 强制清理,可审计可监控 | 需要统一线程池创建入口 | Web、异步任务、批处理等长期服务 |
工程上推荐把 ThreadLocal 当作借用人的资源:谁在任务开始时设置,谁必须在任务结束时清理;框架层再提供兜底,避免依赖人的记忆。
可运行复现:固定线程池如何保留大对象
下面代码不需要额外依赖,使用 JDK 17 可直接编译运行。为了避免 JIT 将对象优化掉,示例通过弱引用观察对象是否可回收。
java
import java.lang.ref.WeakReference;
import java.util.concurrent.*;
public class ThreadLocalLeakDemo {
// 模拟较大的请求上下文,例如用户信息、权限集合、租户配置
record BigContext(String tenantId, byte[] payload) {}
static final ThreadLocal<BigContext> CONTEXT = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
WeakReference<BigContext> ref = submitWithoutCleanup(pool);
System.gc();
Thread.sleep(200);
// 如果 value 已释放,ref.get() 应为 null;线程池复用时通常仍然非 null
System.out.println("未 remove,上下文是否还存在:" + (ref.get() != null));
// 再提交一个空任务,让同一个线程继续工作;此时还可能读到旧值或残留状态
pool.submit(() -> System.out.println("下一个任务读到旧上下文:" + CONTEXT.get())).get();
pool.shutdown();
}
private static WeakReference<BigContext> submitWithoutCleanup(ExecutorService pool)
throws Exception {
BigContext context = new BigContext("tenant-a", new byte[8 * 1024 * 1024]);
WeakReference<BigContext> ref = new WeakReference<>(context);
pool.submit(() -> {
// 典型错误:只 set,不在 finally 中 remove
CONTEXT.set(context);
handleRequest();
}).get();
return ref;
}
private static void handleRequest() {
System.out.println("处理请求:" + CONTEXT.get().tenantId());
}
}运行结果通常会显示上下文仍然存在,下一个任务还能读到上一个任务设置的值。注意,不同 JVM 的 GC 行为可能略有差异,但在生产上不能把回收时机建立在“多执行几次可能触发清理”这种不确定行为上。
正确姿势一:任务内部使用 try-finally 调用 remove
最小修复是把 set 和 remove 放在同一个作用域内:
java
private static void handleWithContext(ExecutorService pool, BigContext context) {
pool.submit(() -> {
BigContext previous = CONTEXT.get();
CONTEXT.set(context);
try {
handleRequest();
} finally {
// 无论成功还是异常,都必须解除当前线程对 value 的强引用
CONTEXT.remove();
}
});
}如果上下文可能由外层拦截器设置、内层任务只是临时覆盖,还应保存旧值并恢复:
java
static void replaceContext(BigContext next) {
BigContext old = CONTEXT.get();
CONTEXT.set(next);
try {
handleRequest();
} finally {
if (old == null) {
CONTEXT.remove();
} else {
CONTEXT.set(old);
}
}
}remove() 比简单 set(null) 更合适:前者会清理当前 Entry 并执行槽位清理;后者仍保留一个值为 null 的 Entry,在线程池场景下不够彻底。
正确姿势二:线程池统一安装清理钩子
业务代码一多,手写 try-finally 难免遗漏。更稳妥的方式是在线程池边界统一包装 Runnable 和 Callable,并对任务执行前后的上下文状态做监控。
java
import java.util.*;
import java.util.concurrent.*;
public final class ContextAwareExecutors {
private static final Set<ThreadLocal<?>> MANAGED =
Collections.newSetFromMap(new ConcurrentHashMap<>());
// 所有需要被线程池兜底清理的 ThreadLocal 都在这里登记
public static <T> ThreadLocal<T> register(ThreadLocal<T> local) {
MANAGED.add(local);
return local;
}
public static ThreadPoolExecutor fixedPool(int size, String namePrefix) {
return new ThreadPoolExecutor(
size, size,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
new ThreadFactory() {
private final AtomicInteger id = new AtomicInteger();
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, namePrefix + "-" + id.incrementAndGet());
t.setDaemon(true);
return t;
}
}
) {
@Override
public void execute(Runnable command) {
super.execute(wrap(command));
}
@Override
public <T> Future<T> submit(Callable<T> task) {
return super.submit(wrap(task));
}
@Override
public Future<?> submit(Runnable task) {
return super.submit(wrap(task));
}
};
}
private static Runnable wrap(Runnable task) {
return () -> {
try {
task.run();
} finally {
cleanupAfterTask();
}
};
}
private static <T> Callable<T> wrap(Callable<T> task) {
return () -> {
try {
return task.call();
} finally {
cleanupAfterTask();
}
};
}
private static void cleanupAfterTask() {
for (ThreadLocal<?> local : MANAGED) {
// 兜底清理,防止业务漏写 finally;发现残留时应打点或记录告警
if (local.get() != null) {
System.err.println("检测到 ThreadLocal 残留,线程=" + Thread.currentThread().getName());
local.remove();
}
}
}
private static final class AtomicInteger {
private int value;
synchronized int incrementAndGet() { return ++value; }
}
}使用方式:
java
public class CleanDemo {
private static final ThreadLocal<String> TENANT =
ContextAwareExecutors.register(new ThreadLocal<>());
public static void main(String[] args) throws Exception {
ThreadPoolExecutor pool = ContextAwareExecutors.fixedPool(2, "biz-worker");
for (int i = 0; i < 10; i++) {
String tenant = "tenant-" + i;
pool.submit(() -> {
TENANT.set(tenant); // 即使忘记 remove,包装器也会在任务结束时清理
System.out.println(Thread.currentThread().getName() + " 处理 " + TENANT.get());
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
}上面的 AtomicInteger 只是为了示例自包含,实际项目应使用 java.util.concurrent.atomic.AtomicInteger。如果使用 Spring,可以把类似逻辑放到 TaskDecorator;如果使用 Tomcat 或 Jetty,则应在 Filter 的 finally 中清理请求上下文,因为请求线程同样会被复用。
如何观测与验证清理是否生效
第一,在压测前后分别执行堆直方图,观察业务上下文类实例数量是否随任务结束下降;第二,给关键 ThreadLocal 增加残留计数,任务结束时若值仍非空,输出线程名、任务名和 traceId;第三,编写多任务复用同一个线程的单元测试,第二个任务开始时断言上下文必须为空。
需要特别强调虚拟线程:虚拟线程数量可以很多,不能因为它“轻量”就随意保留 ThreadLocal。大量短暂虚拟线程可能降低单线程长期复用造成的串号概率,但深度递归、调度器载体线程、池化通道中的上下文仍然可能带来额外内存占用。迁移到虚拟线程时,更推荐使用作用域明确、不可变传递的上下文对象;必须使用线程局部语义时,也要保留边界清理和监控。
避坑总结
- 错误现象:认为 key 是弱引用就不会泄漏。 修正方法:明确 Entry 对 value 是强引用,线程池线程不退出时 value 仍可达;所有跨请求复用线程都必须显式
remove()。 - 错误现象:只在正常返回路径清理,任务抛异常后下个任务读到旧租户或旧用户。 修正方法:把清理写入
finally,框架包装器也必须在finally中执行,不能只包一层成功分支。 - 错误现象:用
set(null)代替remove(),以为值清空就万事大吉。 修正方法:优先调用remove(),它会删除 Entry 并触发陈旧槽清理;嵌套场景还要保存并恢复 previous 值。 - 错误现象:业务团队各自
new ThreadLocal,线程池无法统一兜底。 修正方法:建立受控的上下文注册入口和统一线程池工厂,对残留值打点告警,并在代码评审中禁止请求级 ThreadLocal 跨任务裸奔。 - 错误现象:把大对象集合、权限缓存、分页结果放入 ThreadLocal 后异步传递。 修正方法:ThreadLocal 只放小而必要的标识信息,大对象通过方法参数或显式上下文对象传递,避免隐式生命周期和内存放大。
ThreadLocal 泄漏的本质不是引用类型“玄学”,而是资源生命周期没有闭合。在线程池复用模型下,把设置、传递、恢复、移除做成强制边界,并用测试和监控验证,才能同时避免内存泄漏和请求数据串用。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。