ThreadLocal原理以及内存泄漏

1.ThreadLoacal是干什么的?

ThreadLocal是用来实现线程隔离的数据存储。 它能让每个线程独立持有一份变量副本,互不干扰。

ThreadLocal = 每个线程都有自己的"私有小仓库"。

示例:

csharp 复制代码
ThreadLocal<String> local = new ThreadLocal<>();

new Thread(() -> {
    local.set("A线程的数据");
    System.out.println(local.get());
}).start();

new Thread(() -> {
    local.set("B线程的数据");
    System.out.println(local.get());
}).start();

输出:

css 复制代码
A线程的数据
B线程的数据

两条线程各用各的,不会串。

2.ThreadLocal的底层原理

ThreadLocal提供了一种线程内独享的变量机制,使每个线程都能有自己独立的变量副本。每个线程内部维护一个ThreadLocalMap!!!这个ThreadLocalMap用于存储线程独立的变量副本。ThreadLocalMapThreadLocal实例作为key,以线程独立的变量副本作为值。不同的线程通过ThreadLocal获取各自的变量副本,而不会影响其他线程的数据。

设计原理

误区:

不是将ThreadLocal看作一个map,然后每个线程都是key,这样每个线程去调用ThreadLocal.get的时候,将自身作为key去map中找,获取各自的value。这个是错误的!!!这样ThreadLocal就变成了共享变量 了。多个线程竞争ThreadLocal,又得加锁,又回到原点。

需要在每个线程的本地都存一份值,每个线程需要有个变量,来存储这些需要本地化资源的值,并且值有可能有多个,这就需要用到map。就是刚才讲的。

比如:现在有三个ThreadLocal对象,两个线程

ini 复制代码
ThreadLocal<String> threadLocal1 =  new ThreadLocal<>();
ThreadLocal<Integer> threadLocal2 =  new ThreadLocal<>();
ThreadLocal<Integer> threadLocal3 =  new ThreadLocal<>();

那么他们对应的关系就是:

这样一来就满足了本地化资源的需求,每个线程维护自己的变量,互不干扰,实现了变量的线程隔离,同时也满足存储多个本地变量的需求。

底层源码

Thread对象里边会有一个ThreadLocalMap,用来保存本地变量。

源码:

java 复制代码
public class Thread {
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

但是这个map是ThreadLocal的静态内部类,这个变量名称是threadLocals

ThreadLocalMap 是 ThreadLocal 的静态内部类,是为了表达逻辑归属关系,但又不让 Map 对 ThreadLocal 实例形成强引用,从而避免内存泄漏。

ThreadLocalMap的定义:

里边有一个Entity数组,继承了WeakReference弱引用 。但是这个不是说是数组弱引用,而是Entry里边的super(k),这个key才是弱引用。

所以ThreadLocalMap里边有个数组,数组的ket就是ThreadLocal对象, value就是我们要保存的值。

ThreadLocal.get()方法

从这可以看出为什么不同的线程对同一个ThreadLocal对象调用get方法能得到不同的值。

map.getEntry(this)

key是如何从ThreadLocalMap中找到Entry的。

3.内存泄漏

什么是内存泄漏?

内存泄漏是指:程序中不再使用的对象仍然被引用着,导致垃圾回收器(GC)无法释放它们的内存空间。

示例:静态集合导致的内存泄漏

csharp 复制代码
public class MemoryLeakDemo {
    // 静态集合(全局存在,不会被 GC 回收)
    private static List<byte[]> memoryHolder = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            // 每次添加 1MB 数据
            byte[] data = new byte[1024 * 1024];
            memoryHolder.add(data);

            System.out.println("第 " + (i + 1) + " 次添加, 当前集合大小: " + memoryHolder.size());
            Thread.sleep(100); // 稍微等一下,方便观察
        }
    }
}
  1. memoryHolder静态变量 ,属于类 MemoryLeakDemo,会一直存在于 JVM 方法区中。
    即使 main() 结束,它也不会被销毁。
  2. 每次循环创建 1MB 的 byte[] 数组,然后存进这个集合。
    这些数组都被强引用着 (ArrayList 里的引用)。
  3. 垃圾回收器无法清理这些数组 ,因为它们仍被 memoryHolder 引用着。
  4. 运行一会后你会看到:OOM
makefile 复制代码
java.lang.OutOfMemoryError: Java heap space

只要释放引用,GC 就能正常工作: memoryHolder.clear(); // 清空集合

又比如再ThreadLocal发生的内存溢出问题

csharp 复制代码
ThreadLocal<byte[]> local = new ThreadLocal<>();
local.set(new byte[1024 * 1024]); // 1MB 数据
local = null; // ThreadLocal对象没了
// 但 ThreadLocalMap 还在引用 value → 内存泄漏

调用threadLocal.remove();就不会发生这个问题。

ThreadLocal为什么会发生内存泄漏呢?

刚才已经讲明了

key 是弱引用(WeakReference)
value 是强引用(Object)

当我们执行

sql 复制代码
ThreadLocal<User> local = new ThreadLocal<>();
local.set(new User("xxx"));

底层会变成:

存放位置 内容
当前线程的 ThreadLocalMap key = ThreadLocal(弱引用)
value = User(强引用)

如果稍后你把: local = null;

那此时:

  • 这个 ThreadLocal 对象在外部没有强引用
  • 因为它是弱引用 → 下一次 GC 会回收它(key 变成 null
  • 但 value(User 对象)仍然被强引用着(Map 内部)!

这就导致:

ThreadLocalMap 里有个 "key=null、value=仍然存在" 的 Entry,value 永远不会被释放

那你可能会说,把里边的value也变成弱引用不就好了

× 不行, 如果 value 是弱引用,那么只要发生 GC,value 可能被清理掉。然后你下一次 get() 时,就拿不到数据了,线程内部状态会丢失。

举个例子 :

sql 复制代码
ThreadLocal<User> local = new ThreadLocal<>();
local.set(new User("xxx"));

// 这时 value = new User("xxx")
// 如果 value 是 WeakReference,GC 一跑,它就被清了
System.gc();

System.out.println(local.get()); // null

这就破坏了 ThreadLocal 的核心语义:"每个线程拥有独立的一份数据,直到手动清除或线程结束前都能访问"。

那 static ThreadLocal 呢

swift 复制代码
private static final ThreadLocal<User> USER_HOLDER = new ThreadLocal<>();

优点:

  • key 不会被 GC,避免 "key=null" 的 Entry;
  • 每次请求都可以覆盖旧值,不容易泄漏。

但问题是:

问题 原因
value 仍然留在线程中 如果线程池复用线程,而你没调用 remove(),老的 value 仍然在
static 生命周期过长 整个应用期间一直存在,可能造成全局数据滞留
大对象浪费内存 即使不访问,也常驻内存(尤其在线程池中)

所以它确实能缓解 泄漏问题,但不能彻底解决

为什么线程池中更危险?

线程池线程不会销毁!

每个线程都有自己的 ThreadLocalMap

如果你使用了 ThreadLocal,但没有及时 remove()

  • key 被回收(key=null)
  • value 还在
  • 线程长期存在(被线程池复用)
  • 这些"脏 value" 永远留在线程中 → 内存持续上涨 → OOM

所以线程池 + ThreadLocal 是高危组合

ThreadLocalMap 的"清理机制"

ThreadLocalMap 的设计者(Doug Lea)意识到这个风险,

所以在源码中加入了"懒惰清理" 逻辑:

每次调用 get()set()remove() 时,会顺带清理掉 key=null 的 Entry。

csharp 复制代码
private void expungeStaleEntry(int i) {
    table[i].value = null;
    table[i] = null;
}

但注意:

如果你的代码永远不再访问这个 ThreadLocal(比如请求结束后),那清理永远不会触发!

所以这不是自动回收机制,只是懒清理

正确用法

关键点就是 ------ 用完要 remove()

csharp 复制代码
private static final ThreadLocal<User> THREAD_LOCAL = new ThreadLocal<>();

public void handleRequest() {
    try {
        THREAD_LOCAL.set(new User("xxx"));
        // ...业务逻辑
    } finally {
        THREAD_LOCAL.remove(); // ✅ 防止内存泄漏
    }
}

推荐写在 finally 块中,无论发生什么都能执行。

总结

ThreadLocal 泄漏的本质不是 ThreadLocal 自身的问题,而是 ThreadLocalMap 的 value 没有被释放,Thread 又长期存在。

相关推荐
MrSun的博客2 小时前
数据源切换之道
后端
Keepreal4962 小时前
1小时快速上手SpringBoot,熟练掌握CRUD
spring boot·后端
豆浆Whisky2 小时前
Go interface性能调优指南:避免常见陷阱的实用技巧|Go语言进阶(10)
后端·go
IT_陈寒3 小时前
「Redis性能翻倍的5个核心优化策略:从数据结构选择到持久化配置全解析」
前端·人工智能·后端
羚羊角uou3 小时前
【Linux】POSIX信号量、环形队列、基于环形队列实现生产者消费者模型
java·开发语言
风象南3 小时前
SpringBoot安全进阶:利用门限算法加固密钥与敏感配置
后端
数据知道4 小时前
Go语言:用Go操作SQLite详解
开发语言·后端·golang·sqlite·go语言
晨非辰6 小时前
《剑指Offer:单链表操作入门——从“头删”开始破解面试》
c语言·开发语言·数据结构·c++·笔记·算法·面试
代码萌新知9 小时前
设计模式学习(五)装饰者模式、桥接模式、外观模式
java·学习·设计模式·桥接模式·装饰器模式·外观模式