一、ThreadLocal 是什么?
ThreadLocal 是 Java 提供的一个线程隔离的工具类。它可以让每个线程都有自己独立的变量副本,互不干扰。
核心特性
- 线程隔离:每个线程有独立的存储空间
- 自动识别:无需手动指定线程,ThreadLocal 自动识别当前线程
- 内存管理:必须手动
remove(),否则会造成内存泄漏
二、ThreadLocal 的本质
ThreadLocal 的内部实现就是:以 Thread 为 key 的 Map
ThreadLocal 内部结构:
{
Thread-1 → ConcurrentHashMap { key1: data1, key2: data2 }
Thread-2 → ConcurrentHashMap { key1: data1', key2: data2' }
Thread-3 → ConcurrentHashMap { key1: data1'', key2: data2'' }
}
简化理解
// ThreadLocal 内部大概是这样的
public class ThreadLocal<T> {
// 以 Thread 为 key 的 Map
private Map<Thread, T> values = new WeakHashMap<>();
public void set(T value) {
values.put(Thread.currentThread(), value);
}
public T get() {
return values.get(Thread.currentThread());
}
public void remove() {
values.remove(Thread.currentThread());
}
}
三、ThreadLocal 的基础使用
1. 创建 ThreadLocal
// 方式1:直接创建
private static ThreadLocal<String> userThreadLocal = new ThreadLocal<>();
// 方式2:使用 withInitial(推荐)
private final ThreadLocal<ConcurrentHashMap<String, ExpResponse>> cache =
ThreadLocal.withInitial(ConcurrentHashMap::new);
2. 基本操作
// 设置值
cache.set(new ConcurrentHashMap<>());
// 获取值
ConcurrentHashMap<String, ExpResponse> map = cache.get();
// 清除值(重要!防止内存泄漏)
cache.remove();
四、项目中的应用场景
问题背景
在获取操作列表时,需要查询多个实验的详情信息。如果同一个请求中有多个操作引用同一个实验,会导致重复调用 API。
解决方案
使用 ThreadLocal 实现请求级别的缓存:
- 同一请求内:相同的 key 只调用一次 API,后续从本地缓存读取
- 不同请求间:各自有各自的缓存,数据隔离
五、实际代码示例
Service 中的实现
/**
* 请求级别的实验详情缓存(ThreadLocal)
* 用于在单个请求内缓存实验详情,避免同一请求中重复调用 API
*/
private final ThreadLocal<ConcurrentHashMap<String, ExpResponse>> requestLevelCache =
ThreadLocal.withInitial(ConcurrentHashMap::new);
/**
* 初始化请求级别缓存
* 在处理请求前调用
*/
public void initRequestCache() {
requestLevelCache.set(new ConcurrentHashMap<>());
}
/**
* 清除请求级别缓存
* 在请求处理完成后调用
*/
public void clearRequestCache() {
requestLevelCache.remove();
}
/**
* 获取请求级别缓存
*/
private ConcurrentHashMap<String, ExpResponse> getRequestCache() {
return requestLevelCache.get();
}
/**
* 查询实验详情(带请求级别缓存)
* 在单个请求内缓存,避免重复调用 API
*/
public ExpResponse getExpDetailWithCache(String expKey) throws Exception {
ConcurrentHashMap<String, ExpResponse> cache = getRequestCache();
// 先从请求级别缓存中查询
ExpResponse cachedResponse = cache.get(expKey);
if (cachedResponse != null) {
return cachedResponse;
}
// 缓存未命中,调用 API 获取实验详情
ExpResponse response = getExpDetail(expKey);
// 将结果存入请求级别缓存
if (response != null) {
cache.put(expKey, response);
}
return response;
}
Controller 中的使用
private List<Operation> getOperationByProcessType(Long id, String processType) {
List<Operation> operations = planOperationService.queryByPlanId(id);
// 初始化请求级别缓存
arenaService.initRequestCache();
try {
// 获取最新实验信息
// ⚠️问题:大部分operation是同一个实验,如果每次都请求实验api会导致整个接口性能很差
operations.forEach(operation -> {
getCurrentExpValue(operation);
});
} finally {
// 清除请求级别缓存
arenaService.clearRequestCache();
}
return operations;
}
六、执行流程示例
场景:同一请求中有多个相同的 key
请求1(线程1)
initRequestCache(); // 初始化线程1的缓存
for (Operation op : operations) {
// 第一个 key1:缓存未命中 → 调用 API → 存入缓存
ExpResponse data1 = getExpDetailWithCache("key1");
// 第二个 key2:缓存未命中 → 调用 API → 存入缓存
ExpResponse data2 = getExpDetailWithCache("key2");
// 第三个 key1:缓存命中 ✅ → 直接返回(不调用 API)
ExpResponse data3 = getExpDetailWithCache("key1");
// 第四个 key3:缓存未命中 → 调用 API → 存入缓存
ExpResponse data4 = getExpDetailWithCache("key3");
// 第五个 key1:缓存命中 ✅ → 直接返回(不调用 API)
ExpResponse data5 = getExpDetailWithCache("key1");
}
clearRequestCache(); // 清除线程1的缓存
请求2(线程2)同时进来
此时如果实验信息已经更新为 v2版本,能保证获取到最新实验数据
initRequestCache(); // 初始化线程2的缓存(独立的!)
for (Operation op : operations) {
// 第一个 key1:线程2的缓存未命中 → 调用 API 获取最新数据 → 存入线程2的缓存
ExpResponse data1 = getExpDetailWithCache("key1");
// 第二个 key1:线程2的缓存命中 ✅ → 直接返回(不调用 API)
ExpResponse data2 = getExpDetailWithCache("key1");
// 第三个 key2:线程2的缓存未命中 → 调用 API 获取最新数据 → 存入线程2的缓存
ExpResponse data3 = getExpDetailWithCache("key2");
}
clearRequestCache(); // 清除线程2的缓存
缓存状态对比
请求1(线程1)的缓存:
{
key1: data1_v1,
key2: data2_v1,
key3: data3_v1
}
请求2(线程2)的缓存:
{
key1: data1_v2, // 不同的版本!
key2: data2_v2
}
关键点:
✅ 请求1 中,key1 出现了 3 次,但只调用了 1 次 API
✅ 请求2 中,key1 出现了 2 次,但只调用了 1 次 API
✅ 请求1 和请求2 的 key1 数据不同,各自独立
七、ThreadLocal vs 全局缓存
不用 ThreadLocal(全局缓存)的问题
请求1(用户A) 请求2(用户B)
↓ ↓
调用 getExpDetail("key1")
↓
获取最新数据:data1_v1
↓
cache.put("key1", data1_v1)
↓
缓存: {key1: data1_v1}
调用 getExpDetail("key1")
↓
从缓存读取 ❌
↓
得到 data1_v1(过时的!如果实验数据更新为v2版本)
↓
用户B看到的不是最新数据
用 ThreadLocal(请求级别缓存)的优势
请求1(线程1) 请求2(线程2)
↓ ↓
initRequestCache() initRequestCache()
↓ ↓
缓存1: {} 缓存2: {}
↓ ↓
调用 getExpDetail("key1") 调用 getExpDetail("key1")
↓ ↓
获取最新数据:data1_v1 获取最新数据:data1_v2
↓ ↓
缓存1.put("key1", v1) 缓存2.put("key1", v2)
↓ ↓
缓存1: {key1: v1} 缓存2: {key1: v2}
↓ ↓
用户A读取:v1 ✅ 用户B读取:v2 ✅
↓ ↓
clearRequestCache() clearRequestCache()
↓ ↓
缓存1被清除 缓存2被清除
ThreadLocal 方案虽然在极端情况下也可能读到旧数据,但概率确实比全局缓存方案低得多
八、ThreadLocal 的内存泄漏问题
为什么会发生内存泄漏?
ThreadLocal 的内存泄漏本质上是因为:线程对象生命周期长,而 ThreadLocal 中存储的数据没有被及时清理
三个必要条件
| 要素 | 说明 |
|---|---|
| 线程池 | 线程不销毁,一直被重用 |
| 大对象 | ThreadLocal 中存储的数据占用内存 |
| 忘记 remove() | 没有及时清理数据 |
只要满足这三个条件,就会发生内存泄漏!
项目中的内存泄漏场景
❌ 错误做法:忘记调用 remove()
// ArenaService.java - 错误实现
public void clearRequestCache() {
// requestLevelCache.remove(); // ❌ 注释掉了!
log.debug("Cleared request level cache");
}
// 或者在 Controller 中忘记了 finally
private List<Operation> getOperationByProcessType(Long id, String processType) {
List<Operation> operations = planOperationService.queryByPlanId(id);
arenaService.initRequestCache();
// ❌ 没有 try-finally,如果发生异常就不会清除
operations.forEach(operation -> {
getCurrentExpValue(operation);
});
// ❌ 没有调用 clearRequestCache()
return operations;
}
内存泄漏的具体过程
时间线:
T1: 请求1 来到,Tomcat 线程池分配 Thread-1 处理
↓
initRequestCache() // 初始化缓存
↓
requestLevelCache 中存储了 ConcurrentHashMap { exp1: {...}, exp2: {...}, ... }
↓
处理请求完成
↓
❌ 没有调用 clearRequestCache(),数据仍然在 ThreadLocal 中!
↓
Thread-1 回到线程池,等待下一个请求
T2: 请求2 来到,Tomcat 线程池复用 Thread-1 处理
↓
initRequestCache() // 初始化缓存
↓
requestLevelCache.set(new ConcurrentHashMap<>()) // 设置新的缓存
↓
✅ set() 会覆盖旧值,请求1 的 ConcurrentHashMap 被覆盖
↓
此时 ThreadLocal 中:
- 当前指向:请求2 的 ConcurrentHashMap { }
- 请求1 的对象被覆盖,如果没有其他引用就会被 GC 回收 ✅
↓
处理请求完成
↓
❌ 但没有调用 clearRequestCache()
↓
Thread-1 回到线程池,请求2 的 ConcurrentHashMap 仍然在 ThreadLocal 中!
T3: 请求3 来到,Tomcat 线程池复用 Thread-1 处理
↓
initRequestCache() // 初始化缓存
↓
requestLevelCache.set(new ConcurrentHashMap<>()) // 又设置新的缓存
↓
✅ set() 会覆盖旧值,请求2 的 ConcurrentHashMap 被覆盖
↓
此时 ThreadLocal 中:
- 当前指向:请求3 的 ConcurrentHashMap { }
- 请求2 的对象被覆盖,如果没有其他引用就会被 GC 回收 ✅
↓
处理请求完成
↓
❌ 但没有调用 clearRequestCache()
↓
Thread-1 回到线程池,请求3 的 ConcurrentHashMap 仍然在 ThreadLocal 中!
...
结果:
每个请求的 ConcurrentHashMap 都会被覆盖并 GC 回收,但问题是:
- 请求1 的 ConcurrentHashMap(10MB)→ 被覆盖后 GC 回收 ✅
- 请求2 的 ConcurrentHashMap(10MB)→ 被覆盖后 GC 回收 ✅
- 请求3 的 ConcurrentHashMap(10MB)→ 被覆盖后 GC 回收 ✅
❌ 真正的问题是:当前指向的对象永远不会被清理!
- 请求3 的 ConcurrentHashMap 一直被 ThreadLocal 持有
- 线程 Thread-1 不销毁,就一直占用这 10MB 内存
- 如果有 100 个请求,就有 100 个线程,就是 100 * 10MB = 1GB 内存泄漏!
ThreadLocal 内部结构的堆积
requestLevelCache 内部(以 Thread-1 为例):
T1 之后:
{
Thread-1 → 请求1 的 ConcurrentHashMap { exp1: {...}, exp2: {...}, ... } (10MB)
}
T2 之后(set() 覆盖了引用):
{
Thread-1 → 请求2 的 ConcurrentHashMap { } (10MB)
✅ 请求1 的对象被覆盖,可以被 GC 回收
}
T3 之后(set() 又覆盖了引用):
{
Thread-1 → 请求3 的 ConcurrentHashMap { } (10MB)
✅ 请求2 的对象被覆盖,可以被 GC 回收
}
❌ 真正的问题:
- 当前指向的对象(请求3 的 ConcurrentHashMap)永远不会被清理
- 因为没有调用 remove(),ThreadLocal 中的引用一直存在
- 线程 Thread-1 不销毁,这 10MB 就一直占用
- 这就是内存泄漏!