Bootstrap

强大的在线调试工具 Arthas

优质博文:IT-BLOG-CN

Arthas(阿尔萨斯) 是 Alibaba开源的一款Java在线诊断工具,能够分析,诊断,定位Java应用问题,例如:JVM信息,线程信息,搜索类中的方法,跟踪代码执行,观测方法的入参和返回参数等等。并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法的出入参,异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。简单的话:就是再不重启应用的情况下达到排查问题的目的。

当遇到以下类似问题而束手无策时,Arthas可以帮助你解决:
【1】这个类从哪个 jar 包加载的? 为什么会报各种类相关的 Exception?
【2】无法确定线上环境是否是最新提交的代码,只能把服务器上的class文件下载下来使用反编译工具打开确认?
【3】遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
【4】线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
【5】出问题的方法被执行的路径非常多,无法确定该方法是在哪些具体的地方被调用或执行,这个方法也可能是第三方的 jar包里的。
【6】是否有一个全局视角来查看系统的运行状况?
【7】有什么办法可以监控到 JVM的实时运行状态?

一、本地安装

运行环境要求: Arthas支持JDK 6+,支持Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位和诊断。
执行如下命令:使用curl命令从阿里服务器上下载对应的arthas jar包windowLinux下均可执行。

C:\Users\86156>curl -O https://alibaba.github.io/arthas/arthas-boot.jar
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  133k  100  133k    0     0   1496      0  0:01:31  0:01:31 --:--:--  1707

启动:使用 java -jar 启动 arthas-boot.jar,来安装 arthas,大约 10M。需要注意在启动之前,必须启动至少一个 java程序,否则会自动退出。运行此命令会发现 java进程,输入需要 attach 粘附进程对应的序列号,例如,输入1按回车。则会监听该进程。

d:\DevSoft>java -jar arthas-boot.jar --repo-mirror aliyun --use-http
[INFO] arthas-boot version: 3.3.9
[INFO] Process 14464 already using port 3658
[INFO] Process 14464 already using port 8563
[INFO] Found existing java process, please choose one and input the serial number of the process, eg : 1. Then hit ENTER.
* [1]: 14464
  [2]: 19092 org.jetbrains.jps.cmdline.Launcher
1
[INFO] arthas home: C:\Users\86156\.arthas\lib\3.4.0\arthas
[INFO] The target process already listen port 3658, skip attach.
[INFO] arthas-client connect 127.0.0.1 3658
  ,---.  ,------. ,--------.,--.  ,--.  ,---.   ,---.
 /  O  \ |  .--. ''--.  .--'|  '--'  | /  O  \ '   .-'
|  .-.  ||  '--'.'   |  |   |  .--.  ||  .-.  |`.  `-.
|  | |  ||  |\  \    |  |   |  |  |  ||  | |  |.-'    |
`--' `--'`--' '--'   `--'   `--'  `--'`--' `--'`-----'


wiki      https://arthas.aliyun.com/doc
tutorials https://arthas.aliyun.com/doc/arthas-tutorials.html
version   3.4.0
pid       14464
time      2020-09-08 22:52:50

[arthas@14464]$

如果端口被占用了,也可以通过以下命令换成另一个端口号执行:

java -jar arthas-boot.jar --telnet-port 9922 --http-port -1

目录文件:下载的文件目录,可以从上面返回的信息获取,目录中的文件如下所示:

rm -rf ~/.arthas/
rm -rf ~/logs/arthas

二、IDE插件安装

再 File-> Setting-> Plugins 搜索 Alibaba Cloud Toolkit 安装并重启。插件安装的好处是省去了下载 jar包和配置环境的步骤:

插件会默认帮你下载arthas-boot.jar 并配置相关环境,然后在IDE的终端自动执行 java -jar arthas-boot.jar 命令。

确保你本地的项目启动成功,然后选择你项目pid的前缀,即pid前面的[]的标识,回车即可attach到你本地的 jvm进程。

出现arthas的彩色字体即attach成功,下面就可以使用arthas的命令解决具体的业务场景问题,命令的使用参照下面的说明。

这里arthas有个问题,比如使用watch命令查看代码里的值如果带有中文会出现乱码的问题,如果介意这一点可以先在IDE的终端退出arthas,使用exit,然后将上图第一个红圈里的命令增加-Dfile.encoding=UTF-8 编码,再启动 arthas即可解决中文乱码问题。即: java -Dfile.encoding=UTF-8 -jar D:\Users\yx_mao.arthas\lib\3.2.0\arthas\arthas-boot.jar (后面的路径以你具体的目录为准)

另外如果觉得输入每次命令麻烦可以再下载一个arthas的辅助插件,这个插件可以自动生成对应的命令: Arthas Command

如果要远程诊断测试环境的代码,可以通过添加 host的方式连接,设置ip和用户名密码,但前提是你有测试环境的登录权限,当然插件也支持跳板机的方式,同样也会自动帮你在服务器上下载和安装arthas-boot.jar并执行。

线上环境慎重使用

三、常用命令接触

【dashboard 仪表板】

输入dashboard(仪表板),会展示当前进程的信息,按 ctrl + c / q 可以中断执行。
【1】第一部分时显示 JVM中运行的所有线程:所在线程组,优先级,线程的状态,CPU的占有率,是否是后台进程等;
【2】第二部分显示的 JVM内存的使用情况;
【3】第三部分是操作系统的一些信息和 Java版本号;

【thread】

查看当前 jvm的线程堆栈信息。通过 thread 命令获取 arthas-demo 进程的主类。可以指定线程 ID,例如 thread tid;参数说明:

参数名称参数说明
数字线程id
[n:]指定最忙的前 N个线程并打印堆栈
[b] 找出当前阻塞其他线程的线程
[i <value>]指定 cpu占比统计的采样间隔
[arthas@20584]$ thread -n 2
"main" Id=1 cpuUsage=100% RUNNABLE
    at com.zzx.demo.demo.main(demo.java:11)


"Reference Handler" Id=2 cpuUsage=0% WAITING on java.lang.ref.Reference$Lock@4f2d17e4
    at java.lang.Object.wait(Native Method)
    -  waiting on java.lang.ref.Reference$Lock@4f2d17e4
    at java.lang.Object.wait(Object.java:502)
    at java.lang.ref.Reference.tryHandlePending(Reference.java:191)
    at java.lang.ref.Reference$ReferenceHandler.run(Reference.java:153)

【jad】

通过 jad来反编译 Main Class。例如:jad 包名.类名 或者只反编译某个方法:jad 包名.类名 方法名。或者只查看某个类的源码,不看类加载器等:jad 包名.类名 --source-only

【watch】

监视: 来查看函数的返回值。watch 包名.类名 方法 返回值(OGNL表达式)。作用: 方法执行数据观测,让你能方便的观察到指定方法的调用情况。能观察到的范围为:返回值、抛出异常、入参,通过编写 OGNL表达式进行对应变量的查看。watch 的参数比较多,主要是因为它能在 4个不同的场景观察对象。

参数名称参数说明
class-pattern类名表达式匹配
method-pattern方法名表达式匹配
express观察表达式
condition-express条件表达式
[b]在方法调用之前观察 before
[e]在方法异常时候观察 exception
[s]在方法返回之后观察 success
[f]在方法结束之后(正常返回和异常返回)观察 finish
[E]开启正则表达式匹配,默认为通配符匹配
[x:]指定输出结果的属性遍历深度,默认为1
这里重点要说明的是观察表达式,观察表达式的构成主要由 ognl 表达式组成,所以可以这样写 “{params,returnObj}”,只要是一个合法的 ognl表达式,都能被正常支持。

特别说明:
【1】watch 命令定义了 4个观察事件点,即 -b方法调用前,-e 方法异常后, -s 方法返回后,-f 方法结束后;
【2】4个观察事件点 -b,-e,-s默认关闭,-f 默认打开,当指定观察点被打开后,在相应事件点会对观察表达式进行求值并输出;
【3】这里要注意方法入参和方法出参的区别,有可能在中间被修改导致前后不一致,除了 -b事件点 params代表方法入参外,其余事件都代表方法出参;
【4】当使用 -b时,由于观察事件点是在方法调用前,此时返回值或异常均不存在;

举例一:观察 demo.MathGame类中 primeFactors 方法出参和返回值,结果属性遍历深度为2。列举了执行时间和正则表达式的返回值。

watch命令还可以根据耗时和具体的入参条件筛选过滤,只要符合Ognl语法,可以满足很多监控维度,如: 基于Ognl的一些特殊语法

例如观察参数里的订单号为 300的请求:

watch com.zzx.chinese.flight.business.dtomapper.xorderdetailsearch.XOrderDetailLoungeMapper toResponseType "params[0][0].airportLoungeProduct.{? #this.orderId=='300'}" -x 2

四、基础命令

【1】session:查看当前会话的信息。

# 还原指定类
reset Test
#还原所有 List结尾的类
reset *List
#还原所有类
reset

效果:

class / classloader 相关命令: 方便在运行的过程中来诊断代码,查看错误。

【1】jad把字节码文件反编译成源代码,该功能基于一个第三方的反编译工具CFR实现;

#1、使用 jad 反编译类文件 [demo.MathGame] 输出到 /root/MathGame.java
jad --source-only demo.MathGame > /root/MathGame.java

注: 可以使用reset命令对增强后的class文件还原,或者shutdown服务端关闭时也会重置所有增强过的类。

【2】mc在内存中把源代码编译成字节码文件;

# 2、上面的代码编辑完毕以后,使用 mc 对新的代码进行编译,并指定编译完之后的路径
[arthas@16866]$ mc /root/MathGame.java -d /root
Memory compiler output:
/root/demo/MathGame.class
Affect(row-cnt:1) cost in 377ms

【3】redefine把新生成的字节码文件加载到内存中执行(加载外部的.class文件替换掉当前的类)类似于热加载或热修复的功能, 修改java文件编译后的, 将替换掉jvm已加载的.class类, 基于jdk本身的限制, 修改的class文件里不允许新增加成员变量和方法。

基于这个功能可以模拟一个简单的监控功能,比如在java文件的某个方法里加上调用耗时和请求参数的打印功能,然后使用redefine即可看到该方法的耗时时间和参数值, 并且不用重启服务;

#3、使用 redefine 命令加载新的字节码
redefine /root/demo/MathGame.class  # redefine 类的全路径

注意: 使用时需谨慎,确保修改的代码能正确编译并且逻辑正确,毕竟这个命令的杀伤力太大,验证过之后最好通过 reset 命令还原回来。

【4】sc:Search Class 查看运行中的类信息;
【5】sm:Search Method 查看运行中方法的信息;
【6】dump:将加载类的字节码文件保存到特定目录:logs/arthas/classdump/,不同的类加载器放在不同的目录下。

#把 String 类的字节码保存到 /logs/arthas/classdump
dump java.lang.String
#将 demo包下的所有类的字节码文件保存到 /logs/arthas/classdump/目录下
dump demo.*

【7】classloader:获取类加载器的信息。① classloader 命令将 JVM 中所有的 classloader的信息统计出来,并可以展示继承树,urls等。② 可以让指定的 classloader 去 getResources,打印出所有查找到的 resource 的url。对于 ResourceNotFoundException 异常比较有用。

参数名称参数说明
[I]按类加载实例进行统计
[t]打印所有 ClassLoader的继承树
[a]列出所有 ClassLoader加载的类,请谨慎使用
[c:]ClassLoader 的 hashcode
[c: r:]用 ClassLoader 去查找 resource
[c: load:]用 ClassLoader 去加载指定的类

五、monitor/trace相关

请注意,这些命令,都通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此在线上、预发使用时,请尽量明确需要观测的类、方法以及条件,诊断结束要执行 shutdown 或将增强过的类执行 reset 命令。

【monitor】

monitor命令:监控指定类中方法的执行情况。对匹配 class-pattern / method-pattern 的类,方法的调用进行监控。monitor 命令是一个非实时返回命令,实时返回命令是输入之后立即返回。而非实时返回命令,则是不断的等待目标 Java进行返回信息,直到用户输入 ctrl+c 为止。

参数名称参数说明
class-pattern类名表达式匹配
method-pattern方法名表达式匹配
[E]开启正则表达式匹配,默认为通配符匹配
[c:]统计周期,默认值为 120秒
# 每 5s 监控一次
monitor 包.类 方法名 -c 5

【trace】

对方法内部调用路径进行追踪,并输出方法路径上的每个节点上的耗时。trace 命令能主动搜索 class-pattern / method-pattern 对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路。观察表达式的构成主要由 ognl表达式组成,所以你可以写成 “{params,returnObj}”,只要是一个合法的 ognl表达式,都能被正常支持。很多时候我们只想看到某个方法的 rt大于某个时间之后的 trace结果,现在 Arthas可以按照方法执行的耗时来进行过滤,例如:trace *StringUtils isBlank '#cost>100’表示当执行时间超过 100ms的时候,才会输出结果。 watch/stack/trace 这三个命令都支持 #cost 耗时条件过滤。也可以用正则表匹配路径上的多个类和函数,一定程度上达到多层 trace的效果:trace -E com.test.ClassA|org.test.ClassB method1|method2。

参数名称参数说明
class-pattern类名表达式匹配
method-pattern方法表达式匹配
condition-express条件表达式,使用OGNL表达式
[E]开启正则表达式,默认是通配符匹配
[n:]命令执行次数
#cost方法执行耗时,单位是毫秒

样例一: trace 函数指定类的指定方法;

如果输出的节点过多,也可以在命令后面加上筛选耗时(‘#cost > 100’) 单位毫秒

【stack】

输出当前方法被调用的调用路径。很多时候我们都知道一个方法被执行,但这个方法被执行的路径非常多,或者你根本就不知道这个方法是从哪里被执行了,此时你就需要的是 stack命令。

参数名称参数说明
class-pattern类名表达式匹配
method-pattern方法名表达式匹配
condition-express条件表达式,OGNL
[E]开启正则表达式匹配,默认为通配符匹配
[n:]执行次数限制

样例一: 查看方法的调用路径;

【tt】

time-tunnel 时间隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同时间下调用的信息进行观测。

::: warning
watch 虽然很方便和灵活,但需要提前想清楚观察表达式的拼写,这对排查问题而言要求太高,因为很多时候我们并不清楚问题出自于何方,只能靠蛛丝马迹进行猜测。这个时候要是能记录下当时方法调用的所有入参和返回值、抛出的异常会对整个问题的思考与判断非常有帮助。于是乎,TimeTunnel 命令就诞生了。
:::

tt 的参数说明
-t记录某个方法在一个时间段中的调用。方法的每次执行情况
-l显示所有已经记录的列表
-n 次数只记录多少次
-s 表达式搜索表达式
-i 索引号查看指定索引号的详细调用信息
-p重新调用指定的索引号时间碎片
这里着重说下 -n 参数, 当你执行一个调用量不高的方法时可能你还能有足够的时间用 CTRL+C 中断 tt 命令记录的过程,但如果遇到调用量非常大的方法,瞬间就能将你的 JVM 内存撑爆。

此时你可以通过 -n 参数指定你需要记录的次数,当达到记录次数时 Arthas 会主动中断tt命令的记录过程,避免人工操作无法停止的情况。

$ tt -t -n 15 com.zzx.Xproduct toResponseType

记录15次调用后结束,可以通过 tt -l 命令查看调用记录,也可以查看调用的详细信息 tt -i INDEX

$ tt -i 1002                                                                                                                                                                                                                                     
 INDEX          1002                                                                                                                                                                                                                             
 GMT-CREATE     2018-12-27 12:49:29                                                                                                                                                                                                              
 COST(ms)       173.237186                                                                                                                                                                                                                       
 OBJECT         0x42f6a6f9                                                                                                                                                                                                                       
 CLASS          com.ctrip.ibu.flight.business.dtomapper.xorderdetailsearch.XOrderDetailLoungeMapper                                                                                                                                              
 METHOD         toResponseType                                                                                                                                                                                                                   
 IS-RETURN      true                                                                                                                                                                                                                             
 IS-EXCEPTION   false                                                                                                                                                                                                                            
 PARAMETERS[0]  @ArrayList[                                                                                                                                                                                                                      
                    @XOrderInformationDTO[com.ctrip.ibu.flight.models.dto.xorderdetail.entity.XOrderInformationDTO@4b698c87],                                                                                                                    
                ]                                                                                                                                                                                                                                
 RETURN-OBJ     @ArrayList[                                                                                                                                                                                                                      
                    @XLoungeInfo[XLoungeInfo{segmentNo=1, loungeInfoList=[XLoungeDetail{loungeProductId=73986, loungeName=VIP Lounge, airport=Auckland International Airport, terminal=国内航站楼,
                    openTime=00:00-23:59,              salePrice=null,totalPrice=null, currency=null, loungePassengerInfoList=[LoungePassengerInfo{orderId=3046199910, productorderId=3046200017,
                    passengerProductId=3046200018, passengerName
                =LUCAS/SMITH, qrCode=, canRefund=null, productStatus=U, qrStatus=null, orderStatus=C}], boardingGate=null, supplierName=爱旅行, innerPhotos=[LoungePhoto{
                url=http://images4.c-ctrip.com/target/fd/flight/g4/M0A/23/92/CggYHFYbdSeAHw
                z_AAEOTpBr7pw943.png, description=休息区}, LoungePhoto{url=http://images4.c-ctrip.com/target/fd/flight/g3/M05/1D/18/CggYGVYbdTSAMfSPAAEDkA1N7V4323.png, description=休息区},
                LoungePhoto{url=http://images4.c-ctrip.com/target/fd/fligh
                t/g3/M06/1D/5D/CggYG1YbdUGAf0cCAAD5515lX0Y407.png, description=休息区}], segmentNo=1, sequence=1, expired=false, [email protected], cityCode=AKL, cityName=Auckland},
                XLoungeDetail{loungeProductId=5532, loungeName=VIP
                 Lounge, airport=Changi Airport, terminal=null, openTime=00:00-23:59, seviceFeatureList=[SeviceFeature{iconCode=e695, description=Baby free},
                 innerPhotos=[LoungePhoto{url=test/test4.png, description=测试描述4}, LoungePho
                to{url=test/test5.png, description=测试描述5}, LoungePhoto{url=test/test6.png, description=测试描述6}], segmentNo=1, sequence=2, expired=false, [email protected],
                cityCode=SIN, cityName=Singapore}]}],                     
                ]

重放功能: tt -i 1006 -p 因为tt 命令由于保存了当时调用的所有现场信息,所以我们可以自己主动对一个 INDEX 编号的时间片自主发起一次调用。

当我们改了问题后,比如改了配置,需要在线上测试下是否修复的时候,可能会用到该功能,因为环境和数据的问题本地可能无法验证,但线上环境不可能让用户再调用一次,所以这个参数 -p 就可以再重新发起一次调用,但是是由阿尔萨斯内部发起的线程实现的,所以调用方不一样,而且如果之前的调用数据有从threaLocal里获取的话,这次调用代码里也无法获取,使用时需要注意。

其实最重要的还是要结合实际场景,因为线上真实环境去模拟用户再次发起调用如果牵涉到创单或支付流程的话还是要慎重的,否则可能引起一些非幂等的后果 : (

【profiler 火焰图】

profiler 命令支持生成应用热点的火焰图。本质上是通过不断的采样,然后把收集到的采样结果生成火焰图。命令基本运行结构是profiler 命令 [命令参数]火焰图可以直观的分析代码占用cpu,内存,锁争用的情况,比较直观,但是这个命令只支持Linux和Mac系统,一般可以用于线上环境的诊断。

#启动 profiler
$ profiler start

当使用profiler stop 停止之后,就会给 arthas-output文件中生成一个火焰图,如下:通过 --format html可以指定输出的格式;

说明: 纵轴表示栈帧深度,横轴表示代码占用CPU/内存的比例,点击可以查看详情。

profiler命令作用
profiler start启动 profiler,默认情况下,生产 cpu的火焰图
profiler list显示所有支持的事件
profiler getSamples获取已采集的 sample的数量
profiler status查看 profiler的状态,运行时间
profiler stop停止 profiler,生成火焰图的结果集,指定输出目录和输出格式:svg或html
全部命令:官网手册
注: 该命令也是通过多次采样的方式统计,会对系统性能有一些影响,使用慎重。

六、 jobs 后台异步任务命令

当线上出现偶发的问题,比如需要watch某个条件,而这个条件一天可能才会出现一次时,这种情况可以使用异步任务将命令在后台运行,而且可以保存到指定的文件, 方便查看。

比如:观察请求参数中订单号=300的操作, 并将调用记录保存到watch.log文件里,文件目录默认在当前用户 /Users/logs/arthas-cache/ 下

watch com.ctrip.ibu.flight.business.dtomapper.xorderdetailsearch.XOrderDetailLoungeMapper toResponseType "params[0][0].airportLoungeProduct.{? #this.orderId=='300'}" -x 2 >> watch.log &

或者记录某个耗时方法的调用记录

trace com.ctrip.ibu.flight.business.dtomapper.xorderdetailsearch.XOrderDetailLoungeMapper toResponseType >> trace.log &

具体操作可以查看官方使用手册: 异步调用

注意: 使用异步任务时,请勿同时开启过多的后台异步命令,以免对目标JVM性能造成影响。

七、实现原理

【1】JDK Instrumentation 和 Attach API 机制: sun.instrument.InstrumentationImpl 通过instrument机制的实现可以构建一个独立于应用程序的代理程序Agent,再结合 attach机制来绑定我们的应用程序的pid就可以实现监控和协助运行在JVM上的程序,还可以替换和修改类的定义(主要通过redefine, addTransformer函数),比如实现虚拟机级别支持的AOP实现方式,attach机制可以提供一种 jvm进程间通信的能力,能让一个进程传命令给另外一个进程,并让它执行内部的一些操作,具体实现可以看下面的源码分析一节,instrument 和AttachAPI 是btrace,greys,arthas等监控工具的原理基础,如果要深入了解建议查看相关详细资料并自己做一个监控软件出来。

【2】ASM字节码增强技术: ASM是一个java字节码操纵框架,它能被用来动态生成类或者增强既有类的功能,ASM可以从类文件中读入信息后,能够改变类行为,分析类信息,能够根据用户要求生成新类,
当然除了asm还有javassist字节码工具,虽然在反射性能上不如asm(但肯定优于jdk原生的反射),但提供了基于java语法实现操作字节码api,学习成本上比asm低。

【3】JVMTI: JVMTI(JVM Tool Interface)Java虚拟机所提供的 native 编程接口,上面提到的instrument 底层就是基于此实现的,JVMTI 提供了可用于 debug 和 profiler 的接口,在 Java 5/6 中,虚拟机接口也增加了监听(Monitoring),线程分析(Thread analysis)以及覆盖率分析(Coverage Analysis)等功能。

正是由于 JVMTI 的强大功能,它是实现 Java 调试器,以及其它 Java 运行态测试与分析工具的基础,Instrumentation底层也是基于JVMTI实现的。

另外还有Eclipse,IntellJ Idea 等编译期的 debug功能都是基于JPDA(Java Platform Debugger Architecture)实现的,如下图:

Arthas正是使用Java6的Instrumentation特性,结合asm等第三方字节码操作框架的动态增强功能来实现的(核心功能实现在 com.taobao.arthas.core.advisor.Enhancer enhance() 方法中)

八、源码分析

源码部分目前只列出主要实现, 一些细节来不及看, 感兴趣的可以自己去git上下载下来看 GitHub - alibaba/arthas: Alibaba Java Diagnostic Tool Arthas/Alibaba Java诊断利器Arthas

根据官网入门手册里的 java -jar arthas-boot.jar 可知程序入口在这个jar包下,查看META-INF下的MANIFEST.MF文件可知(SPI机制)

这是java的一种机制,告知jdk jar包执行入口通过.MF,具体可参考 java.util.ServiceLoader 实现,感兴趣的也可以了解下 SPI 机制。

下面是引导程序Bootstrap的入口main方法,只列出主要代码逻辑,可对照源码查看,下面的所有代码分析中加注释"//"说明的都是关键地方

public static void main(String[] args) throws ParserConfigurationException, SAXException, IOException, ClassNotFoundException, NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {

    ...... 省略部分代码AnsiLog.info("Try to attach process " + pid);

    AnsiLog.debug("Start arthas-core.jar args: " + attachArgs);

    ProcessUtils.startArthasCore(pid, attachArgs); //加载arthas-agent.jar和arthas-core.jar, startArthasCore方法主要是利用了tool.jar这个包中的VirtualMachine.attach(pid)来实现

    AnsiLog.info("Attach process {} success.", new Object[]{pid});

    ......         

    Class<?> telnetConsoleClas = classLoader.loadClass("com.taobao.arthas.client.TelnetConsole"); //通过反射机制调用控制台命令行交互

    Method mainMethod = telnetConsoleClas.getMethod("main", String[].class); //TelnetConsole用到了JLine工具, JLine是一个用来处理控制台输入的Java类库,可以轻松实现Java命令行输入

}

通过上面的startArthasCore()方法内部ProcessBuilder类调用 arthas-core.jar 的进程服务, 下面就是arthas-core.jar包和入口执行类, 同样也可以通过查看MANIFEST.MF获得,下面的attachAgent方法正是使用了tool.jar这个包中的VirtualMachine.attach(pid)来实现,同时上面加载了自定义的agent代理,见下面 virtualMachine.loadAgent,这样就建立了连接。(如果感兴趣的话,可以去详细了解下attach底层技术JVMTI, 相关资料一节里也有)在运行前或者运行时,将自定义的 Agent加载并和 VM 进行通信。

Main-Class: com.taobao.arthas.core.Arthas

-------------------------------------------------------------------------- 

private void attachAgent(Configure configure) throws Exception {

    VirtualMachineDescriptor virtualMachineDescriptor = null;

    Iterator var3 = VirtualMachine.list().iterator();

    String targetJavaVersion;

    while(var3.hasNext()) {

        VirtualMachineDescriptor descriptor = (VirtualMachineDescriptor)var3.next();

        targetJavaVersion = descriptor.id();

        if (targetJavaVersion.equals(Integer.toString(configure.getJavaPid()))) {

            virtualMachineDescriptor = descriptor;

        }

    }

    VirtualMachine virtualMachine = null;

    try {

        if (null == virtualMachineDescriptor) {

            virtualMachine = VirtualMachine.attach("" + configure.getJavaPid()); //核心功能正是调用了com.sun.tools.attach.VirtualMachine类, 底层又调用了WindowsAttachProvider类, 这个类又是调用jdk的native方法实现的

        } else {

            virtualMachine = VirtualMachine.attach(virtualMachineDescriptor);

        }

        Properties targetSystemProperties = virtualMachine.getSystemProperties();

        targetJavaVersion = targetSystemProperties.getProperty("java.specification.version");

        String currentJavaVersion = System.getProperty("java.specification.version");

        if (targetJavaVersion != null && currentJavaVersion != null && !targetJavaVersion.equals(currentJavaVersion)) {

            AnsiLog.warn("Current VM java version: {} do not match target VM java version: {}, attach may fail.", new Object[]{currentJavaVersion, targetJavaVersion});

            AnsiLog.warn("Target VM JAVA_HOME is {}, try to set the same JAVA_HOME.", new Object[]{targetSystemProperties.getProperty("java.home")});

        }

        virtualMachine.loadAgent(configure.getArthasAgent(), configure.getArthasCore() + ";" + configure.toString()); //这里通过loadAgent将我们自定义的Agent(arthas-core.jar)加载并和我们应用程序所在的JVM进行通信

    } finally {

        if (null != virtualMachine) {

            virtualMachine.detach();

        }

    }
}

然后是arthas-agent.jar代理包的MANIFEST.MF文件,该jar已经被第一步arthas-boot.jar里的ProcessUtils.startArthasCore方法加载。

Manifest-Version: 1.0

Premain-Class: com.taobao.arthas.agent.AgentBootstrap //jdk5的intrument机制,只能支持jvm启动前指定监控的类

Built-By: hengyunabc

Agent-Class: com.taobao.arthas.agent.AgentBootstrap //jdk6之后对intrument机制改进,可以在jvm启动后实时修改类,arthas的很多功能都是通过这个设置生效的

Can-Redefine-Classes: true //重新定义类, 正如上面介绍的redefine -p 指令一样, 通过这个属性设置告知jvm

Can-Retransform-Classes: true //转换类, watch, trace, monitor等命令都是动态修改类, 和Redefine-Classes的区别是直接在现有加载的class字节码基础上修改, 不需要一个新的class文件替换

Created-By: Apache Maven 3.5.3

Build-Jdk: 1.8.0_181

--------------------------------------------------------------------------

public static void premain(String args, Instrumentation inst) { //同上,main方法执行前,jdk5的intrument机制, 这里你已经拿到了Instrumentation对象实例

    main(args, inst);

}


public static void agentmain(String args, Instrumentation inst) { //main执行后, jdk6的intrument机制, 这里你已经拿到了Instrumentation对象实例

    main(args, inst);

}

private static synchronized void main(String args, final Instrumentation inst) {

    try {

        ps.println("Arthas server agent start...");

        int index = args.indexOf(59);

        String agentJar = args.substring(0, index);

        final String agentArgs = args.substring(index, args.length());

        File agentJarFile = new File(agentJar); //拿到arthas-agent.jar

        if (!agentJarFile.exists()) {

            ps.println("Agent jar file does not exist: " + agentJarFile);

        } else {

            File spyJarFile = new File(agentJarFile.getParentFile(), "arthas-spy.jar"); //拿到arthas-spy.jar, spy里面主要是些钩子类,基于aop有前置方法,后置方法,这样动态增强类,实现相应command功能

            if (!spyJarFile.exists()) {

                ps.println("Spy jar file does not exist: " + spyJarFile);

            } else {

                final ClassLoader agentLoader = getClassLoader(inst, spyJarFile, agentJarFile); //类加载器加载agent和spy, 具体见下面的getClassLoader方法解析

                initSpy(agentLoader); //初始化钩子,这里面主要是通过反射的方式获取AdviceWeaver编织类, 比如前置方法,后置方法, 并配合asm实现类的动态增强

                Thread bindingThread = new Thread() {

                    public void run() {

                        try {

                            AgentBootstrap.bind(inst, agentLoader, agentArgs); //bind方法又通过反射调用了arthas-core.jar的ArthasBootstrap.bind方法, bind方法这里就不列出了, 可以自己看下

                        } catch (Throwable var2) {

                            var2.printStackTrace(AgentBootstrap.ps);

                        }

                    }

                };

                bindingThread.setName("arthas-binding-thread");

                bindingThread.start();

                bindingThread.join();

            }

        }

    } catch (Throwable var10) {

        var10.printStackTrace(ps);

        try {

            if (ps != System.err) {

                ps.close();

            }

        } catch (Throwable var9) {

            ;

        }

        throw new RuntimeException(var10);

    }

}


private static ClassLoader getClassLoader(Instrumentation inst, File spyJarFile, File agentJarFile) throws Throwable {

    inst.appendToBootstrapClassLoaderSearch(new JarFile(spyJarFile)); //这里把spy添加到jdk的启动类加载器里, 就是我们熟知的BootstrapClassLoader加载, 这样做的目的是为了下面的子加载器能共享spy, 我理解可能是很多命令都不是实时返回的,需要异步获取

    return loadOrDefineClassLoader(agentJarFile); //而agent是交给arthas自定义的classLoader加载的, 这样做的目的应该是不对我们的业务代码侵入

}

接下来就看core核心包里的AgentBootstrap.bind方法做了什么。

public void bind(Configure configure) throws Throwable {

    long start = System.currentTimeMillis();

    if (!this.isBindRef.compareAndSet(false, true)) {

        throw new IllegalStateException("already bind");

    } else {

        try {

            ShellServerOptions options = (new ShellServerOptions()).setInstrumentation(this.instrumentation).setPid(this.pid).setSessionTimeout(configure.getSessionTimeout() * 1000L);

            this.shellServer = new ShellServerImpl(options, this); //ShellServer服务初始化, 应该就是我们的命令行窗口服务

            BuiltinCommandPack builtinCommands = new BuiltinCommandPack(); //这一步就是初始化上面讲到各种命令的类, 比如"watch,trace,redefine...", 每个命令对应一个Command类,具体怎么实现可以看下一个源码分析

            List<CommandResolver> resolvers = new ArrayList();

            resolvers.add(builtinCommands);

            if (configure.getTelnetPort() > 0) {//注册telnet通信方式, 这个注册方法使用了一个第三方的termd工具,termd是一个命令行程序开发框架(termd内部又是基于netty实现的通信,可见netty的强大,韩国棒子思密达)

                this.shellServer.registerTermServer(new TelnetTermServer(configure.getIp(), configure.getTelnetPort(), options.getConnectionTimeout()));

            } else {

                logger.info("telnet port is {}, skip bind telnet server.", new Object[]{configure.getTelnetPort()});

            }

            if (configure.getHttpPort() > 0) {

                this.shellServer.registerTermServer(new HttpTermServer(configure.getIp(), configure.getHttpPort(), options.getConnectionTimeout())); //注册websocket通信方式

            } else {

                logger.info("http port is {}, skip bind http server.", new Object[]{configure.getHttpPort()});

            }

            Iterator var7 = resolvers.iterator();

            while(var7.hasNext()) {

                CommandResolver resolver = (CommandResolver)var7.next();

                this.shellServer.registerCommandResolver(resolver); //注册命令解析器

            }

            this.shellServer.listen(new BindHandler(this.isBindRef));

            logger.info("as-server listening on network={};telnet={};http={};timeout={};", configure.getIp(), new Object[]{configure.getTelnetPort(), configure.getHttpPort(), options.getConnectionTimeout()});

            UserStatUtil.arthasStart(); //这里就是启动命令行服务器,开始监听,到这步就可以接收客户端的命令输入了

            logger.info("as-server started in {} ms", new Object[]{System.currentTimeMillis() - start});

        } catch (Throwable var9) {

            logger.error((String)null, "Error during bind to port " + configure.getTelnetPort(), var9);

            if (this.shellServer != null) {

                this.shellServer.close();

            }

            throw var9;

        }

    }
}

剩下的就可以看下常用的命令是怎么实现逻辑了,比如 redefine,watch,jad 等,下面只列举了部分命令,感兴趣的可以看源码,大同小异。

RedefineCommand源码,对应"redefine"命令(每个命令都是继承AnnotatedCommand类,重写他的process方法实现)

public void process(CommandProcess process) {

    if (this.paths != null && !this.paths.isEmpty()) {

        ......省略部分代码

        Instrumentation inst = process.session().getInstrumentation(); //还是通过Instrumentation实现

        File file = new File(path); //path就是我们的redefine -p 后面指定的class文件路径, 然后下面还会校验文件是否存在

        f = new RandomAccessFile(path, "r"); //读取我们修改的class为byte[]字节数组

        ......省略部分代码

        Class[] var25 = inst.getAllLoadedClasses(); //通过Instrumentation获取jvm所有加载的类

            ......省略部分代码

            try {

                inst.redefineClasses((ClassDefinition[])definitions.toArray(new ClassDefinition[0])); //最终还是调用Instrumentation的redefineClasses方法实现的

                process.write("redefine success, size: " + definitions.size() + "\n");

            } catch (Exception var18) {

                process.write("redefine error! " + var18 + "\n");

            }

            process.end();

        }

    }
}

WatchCommand源码,对应"watch"指令(WatchCommand的实现是在EnhancerCommand里,因为这个指令和trace,stack,tt等都有相同的功能,所以放在父类里实现了)

public class Enhancer implements ClassFileTransformer {

    public static synchronized EnhancerAffect enhance(Instrumentation inst, int adviceId, boolean isTracing, boolean skipJDKTrace, Matcher classNameMatcher, Matcher methodNameMatcher) throws UnmodifiableClassException {

        ......省略部分代码

        inst.addTransformer(enhancer, true); //将enhancer实例添加到转换器里,enhancer是ClassFileTransformer的实现类, ClassFileTransformer正是instrument的另一个关键组件,所有的转换实现都是基于ClassFileTransformer实现的

        if (GlobalOptions.isBatchReTransform) {

            ......省略部分代码

                while(var17.hasNext()) {

                    Class clazz = (Class)var17.next();


                    try {

                        inst.retransformClasses(new Class[]{clazz}); //重新转换指定的类,即动态修改原来的class文件,他和redefineClass方法的区别就是不需要源class文件,而是直接在现有的class文件上做修改,见下面的transform()方法

                        logger.info("Success to transform class: " + clazz);

                    } catch (Throwable var15) {

                        ......省略部分代码

                        throw new RuntimeException(var15);

                    }

                }

            }

        } finally {

            inst.removeTransformer(enhancer);

        }

        return affect;
    }

    public byte[] transform(final ClassLoader inClassLoader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

        // 这个方法正是重载了ClassFileTransformer.transform方法, 通过asm字节码工具的ClassReader和ClassWriter实现修改我们的class文件的

        // 代码这里就不展开了(其实我也看不懂... 内部都是些字节码语法,如果是用javassist还勉强能看)

    }
}

最后一个JadCommand命令实现比较简单,主要是通过一个第三方的反编译框架CFR实现的,cfr支持java8的一些新特性,比如lambda表达式的反编译,对新的jdk支持比较好。

private void processExactMatch(CommandProcess process, RowAffect affect, Instrumentation inst, Set<Class<?>> matchedClasses, Set<Class<?>> withInnerClasses) {

    ......省略部分代码

    try {

        ClassDumpTransformer transformer = new ClassDumpTransformer(allClasses);

        Enhancer.enhance(inst, transformer, allClasses);

        ......省略部分代码

        String source = Decompiler.decompile(classFile.getAbsolutePath(), this.methodName); //decompile()方法就是通过CFR实现的反编译

        ......省略部分代码

        process.write("");

        affect.rCnt(classFiles.keySet().size());

    } catch (Throwable var12) {

        logger.error((String)null, "jad: fail to decompile class: " + c.getName(), var12);

    }
}

总结: 通过上面的代码分析我们知道了JDK的这两项功能: VirtualMachine Instrumentation

Arthas的整体逻辑也是在jdk的Instrumentation基础上实现的,所有加载的类会通过Agent加载,addTransformer之后再进行增强,然后将对应的Advice织入进去,对于类的查找,方法的查找,都是通过SearchUtil来进行的,通过Instrument的loadAllClass方法将所有的JVM加载的class按名字进行匹配,再进行后续处理。

这些机制在以后的工作中如果遇到类似的问题也会给我们带来启发,嗯,Instrumentation是个好东西 : )

这个图是我基于目前的理解用visio画了大概流程:

以上只是主要流程和部分核心源码的讲解,鉴于本人能力有限,如果分析的不对还请指正,谢谢。

九、注意事项

【1】Arthas对应用程序没有侵入(但对宿主机jvm有侵入),代码或项目中不需要引入jar包或依赖,因为是通过attach的机制实现的,我们的应用的程序和arthas都是独立的进程,arthas是通过和jvm底层交互来获取运行在其上的应用程序实时数据的,灵活查看运行时的值,这个和hickwall,jprofiler等监控软件的区别(JPofiler也有这样的功能,但是是收费的)动态增加aop代理和监控日志功能,无需重启服务,而且关闭arthas客户端后会还原所有增强过的类,原则上是不会影响现有业务逻辑的。

【2】对应用程序所在的服务器性能的影响,个别命令使用不当的话,可能会撑爆jvm内存或导致应用程序响应变慢,命令的输出太多,接口调用太频繁会记录过多的数据变量到内存里,比如tt指令,建议加 -n 参数 限制输出次数,sc * 通配符的使用不当,范围过大,使用异步任务时,请勿同时开启过多的后台异步命令,以免对目标JVM性能造成影响,一把双刃剑(它甚至可以修改jdk里的原生类),所以在线上运行肯定是需要权限和流程控制的。

十、需要完善的点

【1】只有应用在线上业务的诊断上,才能体现它的价值,但是真正将这种类似的技术落地还是有很多事情要做的,阿里也只是开源了他的源码,并没有开源他的具体实践过程和心得,因为这个东西不可能让所有人都在线上搞的,肯定有一套严格的审核权限机制,以及配合这个工具使用的相关配套设施,比如一些命令的使用参数限制等等,而且测试和生产环境调试,需要OPS和架构组的支持,在可行性上还有很多事情要做,所以这里先给大家分享出来这个技术,扩展下知识面。

【2】二次封装,优化(增加默认指令输出的深度限制,json格式优化,前端交互界面,不需要手动输入类名方法名和ognl表达式等)。

;