阿里开源项目arthas分析

参考

http://tech.dianwoda.com/2018/12/20/arthasyuan-ma-fen-xi/

https://zhuanlan.zhihu.com/p/53984185

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.
├── CONTRIBUTING.md //贡献者相关信息
├── Dockerfile //Dockerfile
├── Dockerfile-No-Jdk //Dockerfile
├── LICENSE //LICENSE
├── NOTICE //NOTICE
├── README.md //README
├── README_CN.md //README
├── README_EN.md //README
├── TODO.md //将来要做的
├── agent //自定义Agent
├── as-package.sh
├── batch.as
├── bin
├── boot
├── client //Telnet客户端实现
├── common
├── core //Arthas的核心实现
├── demo
├── memorycompiler
├── mvnw
├── mvnw.cmd
├── packaging
├── pom.xml
├── site //一些站点信息,包含图片等
├── spy
├── static
└── testcase

从Main方法入手

大致扫了一眼,首先看到了core这个包,从名字中可以推测出其应该是核心包,所以先看看它的结构,然后进行有目的的分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
.
├── Arthas.java //启动代码
├── GlobalOptions.java //全局配置
├── Option.java //配置
├── advisor
│ ├── Advice.java
│ ├── AdviceListener.java
│ ├── AdviceListenerAdapter.java
│ ├── AdviceWeaver.java
│ ├── ArthasMethod.java
│ ├── AsmCodeLock.java
│ ├── CodeLock.java
│ ├── Enhancer.java
│ ├── InvokeTraceable.java
│ ├── ReflectAdviceListenerAdapter.java
│ └── TracingAsmCodeLock.java
├── command //各种命令
│ ├── BuiltinCommandPack.java
│ ├── Constants.java
│ ├── ScriptSupportCommand.java
│ ├── basic1000
│ │ ├── CatCommand.java
│ │ ├── ClsCommand.java
│ │ ├── HelpCommand.java
│ │ ├── HistoryCommand.java
│ │ ├── KeymapCommand.java
│ │ ├── PwdCommand.java
│ │ ├── ResetCommand.java
│ │ ├── SessionCommand.java
│ │ ├── ShutdownCommand.java
│ │ ├── StopCommand.java
│ │ ├── SystemEnvCommand.java
│ │ ├── SystemPropertyCommand.java
│ │ └── VersionCommand.java
│ ├── express //表达式分析
│ │ ├── ClassLoaderClassResolver.java
│ │ ├── CustomClassResolver.java
│ │ ├── Express.java
│ │ ├── ExpressException.java
│ │ ├── ExpressFactory.java
│ │ └── OgnlExpress.java
│ ├── hidden
│ │ ├── JulyCommand.java
│ │ ├── OptionsCommand.java
│ │ └── ThanksCommand.java
│ ├── klass100
│ │ ├── ClassDumpTransformer.java
│ │ ├── ClassLoaderCommand.java
│ │ ├── DumpClassCommand.java
│ │ ├── GetStaticCommand.java
│ │ ├── JadCommand.java
│ │ ├── MemoryCompilerCommand.java
│ │ ├── OgnlCommand.java
│ │ ├── RedefineCommand.java
│ │ ├── SearchClassCommand.java
│ │ └── SearchMethodCommand.java
│ └── monitor200
│ ├── AbstractTraceAdviceListener.java
│ ├── DashboardCommand.java
│ ├── DashboardInterruptHandler.java
│ ├── EnhancerCommand.java
│ ├── GroovyAdviceListener.java
│ ├── GroovyScriptCommand.java
│ ├── JvmCommand.java
│ ├── MBeanCommand.java
│ ├── MonitorAdviceListener.java
│ ├── MonitorCommand.java
│ ├── PathTraceAdviceListener.java
│ ├── StackAdviceListener.java
│ ├── StackCommand.java
│ ├── ThreadCommand.java
│ ├── TimeFragment.java
│ ├── TimeTunnelAdviceListener.java
│ ├── TimeTunnelCommand.java
│ ├── TimeTunnelTable.java
│ ├── TraceAdviceListener.java
│ ├── TraceCommand.java
│ ├── TraceEntity.java
│ ├── WatchAdviceListener.java
│ └── WatchCommand.java
├── config //配置信息
│ ├── Configure.java
│ └── FeatureCodec.java
├── server //服务器
│ └── ArthasBootstrap.java
├── shell //shell
│ ├── Shell.java
│ ├── ShellServer.java
│ ├── ShellServerOptions.java
│ ├── cli
│ │ ├── CliToken.java
│ │ ├── CliTokens.java
│ │ ├── Completion.java
│ │ ├── CompletionUtils.java
│ │ └── impl
│ │ └── CliTokenImpl.java
│ ├── command
│ │ ├── AnnotatedCommand.java
│ │ ├── Command.java
│ │ ├── CommandBuilder.java
│ │ ├── CommandProcess.java
│ │ ├── CommandRegistry.java
│ │ ├── CommandResolver.java
│ │ ├── impl
│ │ │ ├── AnnotatedCommandImpl.java
│ │ │ └── CommandBuilderImpl.java
│ │ └── internal
│ │ ├── CloseFunction.java
│ │ ├── GrepHandler.java
│ │ ├── PlainTextHandler.java
│ │ ├── RedirectHandler.java
│ │ ├── StatisticsFunction.java
│ │ ├── StdoutHandler.java
│ │ ├── TermHandler.java
│ │ └── WordCountHandler.java
│ ├── future
│ │ └── Future.java
│ ├── handlers
│ │ ├── BindHandler.java
│ │ ├── Handler.java
│ │ ├── NoOpHandler.java
│ │ ├── command
│ │ │ └── CommandInterruptHandler.java
│ │ ├── server
│ │ │ ├── SessionClosedHandler.java
│ │ │ ├── SessionsClosedHandler.java
│ │ │ ├── TermServerListenHandler.java
│ │ │ └── TermServerTermHandler.java
│ │ ├── shell
│ │ │ ├── CloseHandler.java
│ │ │ ├── CommandManagerCompletionHandler.java
│ │ │ ├── FutureHandler.java
│ │ │ ├── InterruptHandler.java
│ │ │ ├── QExitHandler.java
│ │ │ ├── ShellForegroundUpdateHandler.java
│ │ │ ├── ShellLineHandler.java
│ │ │ └── SuspendHandler.java
│ │ └── term
│ │ ├── CloseHandlerWrapper.java
│ │ ├── DefaultTermStdinHandler.java
│ │ ├── EventHandler.java
│ │ ├── RequestHandler.java
│ │ ├── SizeHandlerWrapper.java
│ │ └── StdinHandlerWrapper.java
│ ├── impl
│ │ ├── BuiltinCommandResolver.java
│ │ ├── ShellImpl.java
│ │ └── ShellServerImpl.java
│ ├── session
│ │ ├── Session.java
│ │ └── impl
│ │ └── SessionImpl.java
│ ├── system
│ │ ├── ExecStatus.java
│ │ ├── Job.java
│ │ ├── JobController.java
│ │ ├── Process.java
│ │ └── impl
│ │ ├── CommandCompletion.java
│ │ ├── GlobalJobControllerImpl.java
│ │ ├── InternalCommandManager.java
│ │ ├── JobControllerImpl.java
│ │ ├── JobImpl.java
│ │ └── ProcessImpl.java
│ └── term
│ ├── SignalHandler.java
│ ├── Term.java
│ ├── TermServer.java
│ ├── Tty.java
│ └── impl
│ ├── CompletionAdaptor.java
│ ├── CompletionHandler.java
│ ├── Helper.java
│ ├── HttpTermServer.java
│ ├── TelnetTermServer.java
│ └── TermImpl.java
├── util
│ ├── ApplicationUtils.java
│ ├── ArrayUtils.java
│ ├── ArthasBanner.java
│ ├── ArthasCheckUtils.java
│ ├── ClassLoaderUtils.java
│ ├── ClassUtils.java
│ ├── Constants.java
│ ├── DateUtils.java
│ ├── Decompiler.java
│ ├── FileUtils.java
│ ├── IOUtils.java
│ ├── IPUtils.java
│ ├── LogUtil.java
│ ├── NetUtils.java
│ ├── ObjectUtils.java
│ ├── SearchUtils.java
│ ├── StringUtils.java
│ ├── ThreadLocalRandom.java
│ ├── ThreadLocalWatch.java
│ ├── ThreadUtil.java
│ ├── TokenUtils.java
│ ├── TypeRenderUtils.java
│ ├── UserStatUtil.java
│ ├── affect
│ │ ├── Affect.java
│ │ ├── EnhancerAffect.java
│ │ └── RowAffect.java
│ ├── collection
│ │ ├── GaStack.java
│ │ ├── ThreadUnsafeFixGaStack.java
│ │ └── ThreadUnsafeGaStack.java
│ ├── matcher
│ │ ├── EqualsMatcher.java
│ │ ├── FalseMatcher.java
│ │ ├── GroupMatcher.java
│ │ ├── Matcher.java
│ │ ├── RegexMatcher.java
│ │ ├── TrueMatcher.java
│ │ └── WildcardMatcher.java
│ ├── metrics
│ │ ├── RateCounter.java
│ │ └── SumRateCounter.java
│ ├── reflect
│ │ ├── ArthasReflectUtils.java
│ │ └── FieldUtils.java
│ └── usage
│ └── StyledUsageFormatter.java
└── view
├── Ansi.java
├── ClassInfoView.java
├── KVView.java
├── LadderView.java
├── MethodInfoView.java
├── ObjectView.java
├── TableView.java
├── TreeView.java
└── View.java

Arthas类

这个类有main方法,所以起手从它开始分析,首先是看看这个类的整体构造:

1568798737219

可以看到一共有5个方法(其中一个是main方法),还有两个静态字符串,而这两个静态字符串如下所示:

1
2
private static final String DEFAULT_TELNET_PORT = "3658";
private static final String DEFAULT_HTTP_PORT = "8563";

很显然,这两个静态的字符串是用来指定默认的Telnet端口号和http端口号的。

唯一的一个构造方法

1
2
3
private Arthas(String[] args) throws Exception {
attachAgent(parse(args));
}

可以看到通过parse这个函数对命令行参数进行了处理,然后调用了attachAgent方法,然后就没有然后了。说明奥秘肯定是在attachAgent这个方法上面。但是在深入分析下这个方法之前,先把其它的几个方法简单过一遍。

parse()

这个方法内容比较多,所以先暂时从名字中推测是对用户输入的命令行参数进行解析,然后返回一个Configure对象,之后attachAgent()就可以用这个返回的对象作为参数了。

encodeArg()

1
2
3
4
5
6
7
private static String encodeArg(String arg) {
try {
return URLEncoder.encode(arg, "utf-8");
} catch (UnsupportedEncodingException e) {
return arg;
}
}

非常简单的一个函数,就是用utf-8对传入的字符串进行编码并进行返回。

main()

1
2
3
public static void main(String[] args) {
new Arthas(args);
}

这里稍微对main方法进行了删减,把其中的异常处理删了,无伤大雅。可以发现只是调用了一下Arthas的构造方法,而在构造方法中,又只有attachAgent()这一个方法,说明这个类其实最最主要的其实就是这个叫attachAgent()的方法。

attachAgent()

从我真实使用程序来看,arthas首先会列出一个正在运行java程序的PID列表的,然后让你选择你要attach到哪个进程,那么第一个问题就是:它是如何获取到进程的信息的呢?应该是通过jps这种命令,配合信息摘取来获取。这个目前来说不是很重要,不理解可以先跳过。

列出所有的java相关的列表之后,就是如何attach到其上的问题了,相关的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private void attachAgent(Configure configure) throws Exception {
VirtualMachineDescriptor virtualMachineDescriptor = null;
for (VirtualMachineDescriptor descriptor : VirtualMachine.list()) {
String pid = descriptor.id();
if (pid.equals(Integer.toString(configure.getJavaPid()))) {
virtualMachineDescriptor = descriptor;
}
}
VirtualMachine virtualMachine = null;
try {
if (null == virtualMachineDescriptor) { // 使用 attach(String pid) 这种方式
virtualMachine = VirtualMachine.attach("" + configure.getJavaPid());
} else {
virtualMachine = VirtualMachine.attach(virtualMachineDescriptor);
}

//下面是对java版本进行判断
Properties targetSystemProperties = virtualMachine.getSystemProperties();
String targetJavaVersion = JavaVersionUtils.javaVersionStr(targetSystemProperties);
String currentJavaVersion = JavaVersionUtils.javaVersionStr();
if (targetJavaVersion != null && currentJavaVersion != null) {
if (!targetJavaVersion.equals(currentJavaVersion)) {
AnsiLog.warn("Current VM java version: {} do not match target VM java version: {}, attach may fail.",
currentJavaVersion, targetJavaVersion);
AnsiLog.warn("Target VM JAVA_HOME is {}, arthas-boot JAVA_HOME is {}, try to set the same JAVA_HOME.",
targetSystemProperties.getProperty("java.home"), System.getProperty("java.home"));
}
}

String arthasAgentPath = configure.getArthasAgent();
//convert jar path to unicode string
configure.setArthasAgent(encodeArg(arthasAgentPath));
configure.setArthasCore(encodeArg(configure.getArthasCore()));
virtualMachine.loadAgent(arthasAgentPath,
configure.getArthasCore() + ";" + configure.toString());
} finally {
if (null != virtualMachine) {
virtualMachine.detach();
}
}
}

整体思路是这样的:通过sun公司提供的包中的虚拟机的描述符这个类,然后attach到指定的java类上。完成这个方法之后就成功attach到指定pid的jvm上面了。

程序先获取所有正在运行java的VirtualMachine(包括自己),然后用已知的pid找到对应的VirtualMachine,最后是用VirtualMachine.loadAgent()方法加载arthas.jar。

agent加载

这部分先跳过,重点看arthas是如何执行用户输入的命令的。

处理用户的命令

1
2
3
4
5
6
ShellImpl session = createShell(term);
session.setWelcome(welcomeMessage);
session.closedFuture.setHandler(new SessionClosedHandler(this, session));
session.init();
sessions.put(session.id, session); // Put after init so the close handler on the connection is set
session.readline(); // Now readline

直接通过追踪很容易发现这段代码,然后可以发现是这里完成了欢迎信息的载入,然后可以从这里开始追踪每个功能的实现,最后一句的readLine()明显就是在等待用户的输入。也就是说session会处理用户的输入,然后匹配不同的命令,并且执行。

所以接下来需要到session(即ShellImpl类)里面去找readline这个函数。由于其实是服务器端(即agent,对应的类是ShellServer)在接受用户的命令,执行之后把结果返回给返回给用户客户端,然后用户客户端显示出来就行了。

Shell

接口的结构:

1570761691092

前两个createJob方法用来创建Job,而jobController会返回一个Job的控制器,同时还有一个方法来创建session和关闭这个shell。

ShellImpl

实现了shell接口的一个类。

一个在arthas中真实使用的shell,其具体包含以下成员:

  • shell的id(随机生成的一个uuid)
  • 一个closedFuture(推测是用来返回结果使用的)
  • 一个commandManager,用来管理命令
  • 一个session用来实现会话。
  • 一个terminal
  • 一个目前正在执行的job。

其中最最最最最重要的就是下面的这个函数了

1
2
3
4
public void readline() {
term.readline(Constants.DEFAULT_PROMPT, new ShellLineHandler(this),
new CommandManagerCompletionHandler(commandManager));
}

可以看到是调用了terminal的readLine方法,并且把传入了一些参数,所以其实需要看的就是哪个类实现了term接口,去到那里分析就可以了。

OK,到目前为止,已经知道了其实就是ShellImpl这个类里的这个叫readline函数在真正处理用户的输入,那么如果我们需要追踪不同的命令是如何执行的,这里肯定是入口。

Handler

应该是整个项目中最简单的部分了,一个接口,里面只有一个函数,用来处理事件。

Tty

这个接口的注释是Provide interactions with the Shell TTY,用来和Shell进行通信。所以就是一个terminal。

具体有以下抽象方法:

  • String type(); 返回类型
  • int width(); 返回tty的宽度
  • int height(); 返回tty的高度
  • Tty stdinHandler(Handler<String> handler); 返回一个处理命令的tty
  • Tty write(String data); 向标准输出中输出数据
  • Tty resizehandler(Handler<Void> handler); 重设tty

CommandProcess

这个仍然是接口。

Configure

配置类。主要记录了ip地址,Telnet和http的端口号,java程序的pid,还有核心和agent的代码。一堆可以set和get方法。有一个编码解码器,暂时放一下,然后最后是序列化和反序列化的函数。

ShellServerOptions

看类的名字就知道是ShellServer的选项类。

定义了一些超时的信息、欢迎信息等。

ShellServer具体实现

首先文本的注释中有这么一段话:ShellServer是一系列的Term servers的集合,且由ShellServer来管理这些Term servers。每当Terminal Server收到一个连接的时候,JobController会被创建。

Job

Job是执行在JobController中的,它的生命周期包括run,resume,suspend,interrupt,所以这个接口定义了这些方法。

值得注意的Job是属于session的。

Term

Terminal的一个抽象接口。定义了一些term应该有的功能,具体实现在TermImpl这个类中。

TermImpl

对term的一个具体实现,但是我发现….里面的东西好难懂。。。
先跳过了。

ShellLineHandler

这个是用来处理shell命令的。一共只有两个私有参数,一个是term另一个是shell。

CLiToken

一个接口,代表了命令行界面中的已解析令牌。

1570765174481

  • value 返回这个令牌的值。
  • raw() 返回未经处理的令牌的值,可能包含未转义的字符。
  • isText() 当令牌是文本的时候返回true
  • isBlank() 当令牌的值是空的时候返回true

CliTokenImpl

这个类只有三个私有成员,

1
2
3
final boolean text; //注意这里这个是布尔型的!!
final String raw; //未经处理的
final String value; //经过处理的命令

但是实现的方法比较多:

1570765501464

  • 构造方法有两个,太简单了就跳过,只需要知道必须要有一个text和一个value才可以构造Cli令牌。

  • isText()和isBlank()这两个方法就是靠text那个变量完成的。

  • raw()和value()这两个就是靠成员变量来决定的。

  • 重写了hashcode,比较的是value的hashcode值。

  • tokenize()这个函数比较重要,它通过传入一个string,构造一个包含一系列令牌的list并返回。它会取出字符串中的每一个字符,如果字符是空格或者制表符,就用blankToken,否则就用textToken来处理。

  • textToken(),这个方法用到了LineStatus这个类,这个类是阿里自己开发用来开发终端应用的,详情见这里

    所以猜测就是给定一个字符串,然后根据其中的空格和制表符来生成一个List<CliToken>,比如我有一个命令,是cat file abc,那就会生成一个List,长度是3。

Job

所有的用户命令都会被封装成Job,具体实现见JobImpl类。
然后经过一系列的跟踪可以发现最后在ProcessImpl中有执行命令的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public synchronized void run(boolean fg) {
if (processStatus != ExecStatus.READY) {
throw new IllegalStateException("Cannot run proces in " + processStatus + " state");
}

processStatus = ExecStatus.RUNNING;
processForeground = fg;
foreground = fg;
startTime = new Date();

// Make a local copy
final Tty tty = this.tty;
if (tty == null) {
throw new IllegalStateException("Cannot execute process without a TTY set");
}

final List<String> args2 = new LinkedList<String>();
for (CliToken arg : args) {
if (arg.isText()) {
args2.add(arg.value());
}
}

CommandLine cl = null;
try {
if (commandContext.cli() != null) {
if (commandContext.cli().parse(args2, false).isAskingForHelp()) {
UsageMessageFormatter formatter = new StyledUsageFormatter(Color.green);
formatter.setWidth(tty.width());
StringBuilder usage = new StringBuilder();
commandContext.cli().usage(usage, formatter);
usage.append('\n');
tty.write(usage.toString());
terminate();
return;
}

cl = commandContext.cli().parse(args2);
}
} catch (CLIException e) {
tty.write(e.getMessage() + "\n");
terminate();
return;
}

process = new CommandProcessImpl(args2, tty, cl);
if (cacheLocation() != null) {
process.echoTips("job id : " + this.jobId + "\n");
process.echoTips("cache location : " + cacheLocation() + "\n");
}
Runnable task = new CommandProcessTask(process);
ArthasBootstrap.getInstance().execute(task);
}

通用命令实现

首先arthas有一个接口叫CommandResolver,很明显是用来解析命令的,可以通过跟踪发现是一个叫BuiltinCommandPack的类实现了它。而在这个类里面我也找到了arthas支持的所有的命令(部分):

1570763373377

watch实现

watch能够观察到方法的入参、返回值和异常信息,非常有用,所以来看看这个是怎么实现的。(上面中的通用命令的WatchCommand.class就是它的实现)。

首先是一堆的继承链 AnnotatedCommand - > EnhancerCommand -> WatchCommand

AnnotatedCommand

1570764059798

第一个返回命令的名字,第二个返回命令行接口(默认是Null),第三个是抽象方法用来处理命令,最后一个是命令处理完了之后应该做什么。

EnhancerCommand

这个类友好很多,大部分中文注释。但是还是太多了…

1570775028820

这里最主要的是enhance这个方法,里面最主要的是这一段:

1
EnhancerAffect effect = Enhancer.enhance(inst, lock, listener instanceof InvokeTraceable,skipJDKTrace, getClassNameMatcher(), getMethodNameMatcher());

跟踪到一个叫enhancer的类,接下来就是字节码的增强技术了,没了解过,只能先这样了。

从JDK 1.5起,有一套ClassFileTransformer的机制,Java Agent通过Instrumentation注册ClassFileTransformer,那么在类加载或者retransform时就可以回调修改字节码。