ThreadLocal 入门:它是怎么隔离数据的、为什么会导致内存泄漏

ThreadLocal 入门:它是怎么隔离数据的、为什么会导致内存泄漏

📌 先说结论

ThreadLocal 就干一件事:

给每个线程存一份独立的变量副本,线程之间互不干扰。

典型用途:一次请求的上下文(当前登录用户、traceId),一个线程从头用到尾,不用到处传参。

但它有个必须知道的坑:在线程池里不清理,会导致内存泄漏 + 数据串号(脏数据)。实测会给你看。


一、先看它解决什么问题

1.1 场景:请求上下文到处传参

假设一个 Web 请求要记录"当前登录用户",你会怎么写?

java 复制代码
// ❌ 方式一:参数一层层往下传,方法签名全被污染
public void handle(String userId) {
    service.doBiz(userId);            // 传
}
public void doBiz(String userId) {
    dao.query(userId);                // 再传
}
// 中间每一层都要加这个参数,很啰嗦

ThreadLocal 的解法:

java 复制代码
// ✅ 存一份"线程私有"的副本
public class UserContext {
    private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();

    public static void set(String userId) { CURRENT_USER.set(userId); }
    public static String get() { return CURRENT_USER.get(); }
    public static void clear() { CURRENT_USER.remove(); }
}

// 任意地方直接用,不用传参
UserContext.set("张三");
// ... 很多层调用之后 ...
String user = UserContext.get();     // 还是"张三"

这就是核心价值 :同一个线程内,数据全局可访问,不用改所有方法的签名。

1.2 实测:线程之间确实隔离

java 复制代码
ThreadLocal<String> tl = new ThreadLocal<>();

Thread t1 = new Thread(() -> { tl.set("A的数据"); System.out.println(tl.get()); });
Thread t2 = new Thread(() -> { tl.set("B的数据"); System.out.println(tl.get()); });

实测输出:

复制代码
线程1 读到: A的数据
线程2 读到: B的数据

两个线程各自设了值,互不影响。 这就是"线程隔离"。


二、原理:数据到底存在哪

2.1 一句话原理

数据不是存在 ThreadLocal 里,而是存在 Thread 自己身上。

这是最关键的一点------很多人误以为是"ThreadLocal 里存了一个 Map"。错了。

真实结构:

复制代码
每个 Thread 对象内部有一个 ThreadLocalMap
    │
    └── key = ThreadLocal 对象(弱引用)
        value = 你要存的数据

Thread-1
  └── threadLocals (ThreadLocalMap)
        ├── entry1: key=TL_A(弱引用) → value="张三"
        └── entry2: key=TL_B(弱引用) → value="男"

Thread-2
  └── threadLocals (ThreadLocalMap)
        ├── entry1: key=TL_A(弱引用) → value="李四"
        └── entry2: key=TL_B(弱引用) → value="女"

所以 ThreadLocal.get() 实际是:拿到当前线程 → 用当前线程的 ThreadLocalMap → 以这个 ThreadLocal 对象为 key 去查值。

💡 这也解释了为什么它是"线程隔离"的 ------每个线程查的是自己的 那个 ThreadLocalMap,天然不会串。

2.2 ThreadLocalMap 和普通 HashMap 的区别

点 HashMap ThreadLocalMap
结构 链表 + 红黑树 纯开放地址法(数组 + 线性探测)
key 强引用 ThreadLocal 是弱引用
作用 通用存储 只是线程私有数据

key 是弱引用------这是理解内存泄漏的关键,下一章讲。


三、内存泄漏坑(重点)

3.1 先看实测:线程池里的脏数据

这是最常出事的场景------线程池会复用线程,上一次任务的数据会留给下一次。

java 复制代码
ThreadLocal<String> userTl = new ThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1);   // 只用一个线程复用

// 任务1:设置数据,但没清理
pool.submit(() -> {
    userTl.set("用户张三");
    System.out.println("任务1: " + userTl.get());
}).get();

// 任务2:没设置,却读到了任务1的残留!
pool.submit(() -> {
    System.out.println("任务2 未设置,读到: " + userTl.get());   // ← "用户张三"
}).get();

实测输出:

复制代码
任务1 设置: 用户张三, 当前 = 用户张三
任务2 未设置,读到 = 用户张三 ← 上一个任务的残留!(脏数据)

这就是数据串号 :任务 2 根本没设数据,却读到了上一个用户的用户名。

在生产里的后果 :用户 B 的请求,读到了用户 A 的登录信息------这是安全事故级别的 bug。

3.2 再看待测:清理之后

java 复制代码
// 任务3:用完立刻清理
pool.submit(() -> {
    try {
        userTl.set("用户李四");
        System.out.println("任务3: " + userTl.get());
    } finally {
        userTl.remove();       // ← 关键!
    }
}).get();

// 任务4:读到 null,正确
pool.submit(() -> {
    System.out.println("任务4 读到: " + userTl.get());   // ← null
}).get();

实测输出:

复制代码
任务3 设置: 用户李四, 当前 = 用户李四
任务4 读到 = null ← 已清理,正确为 null

修复就一行:remove() 放 finally。

3.3 为什么会内存泄漏

根本原因:ThreadLocalMap 的 key 是弱引用,但 value 是强引用。

复制代码
ThreadLocalMap 的 entry:
    key   = ThreadLocal 对象(弱引用)  ← 可以被 GC 回收
    value = 你存的数据    (强引用)    ← 只要 entry 还在,就不会回收

泄漏链条:

复制代码
① 你在方法里 new 了一个 ThreadLocal(局部变量,方法结束后没人引用它)
② ThreadLocalMap 里这个 entry 的 key 是弱引用 → 下一次 GC 时 key 被回收变成 null
③ 但 value 是强引用,还挂在 entry 上 → value 无法回收
④ 更糟的是:这个 entry 还挂在 Thread 的 ThreadLocalMap 上
   → 只要线程不死(线程池里的核心线程就不死),这个 value 就一直占着内存

所以:长期运行的线程池 + 大量临时 ThreadLocal = 内存持续增长。

💡 那为什么 JDK 不干脆用强引用?

因为如果 key 是强引用,ThreadLocal 对象永远无法被回收------那才是真泄漏。弱引用是权衡后的设计 ,但要求使用者自己 remove()。

3.4 正确用法模板

java 复制代码
private static final ThreadLocal<UserInfo> CURRENT_USER = new ThreadLocal<>();

// ✅ 用 try-finally 保证清理
public void handleRequest(UserInfo user) {
    try {
        CURRENT_USER.set(user);
        // ... 业务逻辑
    } finally {
        CURRENT_USER.remove();      // 必须!
    }
}

两个规范:

  1. ThreadLocal 声明为 static final(避免每次 new,也避免它本身成为泄漏源);
  2. set() 之后一定在 finally 里 remove()。

💡 如果用 Spring :RequestContextHolder 已经帮你处理了,你可以直接用 RequestContextHolder.getRequestAttributes(),不用自己维护。


四、InheritableThreadLocal(了解即可)

子线程想继承父线程的值,用 InheritableThreadLocal:

java 复制代码
InheritableThreadLocal<String> tl = new InheritableThreadLocal<>();
tl.set("父线程的值");

Thread child = new Thread(() -> System.out.println(tl.get()));   // 能读到"父线程的值"
child.start();

但注意 :它只在"创建子线程"的那一刻复制一次 。线程池里的线程是复用的,所以不生效。

线程池里要传递上下文,得用别的方案:

  • 阿里 TransmittableThreadLocal(TTL)------专为线程池设计;
  • 或者在提交任务时,手动把上下文打包传入。

五、ThreadLocal vs synchronized

经常有人混淆这两个,其实方向完全相反:

ThreadLocal synchronized
思路 隔离:每个线程有自己的副本 同步:共享同一份,排队访问
解决问题 避免共享 控制共享访问
性能 高(无竞争) 有锁开销
适用 每个线程需要独立状态 多个线程要共享状态

一句话:

不想共享 → ThreadLocal;必须共享 → 加锁。


六、面试常问的 6 个问题

1. ThreadLocal 是什么?有什么用?

答:给每个线程提供独立的变量副本 ,线程之间互不干扰。典型用途是保存请求上下文(登录用户、traceId),避免在方法参数里一层层传。

2. ThreadLocal 的原理是什么?数据存在哪?

答:数据存在 Thread 对象里,不是存在 ThreadLocal 里 。每个 Thread 内部有一个 ThreadLocalMap,key 是 ThreadLocal 对象(弱引用),value 是数据。get() 时拿当前线程的 Map,用 ThreadLocal 做 key 去查。

3. ThreadLocal 会导致内存泄漏吗?为什么?

答:会。因为 ThreadLocalMap 的 key 是弱引用,value 是强引用 。ThreadLocal 对象被回收后 key 变成 null,但 value 仍是强引用,且 entry 还挂在 Thread 的 Map 上 ------只要线程不死(线程池的核心线程就不死),value 就一直占内存。解决办法:用完 remove()。

4. 线程池里用 ThreadLocal 会有什么问题?

答:数据串号 。线程池复用线程,上一个任务 set 的值会残留给下一个任务。实测:任务 1 设置"用户张三"没清理,任务 2 读到的是"张三"------生产环境会导致用户 A 的数据泄露给用户 B 。必须 finally 里 remove()。

5. ThreadLocal 和 synchronized 有什么区别?

答:方向完全相反 。ThreadLocal 是隔离 (每个线程有自己的副本,不需要同步);synchronized 是同步(多线程共享同一份,排队访问)。"不想共享用 ThreadLocal,必须共享用 synchronized"。

6. InheritableThreadLocal 有什么用?线程池里能用吗?

答:用于让子线程继承父线程的值 。但它只在创建子线程时复制一次 ,线程池里的线程是复用的,所以不生效 。线程池传上下文要用阿里 TransmittableThreadLocal(TTL)。


七、总结

四个要点:

  1. 是什么------给每个线程独立的变量副本,典型用于请求上下文。
  2. 存在哪 ------数据在 Thread 的 ThreadLocalMap 里 ,key 是 ThreadLocal(弱引用)。
  3. 怎么泄漏------key 弱引用被回收、value 强引用留着,线程池线程不死 → value 一直占内存。
  4. 怎么防 ------static final 声明 + finally 里 remove(),两条规范必须守。

一句口诀:

ThreadLocal 用得好是"上下文神器",用不好是"内存泄漏 + 数据串号"------差的就是一句 remove()。


如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~

关注我,后续分享更多 Java 核心知识点和实战技巧!

相关推荐
紧跟先前的步伐25 分钟前
Go AI Agent 实战 28|Agent 的预算、超时与停止条件
开发语言·人工智能·后端·ai·golang
Hespethorn27 分钟前
112 个 Valgrind 错误,1 个根因:C++ 多线程服务的关机顺序复盘
后端·架构
秋天的怀念_David28 分钟前
算法一千零一夜 · 第二十六夜 协议的母语
java·spring boot·算法
灿飞28 分钟前
Kotlin与Java虚拟机八:控制流与 when:卫语句、上下文相关解析与穷尽性
java·kotlin
小兔子35 分钟前
new Date('2024-01-01') 和 new Date('2024/01/01') 差在哪
后端
文丑颜不良啊1 小时前
LeetCode2021~2040题目解析
java·leetcode
bksczm2 小时前
InnoDB 行格式详解:DYNAMIC / COMPACT / COMPRESSED / REDUNDANT
java·开发语言·数据库
春涧草茶2 小时前
慢就是快16-1协程
开发语言·前端·python
Oiiouui2 小时前
linux服务器部署Logto后端与对应前端的实现
linux·服务器·前端·后端