# 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。
具体策略:
- 对“计时/栈”等仅需存放基础数据的 ThreadLocal:把 value 改为 JDK/Bootstrap 类型(例如
long[]、Object[]), 即使 stop 后 value 遗留,也不会 pin 住 ArthasClassLoader。 - 对必须缓存 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 并遗留。