不知道你有没有过这种时刻:线上抛出来的异常堆栈里,类名写得清清楚楚,但你抓破头也搞不明白,这个类到底加载了没有,它是从哪个jar包冒出来的,线上跑的方法是不是你刚改的那一版。我早年排查这类问题,基本靠翻日志、比对发布包、甚至重启验证,效率低得让人抓狂。后来把Arthas里的sc和sm命令用顺手,才发现很多问题根本不用重启、不用加日志,进JVM里敲两行命令就有明确结论。
这里先给没接触过的朋友一个定位。Arthas是一款Java在线诊断工具,核心价值是不重启、不阻断业务,直接附着到运行中的JVM上查状态。sc是Search Class,负责搜索类信息;sm是Search Method,负责搜索方法信息。两兄弟一个管“类”,一个管“方法”,恰好补齐了日常判定链路里最容易被绕远路的两个环节:类到底存不存在、谁加载的,以及方法到底存不存在、长什么样。
这篇文章就聚焦这两个命令,不打算把Arthas全家桶都讲一遍。适合刚接触Arthas、或者用了一段时间只会看dashboard和thread的Java开发,也适合需要在测试环境反复核对发布产物、排查jar冲突的后端同学。看完你至少能独立回答“线上为什么找不到这个类”和“为什么加载的是旧方法”这两类高频问题。
1. 为什么我会把sc和sm当成Arthas里的侦察兵
1.1 定位问题的传统三板斧,到底哪里慢了
遇到类加载异常或者方法行为不符时,大多数人第一反应是看日志。日志确实能给出堆栈,但堆栈只会告诉你“哪个类在哪个类的调用链里出了问题”,它不会告诉你“这个类当前是谁加载的”“加载的是哪个路径下的产物”。这时候你会陷入一个很尴尬的状态:知道了症状,但找不到病灶。
第二招是去翻构建产物。很多团队的做法是上服务器找到应用的lib目录,然后grep一下有没有目标jar、看版本号对不对。这套流程在单机部署、classpath简单的时候是有效的,但一旦项目是微服务形态、依赖层级深、同一个包名被多个组件引用,你翻半天也只能得出一个“可能没问题”的结论,因为磁盘上的classpath顺序和JVM启动后实际的加载路径并不总是完全一致。
第三招更直接,重启服务验证。重启之后问题可能消失,也可能复现,但对线上场景而言,每一次重启都意味着可用性损耗。更麻烦的是,某些类加载问题只在特定触发路径下发生,你重启之后反而复现不出来了。
sc和sm的价值就在于绕开这三板斧。它们是“运行态”视角,直接对着JVM内存里已经加载的Class对象做查询。类有没有真正进入运行态、被哪个类加载器持有、方法签名长什么样,这些原本只能靠推理和猜测的信息,现在一条命令就能看到结果。
1.2 sc和sm适合谁用,能回答哪几类问题
我自己的判断是,任何写Java后端的人都有必要掌握这两个命令。不是说每个人都要去做JVM调优,而是日常开发里有一类问题特别常见:代码在IDE里能跑、测试环境能跑,一到线上就报ClassNotFound或者NoSuchMethodError,又或者功能行为不符合预期。这背后往往不是业务逻辑复杂,而是运行态和代码态没有对齐。
我把sc和sm能解决的问题归纳成四类:
- 类是否存在:给定一个类名,确认JVM里有没有对应的已加载类。
- 类的来源:这个类是从哪个jar包或者哪个目录加载的,类加载器是谁。
- 方法是否存在:某个类里到底有没有目标方法,方法是重载还是被改名了。
- 方法的签名:参数类型、返回类型、修饰符、声明类是否符合预期。
这四类问题,恰好是排查发布异常、依赖冲突、接口适配错误时最先要确认的事实基础。把事实确认清楚了,再去查业务代码或者清理依赖,方向就不会跑偏。
2. 先搞懂sc和sm在查什么,再看参数
2.1 sc:在已加载类空间里做一次定向搜索
sc命令的底层逻辑并不复杂。JVM运行过程中,每个被加载的类都会对应一个Class对象,这些对象被类加载器管理。sc做的就是在这批已加载的类里做一次定向搜索,找出类名符合条件的Class对象,再输出它的元数据。
这里有个关键点值得反复强调:sc只能查到“已经加载”的类。虚拟机有自己的一套类加载触发规则,如果一个类还没被任何代码使用到,也没有被显式初始化,它可能根本不会被加载。所以sc查不到某个类,不一定代表它不存在,只说明当前JVM状态里没有它。反过来,一旦sc查到了,那基本可以断定这个类已经真正进入了运行态,后续去哪找它的加载来源就都有了依据。
我用sc最频繁的形态是这样的:
sc *OrderService*不加-d,先用一个带通配符的类名确认“有没有”。Arthas对类名搜索默认支持通配符,星号可以放在任意位置。也可以用正则模式,加上-e参数启用Java正则解析,适合做批量的包扫描,比如找出某个包下所有实现类。
再看详细信息时,我会切换到:
sc -d com.example.OrderService这个-d会输出类详单,关键信息包括code-source(类来源)、class-loader-hash(类加载器哈希)、super-class(父类)、interfaces(接口)、annotation(注解)等。code-source那一行尤其重要,它能直接回答“这个类是从哪个jar里来的”。多个同名类并存时,每个类会分别输出一行详情,各自带着不同的来源和加载器,一眼就能看出冲突现场。
如果还要看类里声明了哪些字段,用-f配合-d:
sc -d -f com.example.OrderService注意-f依赖-d,单独用-f不生效。另外,-f展示的是当前类亲自声明的字段,从父类继承来的字段不会在这一层输出。真要查父类字段,可以再对父类执行一次sc。
2.2 sm:从Class对象里拿到方法清单
sm和sc的原理同源,区别在于查询粒度。sc瞄的是Class对象整体,sm瞄的是Class对象上挂载的方法元数据。当JVM加载一个类时,类的字段、方法、注解等信息会被整理成对应的元数据,sm通过反射机制把这些数据暴露出来,你就能看到方法名、参数类型、返回类型、修饰符、声明类等内容。
基础用法非常直白:
sm com.example.OrderService这样会列出这个类里的所有方法。如果类里方法很多,可以再带一个方法名做筛选,也支持通配符:
sm com.example.OrderService getById*要看得更细致,用-d:
sm -d com.example.OrderService getById-d模式下能看到修饰符、参数类型、返回类型、可能的异常声明、方法声明类等。这组信息在排查“方法签名不匹配”时特别有用。比如代码里写了一个重载方法,线上却提示NoSuchMethodError,用sm列出当前实际存在的方法签名,对比一下就清楚是不是参数类型对不上。
经验提醒:sm的-d能看到参数类型,但看不到参数名。参数名这种源码层面的信息,反射层面默认是不保留的,真要确认参数名还是要去反编译class文件。这也是为什么我后面会把sm和jad命令搭配使用。
2.3 输出字段怎么读,重点盯哪几个
无论是sc还是sm,第一次看输出的人容易被一长串键值对搞懵。我按“排查价值”排个序,你重点盯这几个字段就好。
sc输出里,优先级最高的是code-source、class-loader-hash和super-class。code-source直接告诉你类来自哪里,class-loader-hash是后续所有需要指定类加载器的命令的通行证,super-class能快速判断一个类有没有被代理或者继承关系是否对。
sm输出里,优先级最高的是declaring-class、parameters和return。declaring-class告诉你这个方法实际声明在哪个类上,这在排查继承场景时非常关键。一个子类可能会继承父类的方法,当你以为调的是子类方法,实际却走了父类实现时,sm会直接暴露真身。
还要注意一点:sc和sm输出的类加载器信息,不只是给你看的,更是给后续操作用的。Arthas里不少命令都支持-c参数指定类加载器哈希,比如watch、ognl、jad。后续查方法调用、调试表达式、反编译,都可能用到sc先探出来的这个哈希值。
3. 实战:三个真实场景下的完整操作
3.1 场景一:报错ClassNotFoundException,如何确认类到底加载没有
某次我遇到一个典型的报错,堆栈提示:
java.lang.NoClassDefFoundError: com/example/payment/processor/V2Processor表面看是类不存在,但“类不存在”和“当前运行态里没有这个类”是两回事。我第一件事是进入Arthas,执行:
sc *V2Processor*这一步不要直接拼全限定名,先模糊搜。原因是有时候代码里import用的类名和实际类名大小写不同,或者package被其他工具改动过,模糊搜索能避免因为拼写细节错失线索。
如果结果为空,大概率说明这个类确实没有被加载。此时再往后看依赖关系才有意义,比如检查是不是某个jar版本没打进去。如果结果存在,那就不是“类不存在”的问题,而是“类在当前调用链所属的类加载器里不可见”。两个结论的后续处理路径完全不同。
确认类存在之后,我再执行:
sc -d com.example.payment.processor.V2Processor重点看code-source和class-loader-hash。code-source能告诉我这个类最终从哪个jar加载,class-loader-hash能告诉我加载它的人是谁。比如同样的类,在应用里存在两份jar,一份是业务系统自己带的,一份是某个中间件间接引入的,最终生效的是哪份,sc会给出明确答案。
这套操作下来,原本需要翻构建产物、对比启动日志才能确认的事,几分钟内就有结论。
3.2 场景二:同名类重复加载,用sc定位jar对抗现场
有段时间测试环境反复出现接口行为异常,有人怀疑是某个通用组件被升级后引入了新版本的依赖,和系统老版本的类发生冲突。这种问题最怕的就是“猜”,因为同一个类的多个版本可能同时躺在classpath里,但JVM运行时未必会全部加载它们。
我做的第一轮排查还是sc:
sc -d com.common.component.CacheManager结果输出了两个类详情块。两个块的类名一模一样,但code-source各不相同,一个指向旧版本的jar路径,一个指向新版本的jar路径,class-loader-hash也不同。这个输出本身就说明:同一个类名,确实被不同来源的jar承载着,而且它在运行时被加载成了不同的Class对象。
看清楚有几个“同名选手”之后,再配合classloader命令梳理加载器关系。用:
classloader -t看类加载器树结构,确认这两个CacheManager到底挂在哪个加载器下。正常情况下,父子加载器之间是委托关系,同一个类名如果同时被父加载器和子加载器加载,会形成两个隔离的类,它们的静态变量也不共享。业务代码一旦跨加载器引用对方,就会出现各种“看着是同一个类,实际上不是同一个类”的诡异现象。
确认之后,处理方式就很清晰了:要么排除旧依赖,要么升级组件版本,保证最终只有一个源。最怕的是不知道有几个源,只知道“报错有问题”,那就只能用排除法一遍遍试错。
这个案例给我的体会是:所谓jar冲突不一定是构建期的问题,很多是运行期被不同类加载器加载造成的隔离。sc的-d输出恰好把这种隔离直接可视化了。
3.3 场景三:方法版本对不上,用sm和jad做二次确认
另一个高频场景是发版之后怀疑代码没生效。比如代码里明明把getById(Long id)改成getById(String id)了,线上却依旧按Long的方式走,或者直接报方法找不到。
这时候sm最直接:
sm com.example.OrderService getById*输出会列出当前类中所有以getById开头的方法签名,包括参数类型。如果线上确实还是getById(Long),而源码已经改成了getById(String),那基本可以断定线上运行的还是旧版本。
但sm只能证明“方法签名是什么”,它不能证明“方法体长什么样”。有些场景下,签名是新的,方法体却是旧的,比如热更没成功、或者构建时混入了旧产物。遇到这种怀疑,我会用jad反编译确认:
jad com.example.OrderServicejad能看到方法内部的实际字节码翻译结果。把反编译出来的代码和当前git分支上的源码对比一眼,新旧立刻见分晓。sm负责给结论说“方法还在”,jad负责给细节说“方法体是什么”,这两者配合起来,基本堵死了“代码态和运行态不一致”这个排查盲区。
顺带说一个经验:sm查不到方法时,不要第一时间认为“方法不存在”。先看看类名是不是被代理包装过,再用sc看类的super-class和interfaces。很多框架会生成子类或代理类,目标方法在父类或者原始接口里有,在代理类上并不直接可见。
4. 把sc/sm的结果喂给其他Arthas命令
4.1 classLoaderHash是连接命令的枢纽
很多人用Arthas查完sc就结束了,回去继续翻代码,这其实浪费了最有价值的信息。sc -d输出的class-loader-hash,不只是为了展示,它还是后续一系列操作的入口。
Arthas里不少命令都支持-c参数,用来指定类加载器。当你面对的是自定义类加载器加载的类时,如果不带-c,命令可能找不到目标类或者报“找不到类加载器”。带上sc给出的哈希,就能把后续操作精准定位到同一个类加载器的同名类上。
所以我现在形成了一种习惯:只要用了sc -d,就一定顺手把class-loader-hash复制出来;后面不管是用watch、ognl还是jad,都先带上它。这个习惯帮我避开了很多“命令没输出”的奇怪问题。
4.2 从sc/sm到ognl、watch、trace的联动顺序
排查线上问题时很少只用一条命令,更多时候是按固定顺序走一遍。我常用的排查顺序是这样的。
先用sc锁定类,用sm锁定方法,这是第一步。确认对象存在之后,如果需要验证这个方法被谁调用、调用参数是什么,再上watch:
watch -c <classLoaderHash> com.example.OrderService getById params这条命令可以拿到方法调用时的入参。watch带上-c,确保命令在正确的类加载器上下文里执行。如果没有这一步,Arthas在一些复杂的类加载环境下会匹配不到你要监控的方法。
如果只是想确认某个静态方法或者某个静态字段的值,用ognl更轻量:
ognl -c <classLoaderHash> '@com.example.OrderService@STATUS'这里@类名@静态字段的写法是ognl的常用格式。要执行静态方法也是类似思路。配合sc拿到的类加载器哈希,ognl能直接触碰运行态里的真实数据,省去在日志里大海捞针。
trace则适合看方法内部耗时分布。先sm确认类里确实有目标方法,再trace追踪方法调用链,Arthas会输出子调用耗时占比,慢在哪一步一下就看出来了。
4.3 更进阶:sc定位后一条龙走jad、mc、redefine
还有一个很多人感兴趣但用得小心的场景:线上临时热更。前提是问题紧急、允许临时处理,并且后续会用正规发布修复,平时不建议把热更当成常规手段。
流程的起点还是sc。先用sc -d拿到类的code-source和class-loader-hash,确认类来源。然后jad反编译这个类:
jad -c <classLoaderHash> com.example.OrderService > /tmp/OrderService.java把反编译结果保存下来,在临时目录里手工修改,再用mc命令编译:
mc -c <classLoaderHash> -d /tmp /tmp/OrderService.javamc会输出编译后的class文件路径。最后用redefine把这个class塞回运行中的JVM:
redefine /tmp/com/example/OrderService.class整条链路里,class-loader-hash几乎贯穿始终。如果你第一步没有用sc确认加载器,后面mc和redefine很容易因为加载器不对而失败。所以我把sc称为整条热更链路的“地基”并不夸张。
需要特别提醒:redefine非常强大,也非常危险。它能改方法体,但不能新增或删除字段、方法,改完的方法影响的是当前运行实例。做之前一定要确认业务影响范围,做好回滚预案,最好先在测试环境完整演练一遍。
5. 常见问题与避坑清单
5.1 我踩过且值得你避开的几个坑
先说一个最常见的坑:类名拼写。sc支持通配符之后,很多人反而懒得确认全限定名,直接用sc *类名*搜到一堆结果,然后对着结果猜。我的建议是模糊搜索用来做第一轮“有没有”,一旦确认存在,立刻用完整类名加-d做第二轮“细查”,别停在模糊结果上。
第二个坑是大小写敏感。Java类名本身区分大小写,sc和sm的匹配也一样。排查线上问题时,异常堆栈里的类名通常是准确的,但手动敲入时容易把首字母或者包名里的某个字母敲错。敲错后命令不报错,只会静默返回空,很容易误导你得出“类不存在”的结论。
第三个坑是sc查不到类不代表类真的不存在。前面说过,sc只认已加载的类。有些类虽然打了jar包,但当前还没有被任何线程触发加载,sc当然是空的。真要确认“是否存在”,可以往前推一步,主动触发一次类加载,比如用ognl调用它的静态方法,然后再查。当然,触发前要考虑副作用。
第四个坑和-f有关。sc -d -f只显示当前类自己声明的字段,不包含父类字段。我早年在这个地方卡过一段时间,明明sc输出里看不到某个字段,代码里却用得很开心,最后发现字段在父类上。
第五个坑比较隐蔽:sm看不到参数名,看不到方法体。很多人以为sm -d已经把方法的所有秘密都打印出来了,其实它只给签名层面的信息。想确认方法体,老老实实用jad。签名对不代表方法体对,这两件事在排查时一定要分开验证。
5.2 常见问题速查表
我把日常高频问题对应的命令组合整理成了一张表,排查时直接照着走:
| 问题现象 | 建议排查命令 | 关注点 |
|---|---|---|
| 报ClassNotFoundException | sc类名;sc -d 全限定类名 | 类是否存在、code-source来源 |
| 怀疑依赖版本冲突 | sc -d 全限定类名 | 同名类是否输出多个详情块 |
| 方法找不到 | sm 类名 方法名* | 方法是否存在、声明类是谁 |
| 方法签名不匹配 | sm -d 类名 方法名 | 参数类型、返回类型 |
| 代码改了没生效 | sm -d 类名 方法名;jad 类名 | 签名和字节码双重确认 |
| 自定义加载器下排查 | sc -c hash 类名;classloader -t | 类加载器的隔离范围 |
| 方法调用参数异常 | watch -c hash 类名 方法名 params | 实际入参值 |
这些命令组合可以串成一条排查流水线:先用sc确认类,再用sm确认方法,然后根据问题方向决定是watch、trace还是jad。大多数“奇怪问题”在走完这条流水线之后都能定位到具体差异。
还有一个小技巧:不要在业务高峰期对全量类执行无参数的sc。不带任何条件直接敲sc,会尝试搜索全部已加载类,输出量巨大,而且会带来额外的遍历开销。我习惯先带着类名或者通配符把范围缩小,再逐步加-d/加-f,让每次操作都只碰必要的元数据。
再补一条关于Arthas交互模式的经验。如果发现命令卡住或者输出异常,先确认Arthas是否还附着在目标进程上,有些长连接会因为网络问题断开,但控制台本身不带明显的报错。重新连接一次往往就能恢复正常。
我个人在实际操作中的体会是,sc和sm真正的价值不在于它们能输出多少字段,而在于它们能把排查起点从“猜”变成“看”。异常堆栈给的是代码世界的线索,sc和sm给的是运行世界的答案。这两者之间的断层,恰恰是很多疑难问题拖了几天都定位不到根因的真正原因。
最后再分享一个习惯。我每次在测试环境发完版本,都会顺手用sc和sm做一次快速冒烟:sc确认新类已经加载,sm确认新方法签名正确。整个过程不到一分钟,但能省掉后续联调时的大量无效沟通。等到出了线上问题再临时抱佛脚去学这些命令,那才真的晚了。