ThreadLocal 与内存泄漏

有一个问题ThreadLocal:是线程的"私人储物柜"还是"垃圾填埋场"?哈哈 这是一个好问题吧,下面欢迎大家来到内存管理的敏感地带;

在 Spring 事务管理、用户上下文(UserContext)、数据库连接池等场景中,ThreadLocal 是我们的得力助手:它让每个线程拥有了自己的"私人储物柜",互不干扰!但是,如果咱只记得 set() 却忘记了 remove(),那么这个"私人储物柜"就会变成永久性的垃圾填埋场 。在线程池复用的场景下,这直接导致经典的OOM (Out Of Memory),让你的服务器在深夜崩溃、深夜里买醉~深夜里默默哭泣、自己扛

第一章:核心概念

ThreadLocal 提供了线程局部的变量副本。每个访问该变量的线程都有自己独立的初始化副本

想象一家大型健身房(JVM 进程):

  • 线程 (Thread) = 健身会员。
  • ThreadLocal = 储物柜管理员。
  • ThreadLocalMap = 每个会员腰上挂着 的**私人腰包,**装个水什么的。
  • Key = 储物柜的钥匙(ThreadLocal 实例本身)弱引用
  • Value = 你存在柜子里的贵重物品(比如 UserContext 对象)。

第二章:底层源码

存储结构:Thread -> ThreadLocalMap -> Entry

每个线程都持有一个 ThreadLocalMap,这个 Map 的 Key 是 ThreadLocal 对象,Value 是你存的数据;

复制代码
public class Thread {
    // ... 其他字段
    
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    //每个会员(线程)都有一个专属腰包 threadLocals
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    // ...
}
Entry 的秘密:弱引用 vs 强引用

ThreadLocalMap 内部的静态内部类:entry ------安静的美男子

复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k); //  重点!Key 是弱引用 (WeakReference)
        value = v; //  致命!Value 是强引用
    }
}

再上一个形象的图:很形象、你细品

复制代码
[ Thread 对象 ] (强引用)
      |
      | (持有)
      v
[ ThreadLocalMap ]
      |
      | (数组 table)
      v
[ Entry 对象 ] (强引用)
   /       \
  /         \
(WeakRef)   (Strong Ref)
  |           |
  v           v
[ ThreadLocal Key ]  [ Value 对象 (如 UserContext) ]
 (弱引用)          (强引用)
泄漏剧本:当 Key 消失,Value 却还在

你自定义了一个 ThreadLocal<User>,用完之后,你把外部对 ThreadLocal 实例的引用置为 null(或者它是一个局部变量,方法执行完就没了)

1、Key 的结局 :因为 Entry 继承自 WeakReference,如果外部没有其他地方引用这个 ThreadLocal 对象,GC (垃圾回收器) 在下一次扫描时,会回收掉 Key

  • 此时,Entry 里的 key 变成了 null
  • 如果 Key 是强引用,那么只要 Thread 活着,ThreadLocal 对象就永远无法被回收(即使外部已经不用它了)。这会导致 ThreadLocal 实例本身的内存泄漏。
  • 设计成弱引用,至少保证了当外部不再引用 ThreadLocal 时,Key 能被回收,从而有机会(在下次 set/get 时)触发 Value 的清理。虽然 Value 仍可能泄漏,但这比 Key 和 Value 都泄漏要好一点。

2、Value 的悲剧

  • Thread 对象还活着(特别是在线程池中,线程是复用的,长期存活)。
  • Thread 强引用着 ThreadLocalMap
  • ThreadLocalMap 强引用着 Entry 数组。
  • Entry 数组强引用着 Entry 对象。
  • Entry 对象强引用Value
  • 结论 :即使 Key 死了(变 null 了),Value 依然有一条完整的强引用链连接到根节点 (GC Roots)
  • Value 是强引用。只要 Thread 活着,Entry -> Value 的链就断不了。只有 Key 变 null 后,配合后续的 set/get 操作才能清理 Value。如果线程一直复用且不再操作该 TL,Value 就死锁在内存里!
灾难现场(线程池场景)
  • 假如线程池有 100 个线程,长期运行。
  • 每个请求都用 ThreadLocal 存了一个 1MB 的用户对象。
  • 请求结束,业务代码没调 remove()
  • 线程归还给线程池,线程没死,只是去睡大觉了。
  • 那个 1MB 的对象依然死死地抱在线程的腰包里。
  • 再来 10000 个请求...一直没有清理+线程复用
  • 结局 :100 个线程 * 10000 次复用 * 1MB = 1GB 内存泄漏 !直到 java.lang.OutOfMemoryError: Java heap space
补充:引用类型
1、强引用 (Strong Reference) ------ "铁链锁魂"

只要你用 = 赋值了一个对象,它就是强引用

复制代码
Object obj = new Object(); // 这就是强引用
List<String> list = new ArrayList<>();
  • GC 行为只要强引用存在,GC 绝对不会回收该对象,哪怕内存已经溢出(OOM)。
  • 后果 :如果内存不足,JVM 宁愿抛出 OutOfMemoryError 让程序崩溃,也不会断开强引用来回收内存
  • 你把大象(对象)用粗铁链拴在柱子(变量)上。结局:只要铁链不断(变量不置为 null 或超出作用域),大象就永远走不了。哪怕动物园(堆内存)着火了,消防员(GC)也不敢砍断铁链,只能看着动物园烧毁(OOM)
2. 软引用 (Soft Reference) ------ "弹性绳索"
  • 描述一些还有用但并非必须的对象,只有在内存不足时,GC 才会回收它们

  • 需要使用 java.lang.ref.SoftReference

    // 创建一个软引用
    Object strongObj = new Object();
    SoftReference<Object> softRef = new SoftReference<>(strongObj);

    // 获取对象
    Object obj = softRef.get();

  • 内存充足时:不回收,像强引用一样活着。

  • 内存不足时 (即将 OOM 前):GC 会把这些对象回收掉,释放内存,避免程序崩溃;所以第二次调用 get() 可能返回 null。

  • 你把大象用一根有弹性的绳子拴着,平时大象好好的。但如果动物园快挤爆了(内存不足),管理员(GC)会剪断橡皮筋,把大象放走(回收),给新动物腾地方。

使用场景:比想象中要多

  • 缓存系统 :比如图片缓存、网页缓存。
    • 内存够用时,缓存留着,下次访问快。
    • 内存不够时,自动清空缓存,保命要紧。
  • 实现方式:很多第三方缓存库(如早期的 EhCache)利用软引用实现自动淘汰。
3. 弱引用 (Weak Reference) ------ "蛛丝一吹即断"
  • 描述非必需的对象,强度比软引用更弱。无论内存是否充足 ,只要发生 GC,扫描到弱引用对象,立刻回收

  • 需要使用 java.lang.ref.WeakReference

    // 创建一个弱引用
    Object strongObj = new Object();
    WeakReference<Object> weakRef = new WeakReference<>(strongObj);//熟悉吧

    // 手动断开强引用
    strongObj = null;

    // 触发 GC (System.gc() 只是建议,但在演示中通常有效)
    System.gc();

    // 获取对象 -> 大概率是 null
    Object obj = weakRef.get(); // 返回 null

  • GC 行为无视内存状况。一旦 GC 启动,发现只有弱引用连着对象,马上回收。

  • 后果:对象的生命周期非常短,随时可能消失。

  • 你用一根脆弱的蜘蛛网拴着大象,😂只要清洁工(GC)拿着扫帚过来扫一扫(不管动物园挤不挤),蜘蛛网就断了,大象跑了

场景:

首先就是咱们的threadLocal的key

  • ThreadLocal 的 Key
    • 最经典用法ThreadLocalMapEntry 继承自 WeakReference<ThreadLocal>
    • 目的 :防止 ThreadLocal 实例本身泄漏,当外部代码不再使用某个 ThreadLocal 对象时(强引用消失),GC 能立刻回收 Key,将其变为 null
    • 局限 :虽然 Key 没了,但 Value 还是强引用,所以还需要配合 remove() 才能彻底清理 Value。
  • 监听器/回调注册:防止因忘记移除监听器导致的内存泄漏。
  • 规范映射 (Canonicalizing Mappings) :如 WeakHashMap
4. 虚引用 (Phantom Reference) ------ "幽灵通知"
  • 最弱的引用。无法通过虚引用获取对象实例 。它的唯一作用是跟踪对象被垃圾回收的状态

  • 必须和 ReferenceQueue 联合使用。

    ReferenceQueue<Object> queue = new ReferenceQueue<>();
    PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);

    // phantomRef.get() 永远返回 null!

  • GC 行为:对象被回收时,JVM 会将这个虚引用放入关联的队列中。

  • 用途 :收到通知,知道"某对象刚才死了",以便执行一些堆外内存清理等收尾工作。

  • 你并没有拴住大象,你只是在大象脖子上挂了一个报警器,大象死了(被回收了 阿门),报警器响了(引用入队),告诉你"大象已死,快去处理后事(清理堆外资源)"

场景:

直接内存(堆外内存)管理 :如 java.nio.DirectByteBuffer

  • Java 堆内的对象被回收了,但堆外分配的内存(操作系统内存)不会自动释放。
  • 通过虚引用监控堆内对象死亡,一旦收到通知,立即去释放对应的堆外内存,防止堆外 OOM

第三章:JDK 的"补救措施"与局限性

ThreadLocalMapset(), get(), remove() 方法中,加入了一些启发式清理 (Heuristic Cleaning) 逻辑

1、探测式清理 (Expunge Stale Entries)

当调用 set()get() 时,JDK 会遍历 table 数组,检查是否有 key == null 的 Entry

  • 如果发现 key == null

    1. 将该 Entry 的 value 置为 null(帮助 GC 回收 Value)。
    2. 将该 Entry 本身置为 null(槽位释放)。
    3. 重新哈希 (Rehash) 后面的元素,解决冲突。

    // 伪代码逻辑示意
    private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    复制代码
      // 先清理当前槽位
      tab[staleSlot].value = null; 
      tab[staleSlot] = null;
      
      // 向后探测,清理所有 key 为 null 的 Entry
      for (int i = nextIndex(staleSlot, len); ; i = nextIndex(i, len)) {
          Entry e = tab[i];
          if (e != null) {
              if (e.get() == null) { // Key 是弱引用,get() 返回 null 说明被 GC 了
                  e.value = null;
                  tab[i] = null;
              }
          } else {
              break;
          }
      }
      // ...

    }

2. 为什么这还不够?
  • 被动触发 :清理只在 set/get 时发生。如果线程存入数据后,再也没有调用过该 ThreadLocalget/set,而是直接结束了业务逻辑,清理永远不会发生
  • 哈希冲突:如果哈希冲突严重,清理效率会下降。
  • 线程池复用 :在线程池中,线程长期存活。如果业务代码不规范,依赖 JDK 的被动清理是非常危险的。必须主动清理
如何监控 ThreadLocal 泄漏
  1. 代码审查(Code Review):检查所有 set 是否有对应的 remove
  2. 压测 + Heap Dump:在高并发压测后,dump 内存,分析 Thread 对象的 threadLocals 字段,看是否有大量堆积的 Entry 或大对象。
  3. 使用 Arthas 等工具监控线上内存

第四章:最佳实践

错误示范(内存泄漏元凶)

咱们整一个一般权限业务常用的一种方式:

复制代码
// 典型的 Spring Interceptor 或 Filter 错误写法
public class UserInterceptor implements HandlerInterceptor {
    private static final ThreadLocal<User> userContext = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        User user = parseUser(request);
        userContext.set(user); // 存进去
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        //  忘记 remove()!
        // 线程归还给线程池,user 对象依然挂在线程上,泄漏!
    }
}
正确示范

原则 :谁 set,谁 remove。通常在 finally 块中清理

复制代码
public class SafeUserInterceptor implements HandlerInterceptor {
    private static final ThreadLocal<User> userContext = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        User user = parseUser(request);
        userContext.set(user);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            // 业务逻辑处理...
        } finally {
            //  必须移除!无论是否发生异常
            userContext.remove(); 
            // 这一步做了三件事:
            // 1. 获取当前 Entry
            // 2. 将 value 置为 null
            // 3. 将 entry 置为 null (如果 key 也为 null)
            // 彻底切断强引用链,允许 GC 回收 Value
        }
    }
}
进阶技巧:InitialValues 与 自动清理

如果你使用 withInitial 或者重写 initialValue,也要小心。最好的方式依然是显式的 try-finally;对于某些框架(如 Spring),它们已经在 TransactionSynchronizationManagerRequestAttributes 中帮你做了 remove,但如果是自定义的 ThreadLocal,一定一定要自己动手!

总结:ThreadLocal 的双刃剑

  1. 本质ThreadLocal线程级别的全局变量 ,数据存在线程对象ThreadLocalMap 中。
  2. 泄漏根源
    • Thread (强) -> Map (强) -> Entry (强) -> Value (强)。
    • Key 是弱引用,挂了;但 Value 因为强引用链,死不了
    • 线程池让线程长期存活,加剧了问题。
  3. 铁律try { ... } finally { threadLocal.remove(); }
    • 这行代码是防止 OOM 的护身符。
  4. 用完储物柜,不仅要拿走东西,还要**把柜子清空并归还钥匙;**否则,下一个用这个储物柜的人(复用的线程)会发现里面塞满了上一任留下的垃圾,最终仓库爆炸。

之前同事问了我一个问题:方法A和方法B 都用了同一个线程池。方法A调用方法B 会出现什么问题

相关推荐
Data_Journal2 小时前
如何将网站数据抓取到 Excel:一步步指南
大数据·开发语言·数据库·人工智能·php
wuxinyan1232 小时前
Java面试题42:一文深入了解AI Coding 工具
java·人工智能·面试题·ai coding
米码收割机2 小时前
【AI】OpenClaw问题排查
开发语言·数据库·c++·python
¿i?2 小时前
LinkedList 含iterator写法的理解
java·开发语言
所谓伊人,在水一方3332 小时前
【Python数据科学实战之路】第10章 | 机器学习基础:从理论到实践的完整入门
开发语言·人工智能·python·机器学习·matplotlib
无风听海2 小时前
Python之TypeVar深入解析
开发语言·python·typevar
李白的粉2 小时前
基于springboot的来访管理系统
java·spring boot·毕业设计·课程设计·源代码·来访管理系统
东离与糖宝2 小时前
告别Python!Spring Boot 3集成GPT-5.4,Java后端10分钟接入原生计算机操作
java·人工智能
用户2058620985832 小时前
仿 12306 高并发购票系统:抢票下单逻辑设计
java