面试官问:"了解ThreadLocal吗?为什么会内存泄漏?"
很多人只知道"每个线程独立",再深问就答不上了。今天把ThreadLocal从原理到坑都讲清楚。
ThreadLocal解决什么问题
多线程共享变量会有线程安全问题,但有时候我们需要的不是共享,而是每个线程有自己的副本。
public class UserContext {
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
public static void set(User user) {
CURRENT_USER.set(user);
}
public static User get() {
return CURRENT_USER.get();
}
public static void remove() {
CURRENT_USER.remove();
}
}
典型场景:把登录用户信息在请求处理的各个层之间传递,不用一路传参数。
// Controller层设置
UserContext.set(loginUser);
// Service层直接取
UserContext.get().getId();
底层原理
每个Thread对象内部都有一个ThreadLocalMap:
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
}
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key是弱引用
value = v; // value是强引用
}
}
private Entry[] table;
}
关系图:
Thread
└── ThreadLocalMap
└── Entry[]
├── key: WeakReference(ThreadLocal) ← 弱引用
└── value: Object ← 强引用
注意这个设计:key是弱引用,value是强引用。
为什么会内存泄漏
ThreadLocal ref (强) ──→ ThreadLocal对象
↑
│ 弱引用
Entry.key ────────────────────┘
Entry.value (强) ──→ 大对象
当外部不再持有ThreadLocal的强引用时:
- 弱引用的
key会被GC回收,变成null - 但
value是强引用,Entry本身还被ThreadLocalMap持有 - 这块value就成了"key为null但value还在"的僵尸Entry
如果线程是线程池里的常驻线程,这个Thread不会结束,ThreadLocalMap也不会被回收,value就永远泄漏了。
单个ThreadLocal泄漏可能就几十字节,但如果有大量线程、大量使用,内存会持续上涨。
正确用法:用完remove
try {
UserContext.set(user);
doBusiness();
} finally {
UserContext.remove(); // 必须放在finally里
}
为什么放finally?因为就算业务抛异常,也能保证清理。
Spring MVC的DispatcherServlet就是这么处理的——请求结束后清理各种ThreadLocal。
怎么在框架里更优雅地做
用拦截器统一处理:
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String token = request.getHeader("Authorization");
User user = tokenService.parse(token);
UserContext.set(user);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
UserContext.remove(); // 请求结束必清理
}
}
这样业务代码完全不用管ThreadLocal的生命周期。
子线程能拿到父线程的值吗
默认不能:
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("main");
new Thread(() -> {
System.out.println(tl.get()); // null
}).start();
需要InheritableThreadLocal:
InheritableThreadLocal<String> tl = new InheritableThreadLocal<>();
tl.set("main");
new Thread(() -> {
System.out.println(tl.get()); // main
}).start();
但InheritableThreadLocal在线程池里不好用——线程是复用的,不会每次都继承。
阿里开源的TransmittableThreadLocal(TTL)解决了这个问题,支持线程池场景的上下文传递。
常见使用场景
| 场景 | 说明 |
|---|---|
| 用户上下文 | 登录信息跨层传递 |
| 请求追踪 | traceId贯穿全链路 |
| 数据库连接 | Spring的事务管理 |
| SimpleDateFormat | 每个线程一个实例,避免线程安全问题 |
| 分库分表路由 | 分片键在ThreadLocal里传 |
一个经典的正确用法:SimpleDateFormat
// 错误:SimpleDateFormat不是线程安全的
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// 正确:每个线程一个
private static final ThreadLocal<SimpleDateFormat> SDF =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 使用
SDF.get().format(new Date());
withInitial是Java 8加的方法,省去了重写initialValue()的麻烦。
面试怎么答
被问到ThreadLocal,按这个思路回答:
- 每个Thread内部有ThreadLocalMap,以ThreadLocal为key,以变量副本为value
- Entry的key是弱引用,value是强引用
- 弱引用key被回收后,value会泄漏,所以用完必须remove
- 线程池场景下泄漏影响更大,因为线程不销毁
- 实际项目用拦截器+finally统一管理生命周期
把这五点说清楚,基本就稳了。