目录

ArthasClassLoader 内存泄露分析与修复方案

  • 日期:2025-12-27
  • 作者:Codex(AI)

背景与现象

在多次 attach / stop / detach Arthas 的场景下,预期 com.taobao.arthas.agent.ArthasClassloader 能在 stop 后被 GC 回收, 从而允许下次 attach 重新加载 Arthas Core(AgentBootstrap.resetArthasClassLoader() 会把静态引用置空)。

实际问题是:stop/detach 后,业务线程的 Thread.threadLocals.table 里遗留了由 ArthasClassLoader 加载的对象, 导致 ArthasClassLoader 一直处于可达状态,无法被 GC 回收,最终表现为多次 attach 内存持续增长。

参考 PR:https://github.com/alibaba/arthas/pull/3077(关联 issue:#1794 #2137 #2978)

根因:业务线程 ThreadLocalMap 持有 ArthasClassLoader 对象

核心点:

  • Advice 回调是在“业务线程”里执行的(被增强的方法在哪个线程运行,就在哪个线程回调)。
  • Arthas 的部分实现使用了 ThreadLocal 缓存对象(ThreadLocal 的 value 强引用)。
  • stop/detach 时无法逐线程执行 ThreadLocal.remove()(JDK ThreadLocal API 只能清理“当前线程”的值),导致遗留 value。
  • 这些 value 的 class 由 ArthasClassLoader 加载,因此形成引用链:
    • Thread (GC Root) → Thread.threadLocals.table → Entry.value → value.getClass() → ArthasClassLoader

在 PR #3077 的排查中,已明确的泄露点包括:

  • ThreadLocalWatch.timestampRef:用于 monitor/watch/trace/tt 等命令计时
  • ExpressFactory.expressRef:用于表达式引擎(OGNL)对象缓存

补充:TimeTunnelAdviceListener.argsRef 也会把 ObjectStack(ArthasClassLoader 加载)放入业务线程 ThreadLocalMap, 同样具备 stop 后 pin 住 ArthasClassLoader 的风险(只是该命令触发条件不同)。

为什么 watch 更容易触发

  • watch 命令在 Advice 回调里一定会进行计时(ThreadLocalWatch.start()/costInMillis())。
  • 因此只要业务线程命中过一次 watch 增强点,就会在该线程的 ThreadLocalMap 里创建 timestampRef 的 value。
  • stop/detach 后 value 遗留,导致 ArthasClassLoader 被业务线程间接持有。

而 vmtool 这类命令并不会走 ThreadLocalWatch 的计时逻辑,因此在一些问题复现中需要依赖“表达式”路径, 才会触发 ExpressFactory.expressRef 相关的 ThreadLocal 缓存。

补充根因:Termd/Netty 的 InternalThreadLocalMap 遗留(watch 必现)

在实际复现中,即使修复了 Arthas 自身的 ThreadLocalWatch/ExpressFactory,仍可能出现 stop 后 ArthasClassLoader 无法回收。

heap dump 的典型现象:

  • dominator 为 java.lang.Object[](由 <system class loader> 加载)
  • 该 Object[] 实际是业务线程的 ThreadLocalMap.table
  • table 中存在一个 value:com.alibaba.arthas.deps.io.netty.util.internal.InternalThreadLocalMap

原因解释:

  • watch 的输出是在业务线程里执行的:WatchAdviceListener → process.appendResult(...) → ProcessOutput.write(...) → TermHandler.apply(...) → term.write(...)
  • Arthas 的 Term 基于 termd,而 termd 底层使用 Netty;Netty 的 FastThreadLocal 会在当前线程创建并缓存 InternalThreadLocalMap
  • 在 Arthas 运行时,core jar 会把 io.netty.* relocate 到 com.alibaba.arthas.deps.io.netty.*,因此 dump 中看到的是 relocate 后的类名
  • 当 InternalThreadLocalMap 实例遗留在“业务线程”的 ThreadLocalMap.table 中时,会强引用 ArthasClassLoader 加载的 Netty 类, 从而 pin 住整个 ArthasClassLoader

PR #3077 的修复思路与风险点

PR #3077 的主要思路:

  • 由于无法跨线程清理 JDK ThreadLocal,于是把 ThreadLocal 改造成一个可 cleanUp() 的结构(ConcurrentHashMap 存储)。
  • 在 AdviceListener.destroy() 时调用 cleanUp(),把所有线程关联的数据一次性清掉。

需要关注的潜在问题:

  • 使用 Thread.getId() 作为 key 会引入“线程 id 复用”的理论风险(规范允许复用)。
  • map 方案会让短生命周期线程的条目在命令结束前无法自动释放(线程结束不等于条目释放),可能在长时间运行命令下放大内存占用。

更稳妥的修复方案(推荐)

目标:不依赖 stop/detach 时跨线程清理,而是从根源避免把“ArthasClassLoader 加载的对象”强引用进业务线程 ThreadLocalMap。

具体策略:

  1. 对“计时/栈”等仅需存放基础数据的 ThreadLocal:把 value 改为 JDK/Bootstrap 类型(例如 long[]、Object[]), 即使 stop 后 value 遗留,也不会 pin 住 ArthasClassLoader。
  2. 对必须缓存 Arthas 对象(如 Express)的 ThreadLocal:把 value 包装为 WeakReference, 打断 ThreadLocalMap.value → Arthas 对象 的强引用链,保证 stop/detach 后旧 ArthasClassLoader 可被 GC。

本仓库已按上述思路落地的改动:

  • core/src/main/java/com/taobao/arthas/core/util/ThreadLocalWatch.java:ThreadLocal<LongStack> → ThreadLocal<long[]>
  • core/src/main/java/com/taobao/arthas/core/command/express/ExpressFactory.java:ThreadLocal<Express> → ThreadLocal<WeakReference<Express>>
  • core/src/main/java/com/taobao/arthas/core/command/monitor200/TimeTunnelAdviceListener.java:ThreadLocal<ObjectStack> → ThreadLocal<Object[]>
  • core/src/main/java/com/taobao/arthas/core/shell/command/internal/TermHandler.java:term.write() 后 InternalThreadLocalMap.remove() 清理 Netty ThreadLocal

建议的验证方式

推荐使用 heap dump / MAT / jcmd 验证 stop/detach 之后是否还存在如下可达链:

  • Thread → threadLocals → Entry.value → ClassLoader(指向旧 ArthasClassLoader)

重点关注两类 value:

  • Arthas 自身的 ThreadLocal<ArthasType>:例如 LongStack/ObjectStack/Express 等
  • termd/netty 相关的 InternalThreadLocalMap(dump 中通常为 relocate 后的 com.alibaba.arthas.deps.io.netty...)

若验证仍失败,可继续全局搜索“业务线程 Advice 回调路径”中是否还有新的 ThreadLocal<ArthasType> 写入点,或是否还有其他依赖在业务线程创建了 ThreadLocal 并遗留。