bigywater.tech
Java · 234

ThreadLocal 与类加载器泄漏:线程池为什么会留住旧应用

热部署、插件卸载之后类加载器回收不掉,常常是 ThreadLocal 在作怪。这篇用一个真实的插件类加载器复现泄漏,逐个验证哪些写法会泄漏、怎么修,并看过期条目与 InheritableThreadLocal 的行为。

false → true
插件类实例留在 ThreadLocal 里:线程活着 / 线程结束后 ClassLoader 是否被回收
42 MB
ThreadLocal 引用置 null 并 GC 之后 40 MB 的值还占着的堆
旧值
池线程复用时 InheritableThreadLocal 看到的是创建线程时的值

泄漏的复现

实测 · 本机执行输出
  插件的 ThreadLocal 里存了插件自己的类的实例(Ctx,1 MB),工作线程还活着,放弃对 ClassLoader 的引用后 GC:ClassLoader 被回收 = false(没有被回收 = 泄漏)
  这时堆占用约 3 MB
  关闭线程池、线程结束之后再 GC:ClassLoader 被回收 = true

哪些写法会泄漏

实测 · 本机执行输出
  存插件类的实例,不清理                        工作线程活着时 GC:ClassLoader 被回收 = false
  存 String(JDK 类型),不清理               工作线程活着时 GC:ClassLoader 被回收 = true
  存插件类实例,卸载前在同一线程 remove()           工作线程活着时 GC:ClassLoader 被回收 = true
  插件加载后没有写过 ThreadLocal              工作线程活着时 GC:ClassLoader 被回收 = true

过期条目

实测 · 本机执行输出
  线程里 ThreadLocal 引用置 null、GC 之后:堆占用 42 MB(40 MB 的值还在:键是弱引用,值是强引用,过期条目没有被清理)
  同一线程再对别的 ThreadLocal 做 3 次 set/get 之后 GC:堆占用 42 MB
  再创建 40 个 ThreadLocal 并 set(触发扩容与清理)之后 GC:堆占用 1 MB
  对照:set 之后 remove():GC 后堆占用 1 MB

InheritableThreadLocal 与线程池

实测 · 本机执行输出
  请求 A 里(主线程设值)第一次提交任务,线程池线程此时才被创建:任务里看到 InheritableThreadLocal = 请求 A 的用户,普通 ThreadLocal = null
  请求 B(主线程把值改成「请求 B 的用户」)再提交任务,复用同一个池线程:任务里看到 请求 A 的用户(继承只发生在线程创建时,之后不会更新)
  虚拟线程(每个任务新建)里 InheritableThreadLocal = 请求 C 的用户(新线程创建时继承了当前值)
  platform 线程 inheritInheritableThreadLocals(false):看到 null

分步演示

ThreadLocal 与类加载器泄漏
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12,-Xmx512m;插件是单独编译的 class 目录,用 URLClassLoader 加载
    • 没有验证:Tomcat 等应用服务器的泄漏检测、静态引用与 JDBC 驱动等其它泄漏路径、堆转储(heap dump)里的定位方法、ThreadLocalMap 清理触发的精确条件(这篇只观察到「40 个 ThreadLocal 之后被清理」)、ScopedValue 作为替代(见相关篇目)
    京ICP备2026067502号-1京公网安备11010502063487号