资讯动态

Arthas实战:排查Future实例堆积与类加载器隔离

发布时间:2026/10/6 9:02:29 来源:尧图企业网站定制
排查线上接口超时、线程池队列暴涨这类问题的时候我一般不是急着贴线程栈而是先做一件事用 Arthas 把CompletableFuture、FutureTask这类的实例数量摸清楚。通过类查找加实例统计两个命令的组合能快速判断是不是有 Future 对象异常堆积、哪些任务一直没结束、以及这些对象到底被谁持有。今天这篇就完整拆解这套排查链路顺带把最容易翻车的类加载器隔离问题单独拿出来说透。1. 什么场景需要去数 Future 实例先搞清楚这个动作要解决什么问题先说一个我踩过的典型场景。某个核心接口的 TP99 从 80ms 一路涨到 2s线程池的活跃线程数打满队列里任务越堆越多。业务代码里用了一堆CompletableFuture做并行调用看起来每个请求都会创建好几个 Future 对象理论上请求结束这些对象就应该被回收掉。但实际情况是某个异常分支里 future 一直没完成引用被静态 Map 持有导致对象全部堆积在老年代里GC 也救不回来。这种问题的难点在于jstack只能看到线程栈看不到堆里的对象分布jmap -histo能看数量但是看不到对象的具体状态和引用来源MAT能看引用链但需要 dump 堆线上环境 dump 一次代价不小。而 Arthas 提供的类查找加实例统计能力可以直接在运行中的 JVM 里完成以下几个任务确认某个 Future 实现类是否被加载以及它是被哪个类加载器加载的统计目标类在堆中的实例数量判断是否存在异常堆积筛选特定状态的对象比如未完成的CompletableFuture或者等待中的FutureTask结合heapdump和火焰图进一步追查这些实例的创建来源。这整个组合操作特别适合两类人用一类是负责线上应急排查的运维和 SRE需要在分钟内给出是不是 Future 泄漏的判断另一类是业务系统开发者想快速验证自己对代码的怀疑而不是靠猜。必须要说明的是很多文章会直接把统计实例数量等同于执行一条ognl表达式这在类数量少、类加载器单一的场景下确实能做但一旦涉及 web 容器多应用部署、自定义类加载器或者多个同名类并存单纯靠ognl就会踩坑。所以这篇文章的主线是先用sc把类的基础信息摸干净再用vmtool做实例统计最后用heapdump和火焰图定位来源。每一步都有它的理由。2. 类查找阶段sc 命令的正确用法和类加载器信息读取2.1 先用 sc 确认类是否加载、被谁加载Arthas 的sc命令全称是 search class它的作用是搜索已加载的类是后续所有操作的基础。我在实战中几乎不会上来就执行实例统计因为有一个前置问题如果类根本没被加载统计命令执行了也是空结果反而会误导排查方向。先看最基本的用法# 搜索所有已加载的 Future 相关实现类 sc *Future*这条命令会把所有类名里带 Future 的类列出来输出包括类全名和类加载器。但是对于CompletableFuture这种 JDK 自带的类直接列全名更有效sc java.util.concurrent.CompletableFuture sc java.util.concurrent.FutureTask执行之后会看到类似下面的信息注意其中的class-loader部分java.util.concurrent.CompletableFuture class-loader -sun.misc.Launcher$AppClassLoader5c647e05 -org.springframework.boot.loader.LaunchedURLClassLoader1e9e6d2a如果你看到同一个类被多个类加载器加载说明系统里存在多个隔离的 classloader 空间。这里有一个必须记住的点Arthas 的 sc 命令是站在 Instrumentation 层面扫描所有已加载类的所以它会列出所有类加载器下的同名类。这不代表有问题只代表你后续做统计的时候必须显式指定使用的是哪个类加载器。2.2 查看类详情classLoaderHash 与类加载器隔离的第一道门槛只看类名不够还需要看类详情。sc -d能输出类的完整信息包括注解、接口、超类、类加载器等其中最关键的字段是classLoaderHashsc -d java.util.concurrent.CompletableFuture输出里会有这样几行class-loader -sun.misc.Launcher$AppClassLoader5c647e05 -org.springframework.boot.loader.LaunchedURLClassLoader1e9e6d2a classLoaderHash 5c647e05这个classLoaderHash是 Arthas 分配给每个类加载器的唯一标识后续执行ognl指定类时或者执行vmtool过滤类加载器时都要用到它。如果系统里有两个不同 hash 的类加载器都加载了CompletableFuture你在ognl里直接写java.util.concurrent.CompletableFutureclass这种全局限定名Arthas 通常会匹配到默认类加载器下的那个类很容易统计漏掉另一个类加载器空间里的对象。这种情况不是理论推演。我用 Spring Boot 内置 Tomcat 部署多个应用的时候就遇到过同一个公共库里的 Future 子类被LaunchedURLClassLoader和AppClassLoader各加载了一份。如果不加区分去统计数量差一倍是常有的事。2.3 sc 查找自定义 Future 子类时的过滤技巧线上代码一般不会直接用 JDK 的FutureTask更多是包了一层自定义实现或者是 Guava 的ListenableFuture、Netty 的Future。这时候sc通配符就派上用场了# 查所有带 Future 的类包含自定义的和三方库的 sc *Future* # 精确查自己的业务包装类 sc com.yourcompany.*Future*这里有一个实操经验三方库里的 Future 实现类往往非常多Netty 一个框架就能搜出一堆Future后缀的类。定位自己业务的类建议用包名前缀加通配符不要全量搜否则输出几百行反而干扰判断。还有一个容易忽略的功能sc支持按接口搜实现类。比如我想知道当前 JVM 里有哪些类实现了java.util.concurrent.Future接口sc -i java.util.concurrent.Future这条命令会输出所有实现类的列表对摸清全局情况很有帮助。我一般在排查开始时会同时执行sc -i java.util.concurrent.Future和sc *CompletableFuture*前者看整体后者看细节两条命令能快速建立起对当前 JVM 内 Future 家族类加载状况的全景认知。3. 实例统计实操vmtool 是首选ognl 只能在特定条件下用3.1 为什么不建议用 ognl 直接数实例很多文章提到用ognl表达式来统计对象数量例如ognl -x 3 java.util.concurrent.CompletableFuturegetInstances().length但这个写法有两个致命问题。第一getInstances()这个静态方法在绝大多数 JDK 版本上根本不存在Arthas 文档里说的getInstances实际上是vmtool命令的内部能力不是ognl的 API。网上很多帖子把这个概念混在一起照着实操会直接报错。第二ognl表达式只能从某个根对象向下导航获取对象引用它没有能力遍历整个堆。如果你非要通过ognl找对象必须先拿到一个根对象的引用这个前提在大多数排查场景里并不成立。所以我的建议是统计实例数量优先用vmtool不要碰ognl。vmtool是 Arthas 3.1 之后提供的诊断命令它的作用就是通过 JVM Instrumentation 机制遍历堆中的对象正好弥补了ognl的短板。3.2 vmtool getInstances 参数详解和数量统计vmtool最核心的动作是getInstances作用是从堆里拉取指定类的所有实例。基本用法如下vmtool --action getInstances --className java.util.concurrent.CompletableFuture --limit 10000参数的含义分别如下表所示参数作用必填--action操作类型这里固定为getInstances是--className目标类的全限定名是--limit最多返回的实例数量默认 100建议给大一点否--express对返回结果做表达式过滤或字段提取否--classLoaderClass指定目标类加载器用于类加载器隔离场景否-x指定结果展开的层级深度否执行后 Arthas 会返回一个对象数组并显示Object[][ CompletableFuture[...], CompletableFuture[...], ... ]有了这个数组统计数量最直接的方法是看返回的元素个数。但如果实例数量巨大比如超过 limitvmtool默认最多返回 limit 个对象超过的部分不会显示会导致统计偏小。所以实操时有两种做法第一种把 limit 调大例如执行完--limit 1000000后通过express输出长度vmtool --action getInstances --className java.util.concurrent.CompletableFuture --limit 1000000 --express length这样 Arthas 会直接返回一个数字省去人工数结果。第二种先保守估计场景规模。比如正常请求并发 1000每个请求创建 5 个 Future那么期望数量级在几千到几万。如果统计出来是几十万甚至百万级基本可以确定有异常堆积。3.3 用 express 过滤出未完成的 Future 实例数量统计只能说明有多少对象但不能说明有多少对象是有问题的。排查 Future 堆积时我更关注的是未完成的对象数量。以CompletableFuture为例它的状态由内部的result字段表示当任务未完成时result为 null。用express可以过滤出这些对象vmtool --action getInstances --className java.util.concurrent.CompletableFuture --limit 10000 --express filter(it - it.result null)it在 vmtool 表达式里代表每个实例对象filter是 Java Stream 风格的过滤操作。同理对于FutureTask可以判断它的state字段是否处于NEW状态FutureTask的状态常量中NEW 0代表尚未执行完vmtool --action getInstances --className java.util.concurrent.FutureTask --limit 10000 --express filter(it - it.state 0)这一招在实战中非常管用。我遇到过这样一个案例统计CompletableFuture总实例数有 8 万个看起来不算特别离谱但过滤result null后只剩 120 个。这 120 个就是真正卡住没完成的任务最后顺着这 120 个对象的引用链直接找到了一个异常分支里因为try-catch吞掉异常导致 future 永远不会 complete 的 bug。这里有一点要特别小心如果你统计的是自定义的 Future 子类直接访问内部字段可能因为字段名不同而失败。建议先用vmtool配合-x 2展开看一两个实例的结构确认字段名后再写过滤表达式。3.4 vmtool 和 heapdump 的互补关系vmtool适合快速回答有没有问题但它的输出只到对象层面看不到对象之间的引用关系。换句话说它能告诉你有一百个没完成的 Future但不能告诉你这些 Future 是被线程池持有的、还是被某个缓存 Map 持有的、还是被业务请求对象间接引用的。这时候需要heapdump命令把堆导出再用 MAT 分析heapdump /tmp/app.hprofMAT 里的 OQL 可以替代 vmtool 的一部分工作比如统计实例数SELECT count(*) FROM java.util.concurrent.CompletableFuture c WHERE c.result null但 MAT 还能做到 vmtool 做不到的事情查看对象引用者、计算深堆和浅堆、生成 Dominator Tree。我的常规链路是vmtool先做快速体检确认数量大不大、未完成的多不多如果确实有异常再heapdump做二次精确定位对于特别大的堆可以结合 OQL 和 Dominator Tree 找引用链最终落到代码行。这两者不是竞争关系而是不同精度的两把尺子。线上应急时先用 vmtool 省时间拿到 dump 后用 MAT 做深度分析。需要特别注意的是heapdump对生产环境影响较大会触发一次 full GC一般建议在低峰期或者接受一次短暂停顿的前提下才执行。4. 类加载器隔离统计结果不准的头号原因排查链路完整拆解4.1 为什么两个类加载器会让统计结果差一倍先看一个真实发生的现象。同一个CompletableFuture类在 Spring Boot 应用里会被org.springframework.boot.loader.LaunchedURLClassLoader加载在普通 Java 应用里会被sun.misc.Launcher$AppClassLoader加载。如果你用vmtool不指定类加载器执行统计Arthas 实际上只会遍历默认类加载器下的那个类另一个类加载器空间里的实例就完全进不了统计范围。更麻烦的是同一个公共库里的自定义 Future 子类如果被多个 webapp 的WebappClassLoader各加载了一次那么每个 classloader 空间里各有一份独立的类定义和各量级的实例。这时候用默认方式统计结果只是其中一个 webapp 的数量完全不能代表全局。这正是标题里强调类加载器隔离的原因不识别类加载器实例统计可能既不全也不准甚至统计到的根本不是你以为的那个类。4.2 classLoaderHash 和 classLoaderClass 两种指定方式Arthas 对类加载器的指定提供了两个手段-c参数和--classLoaderClass参数。前者用 classLoaderHash后者用类加载器的类名。# 通过 hash 指定hash 来自 sc -d 的输出 vmtool --action getInstances --className java.util.concurrent.CompletableFuture --classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader --limit 10000 # 通过完整类名指定 vmtool --action getInstances --className com.yourcompany.CustomFuture --classLoaderClass org.apache.catalina.loader.WebappClassLoaderBase --limit 10000这里要理解-c和--classLoaderClass的本质区别。-c的 hash 来自sc -d输出它精确指向某一个具体的类加载器实例--classLoaderClass匹配的是类加载器的类型名可能匹配到该类型的所有实例比如多个 webapp 共用同一个WebappClassLoaderBase类型。在只有一个同名类加载器实例时两者差别不大但多个实例时-c更精确。实操时我的顺序是先执行sc -d拿到所有相关的classLoaderHash再针对每个 hash 分别统计最后把结果对比起来看。如果多个 hash 下都有对象那就要重点排查是不是有多个部署单元在共享同一个业务代码。4.3 剖析标准排查链路从 sc 到 vmtool 的完整命令序列这里把整套操作整理为一个可以直接照着敲的排查链路每一步都有明确的意图。第一步确认类加载情况sc -d java.util.concurrent.CompletableFuture sc -d com.yourcompany.CustomFuture这一步重点看输出里的 classLoaderHash 列表。如果发现多个 hash记住它们。第二步对每个 hash 统计实例总数vmtool --action getInstances --className java.util.concurrent.CompletableFuture -c hash1 --limit 100000 --express length vmtool --action getInstances --className java.util.concurrent.CompletableFuture -c hash2 --limit 100000 --express length第三步对每个 hash 统计未完成数量vmtool --action getInstances --className java.util.concurrent.CompletableFuture -c hash1 --limit 100000 --express filter(it - it.result null).length vmtool --action getInstances --className java.util.concurrent.CompletableFuture -c hash2 --limit 100000 --express filter(it - it.result null).length第四步如果需要看某一个对象的内部状态和引用信息用-x 2展开第一个实例vmtool --action getInstances --className java.util.concurrent.CompletableFuture -c hash1 --limit 1 -x 2第五步确认有堆积后执行 heapdump 并下载 hprof 文件heapdump /tmp/app_$(date %Y%m%d%H%M%S).hprof这套链路我实际跑过很多次。最典型的结果是hash1 对应的类加载器下只有几千个实例而 hash2 下有几十万个未完成的 Future。这种差异本身就说明问题——通常 hash2 对应的业务模块就是泄漏源头。4.4 类加载器隔离对 JDK 自带类的影响程度这里要澄清一个常见的认知误区CompletableFuture和FutureTask是 JDK 自带的类它们是不是也存在类加载器隔离问题答案是存在但影响角度不同。JDK 核心类通常由 Bootstrap ClassLoader 或者 AppClassLoader 加载理论上不会被 webapp 各自加载一份所以在大多数场景下vmtool --className java.util.concurrent.CompletableFuture能覆盖所有实例。但要注意两个例外第一个例外JDK 版本不同导致的内部实现变化。比如 JDK 8 的CompletableFuture内部有专门的CompletableFuture$Signaller、CompletableFuture$UniCompletion等辅助类如果你在统计时只数CompletableFuture本身会漏掉这些辅助对象而它们恰恰占了内存大头。排查内存泄漏时我会同时统计这几个内部类vmtool --action getInstances --className java.util.concurrent.CompletableFuture$UniCompletion --limit 10000 --express length vmtool --action getInstances --className java.util.concurrent.CompletableFuture$WaitNode --limit 10000 --express length第二个例外某些 Java 应用服务器会使用自定义类加载器重新加载 JDK 类比如在 OSGi 环境或者使用了 agent 做类替换的情况下CompletableFuture也可能出现多份。这种场景虽然少见但我遇到过一次排查方式就是走上面那条完整链路逐个 hash 对比很快就能发现端倪。4.5 ognl 类加载器指定与 vmtool 联动的一个实践虽然我说了ognl不适合做实例统计但当 vmtool 拿到的对象较少、你想对某个具体对象调用方法时ognl反而合适。这里需要手动指定类加载器用法是类全名类方法的变体形式ognl -c classLoaderHash #valuejava.util.concurrent.CompletableFutureclass, #value这个命令的重点在于-c参数能够指定类加载器 hash。实际情况下我更多会用 vmtool 拿到对象引用再直接用ognl调对象方法。举例来说想确认一个 CompletableFuture 是否已取消可以在 vmtool 的 express 里完成vmtool --action getInstances --className java.util.concurrent.CompletableFuture --limit 100 --express filter(it - it.isCancelled())不过需要提醒一点vmtool在遍历实例时若表达式里调用了业务方法会执行到业务代码务必确认这些方法没有副作用否则可能引起线上数据变化。5. 下沉到代码层结合火焰图定位 Future 的真实创建源头5.1 实例统计只能回答是什么火焰图回答为什么和在哪光有实例数量排查还不能收尾。因为即使确认某个类加载器下有几十万个未完成的 Future也还没锁定是哪一段代码创建的。这时候的通用手段就是采样火焰图。Arthas 的profiler命令内置了 async-profiler 能力可以抓 CPU 采样也可以分配采样。创建 Future 的场景一般和 CPU 执行有关也可以直接抓分配采样成本略高但定位精准。以 CPU 火焰图为例profiler start # 压测或等待一段时间让目标流量经过 profiler stop --format html --file /tmp/future-cpu.html拿到 HTML 火焰图后搜CompletableFuture或FutureTask相关的栈帧。火焰图里横向宽度代表采样占比创建 Future 的热点会呈现为一个明显的宽栈顶。点进去就能看到完整的调用链从哪个 Service 方法、哪个工具类、哪一行代码 new 出了这些 Future。这里有一个重要的区别火焰图定位的是创建 Future 的代码路径而不是持有未完成 Future 的代码路径。创建路径帮你找源头但如果 Future 是在线程池里被提交了却没完成那么创建路径和完成路径可能不在同一个地方。所以通常还要配合下面的引用链分析。5.2 从对象引用链反查持有者MAT 里最实用的两个操作如果已经 dump 了堆MAT 是反查持有者最顺手的工具。打开 hprof 文件后先执行 OQL 筛出未完成的 FutureSELECT * FROM java.util.concurrent.CompletableFuture c WHERE c.result null然后用 MAT 的Path To GC Roots功能选中任意一个结果点右键选择 Path To GC Roots - with all references。这一步会展示从根对象到该 Future 的整条引用链。常见的结果有两种一种是引用链最终指向某个线程对象的栈帧说明任务卡在线程执行中另一种是引用链指向某个静态容器比如static ConcurrentHashMap那就是典型的对象被外部持有无法回收的泄漏模式。拿到引用链以后配合前面的火焰图通常就能形成完整证据链。我把这个过程总结为一个经验表格每次排查都会对照着看排查工具回答的问题局限vmtool 实例统计有多少实例、是否堆积看不到引用来源heapdump MAT对象被谁持有、引用链是什么需要停顿操作较重profiler 火焰图谁创建的、创建路径在哪看不到当前持有关系sc 类查找类是否加载、被谁加载回答不了数量问题5.3 综合案例一个堆积场景的完整复原过程我完整复原一个印象比较深的排查过程帮助你把前面所有工具串起来。现象是接口偶发超时监控显示某个业务线程池队列在高峰期从 500 涨到 50000。先执行sc -d com.example.AsyncTask$TaskFuture发现类加载器只有 Spring Boot 的 LaunchedURLClassLoader 一个实例hash 为1e9e6d2a排除多部署干扰。接着用 vmtool 统计vmtool --action getInstances --className com.example.AsyncTask$TaskFuture -c 1e9e6d2a --limit 100000 --express length返回 40000。再过滤未完成的vmtool --action getInstances --className com.example.AsyncTask$TaskFuture -c 1e9e6d2a --limit 100000 --express filter(it - it.state 0).length返回 39990。这说明几乎所有 Future 都没完成极度异常。随后执行heapdump用 MAT 打开后对单个 TaskFuture 做 Path To GC Roots发现引用链指向了一个全局缓存CacheManager.CACHE_MAP。顺藤摸瓜找到代码原来是把一个包装了 Future 的异步任务对象放进了缓存缓存没有设置过期策略任务在异常分支里没有把 future 置为完成状态导致对象只进不出。最后用 profiler 抓取创建路径确认创建点集中在某个Async注解的 service 方法上修复方案是给缓存加过期时间并在 finally 里显式完成 future。整个定位过程不到半小时vmtool、heapdump、MAT、profiler 四个工具轮流上阵各回答了一段问题。6. 避坑指南命令踩过的坑和长期有效的排查习惯6.1 vmtool 的 limit 是最大上限而不是精确输出前面提过 limit 的坑这里展开讲透。vmtool --limit的含义是本次遍历最多返回多少个实例默认值是 100。如果你不设置 limit而堆里实际有 5 万个 CompletableFuture你只会看到前 100 个然后误判数量正常。这是新手最容易踩的坑。我的习惯是凡是统计实例数量limit 一律给到 100 万以上并且用express length输出实际遍历到的数量。即便这样当实例数超过 limit 时length返回的也只是上限值而不是真实值所以如果length恰好等于你设置的 limit说明实际数量可能更大需要提高 limit 重试。6.2 统计数量与观察对象状态要分开执行另一个常见误区是试图一次命令解决两个问题既统计数量又输出每个对象的状态。vmtool 的-x参数对展开层级有限制当对象数量大时直接展开会造成巨大输出Arthas 可能会卡住甚至抛出内存溢出。我把这总结为原则需要数量的场景用 express 返回 length不看对象内部需要看内部结构的场景把 limit 调小到个位数再结合-x 2展开。两者严格分开避免一次命令干两件事。6.3 排查前先记录基线避免误判正常最后分享一个长期有用的习惯。线上 Future 数量本身会波动一次统计说有 5 万个并不能直接定性为异常。最好的做法是在系统健康的时段先跑一遍统计命令把各类型 Future 的数量和未完成数量记下来作为基线。等怀疑出问题时再跑同样一套命令对比基线的数量级变化。比如基线时 CompletableFuture 总数 3000未完成 20 个问题时段总量 6 万未完成 5 万那这个异常就很实。反之如果基线本来就是 6 万说明系统设计上就是这么高并发未必是泄漏。这种对比逻辑比盯住某个绝对值可靠得多。个人实际操作中我还习惯把这一串排查命令保存为一个 Arthas 脚本文件需要时候直接source进去执行既保证每次统计口径一致又能在最短时间内给出可对比的数字。这些命令的组合使用熟练以后整个流程五分钟内就能出初步结论比盲目 dump 堆盲目猜代码要高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑