从ThreadLocal到内存泄漏,我踩了一个隐藏很深的坑
从ThreadLocal到内存泄漏,我踩了一个隐藏很深的坑
排查线上内存泄漏,GC 日志显示老年代对象持续增长,堆内存使用率从 60% 慢慢爬到 85%,FGC 次数越来越多。用 MAT 分析 dump 文件,发现有大量 ThreadLocal$ThreadLocalMap$Entry 对象占据了堆内存。
这说明 ThreadLocal 有问题。
问题现场
一个典型的用户上下文传递场景:
public class UserContext {
private static final ThreadLocal<UserInfo> CONTEXT = new ThreadLocal<>();
public static void set(UserInfo user) {
CONTEXT.set(user);
}
public static UserInfo get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
业务代码里这么用:
@Service
public class OrderService {
public void createOrder(CreateOrderCmd cmd) {
UserInfo user = UserContext.get();
// 使用 user 信息
Order order = new Order();
order.setUserId(user.getId());
// ...
}
}
用户登录时设置上下文,请求结束时清理:
@Controller
public class UserController {
@PostMapping("/login")
public ApiResponse login(@RequestBody LoginRequest request) {
UserInfo user = userService.login(request);
UserContext.set(user); // 设置上下文
return ApiResponse.success(user);
}
@PostMapping("/logout")
public ApiResponse logout() {
UserContext.clear(); // 清理上下文
return ApiResponse.success();
}
}
问题出在哪?用户可能直接关浏览器,或者长连接断开,根本没机会调用 logout。ThreadLocal 里的 UserInfo 对象永远不会被清理。
ThreadLocal 内存泄漏的原理
ThreadLocal 本身并不会内存泄漏,泄漏的是线程持有的 ThreadLocalMap。
ThreadLocalMap 是 ThreadLocal 的内部实现,每个 Thread 对象内部都有一个 ThreadLocalMap:
public class Thread implements Runnable {
ThreadLocalMap threadLocals = null; // Thread 类里的字段
}
ThreadLocalMap 的 Entry 结构:
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;
}
关键点:
- key(ThreadLocal)是弱引用:当 ThreadLocal 对象没有强引用时,可以被 GC 回收
- value 是强引用:即使 ThreadLocal 被回收,value 仍然被 Entry 引用,无法回收
正常情况下的清理
ThreadLocal 有自动清理机制:
public class ThreadLocal<T> {
// get() 时会清理过期的 Entry
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
return (T) e.value;
}
}
return setInitialValue(); // 这里会调用 expungeStaleEntries()
}
}
expungeStaleEntry() 会清理 key 已过期的 Entry,并重新计算索引位置。
内存泄漏的场景
但如果:
- ThreadLocal 变量是 static 的(在类加载时就创建)
- 线程池的核心线程是保活的
- 没有主动调用
remove()
那么:
// 静态的 ThreadLocal,永不销毁
public class UserContext {
private static final ThreadLocal<UserInfo> CONTEXT = new ThreadLocal<>();
}
// 线程池里的线程是复用的
private final ExecutorService pool = Executors.newFixedThreadPool(10);
// 请求1:设置用户A的上下文
UserContext.set(userA);
// 请求1 结束,但忘记调用 clear()
// 请求2:复用线程,处理用户B
// 但线程1的 ThreadLocalMap 里还有 userA 的 Entry
// 如果 userA 的 ThreadLocal 被回收了(理论上 static 不会),
// 实际上 static 的 ThreadLocal 不会被回收,所以 Entry 永远在
实际上 static ThreadLocal + 线程池 + 不清理 = 内存泄漏。
线程池场景下的泄漏
线程池里的线程是长久的,ThreadLocalMap 里的 Entry 如果不主动清理,就会一直存在:
public class ThreadPoolLeakDemo {
private static final ThreadLocal<byte[]> CACHE = new ThreadLocal<>();
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 0; i < 1000; i++) {
final int userId = i;
pool.submit(() -> {
// 每个请求分配 1MB 缓存
CACHE.set(new byte[1024 * 1024]);
// 模拟业务处理
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
// 忘记调用 remove()
// CACHE.remove();
});
}
}
}
每次请求都会在当前线程的 ThreadLocalMap 里放一个 1MB 的 Entry,1000 个请求 = 1GB 内存泄漏。
解决方案
方案一:每次使用后 remove
这是最正确的做法,但需要确保每个入口都清理:
public void handleRequest(HttpRequest request) {
try {
UserContext.set(parseUser(request));
// 业务逻辑
doBusiness();
} finally {
UserContext.remove(); // 无论成功失败都要清理
}
}
问题:依赖开发者自觉,容易遗漏。
方案二:拦截器/Filter 自动清理
用 Spring 的拦截器统一处理:
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// 请求结束后清理
UserContext.clear();
}
}
配置拦截器:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private UserContextInterceptor userContextInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/**");
}
}
这样所有请求处理完都会清理 ThreadLocal。
方案三:使用 try-with-resources 自动清理
自定义 AutoCloseable 包装:
public class UserContextHolder implements AutoCloseable {
public UserContextHolder(UserInfo user) {
UserContext.set(user);
}
@Override
public void close() {
UserContext.clear();
}
}
// 使用
public void handleRequest(HttpRequest request) {
try (UserContextHolder holder = new UserContextHolder(parseUser(request))) {
doBusiness();
}
}
方案四:使用阿里 transmittable-thread-local
Ttl 是阿里开源的库,解决了线程池场景下 ThreadLocal 的传递问题,并且有更完善的清理机制:
// 引入依赖
// <dependency>
// <groupId>com.alibaba</groupId>
// <artifactId>transmittable-thread-local</artifactId>
// <version>2.14.5</version>
// </dependency>
// 使用 TtlRunnable 或 TtlCallable
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(TtlRunnable.get(() -> {
UserInfo user = UserContext.get(); // 自动传递父线程的值
// 业务逻辑
}));
Ttl 的原理是在任务提交时 capture 父线程的 ThreadLocal 值,在任务执行时 set 回去,执行完后清理。
InheritableThreadLocal 的坑
除了 ThreadLocal,还有一个 InheritableThreadLocal,可以让子线程继承父线程的值:
public class InheritableDemo {
private static final InheritableThreadLocal<String> INHERIT =
new InheritableThreadLocal<>();
public static void main(String[] args) {
INHERIT.set("main thread value");
Thread child = new Thread(() -> {
// 子线程能拿到父线程的值
System.out.println(INHERIT.get()); // 输出 "main thread value"
});
child.start();
}
}
但 InheritableThreadLocal 在线程池里有更大的问题:
- 子线程的值是创建时复制的,不会随父线程更新
- 如果线程被线程池复用,子线程的值是上一次的值
public class InheritableThreadLocalPitfall {
private static final InheritableThreadLocal<String> INHERIT =
new InheritableThreadLocal<>();
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(1);
// 第一次设置
INHERIT.set("user1");
pool.submit(() -> System.out.println(INHERIT.get())).join(); // 输出 "user1"
// 第二次设置
INHERIT.set("user2");
// 线程复用,同一个线程打印的值是什么?
pool.submit(() -> System.out.println(INHERIT.get())).join(); // 输出 "user1"!
}
}
这就是 InheritableThreadLocal 在线程池里的"值不更新"问题。阿里 transmittable-thread-local 解决了这个问题。
最佳实践
- ThreadLocal 尽量不要用 static:static 的 ThreadLocal 生命周期等于类加载器生命周期,更容易泄漏
- 在线程池环境下,优先用 Ttl:避免传递和清理问题
- 无论如何,请求结束后要清理:用拦截器或 Filter 统一清理
- ThreadLocal 只存轻量数据:不要存大对象,如果必须存,确保及时清理
最终的 UserContext 改进
public class UserContext {
// 使用 Alipay Ttl
private static final TransmittableThreadLocal<UserInfo> CONTEXT =
new TransmittableThreadLocal<>();
public static void set(UserInfo user) {
CONTEXT.set(user);
}
public static UserInfo get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
配合拦截器确保清理:
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// 请求结束后无条件清理
UserContext.clear();
}
}
总结
ThreadLocal 内存泄漏的根因:
- ThreadLocalMap 的 Entry 对 value 是强引用
- 线程池的线程是复用的,不清理就会累积
- static ThreadLocal + 线程池 + 不清理 = 必然泄漏
解决方案优先级:
- 拦截器统一清理(最推荐):不依赖开发者自觉
- TransmittableThreadLocal(线程池必备):解决传递和清理问题
- try-with-resources(可选):代码更显式
ThreadLocal 是个好工具,但用错了就是内存泄漏的温床。
