ThreadLocal

9 阅读7分钟

一、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(线程1initRequestCache();  // 初始化线程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_v1cache.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 就一直占用
- 这就是内存泄漏!