阿里监控诊断工具 Arthas 源码原理分析

来自:Tomcat那些事儿(微信号:tomcat0000),作者:侯树成

上个月,阿里开源了监控与诊断工具 「Arthas」,一款可用于线上问题分析的利器,短期之内收获了大量关注,在 Twitter 上连 Java 官方的 Twitter 也转发了,真的很赞。

GitHub 上是这样自述的:

Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。

我一般看到感兴趣的开源工具,会找几个最感兴趣的功能点切入,从源码了解设计与实现原理。对于一些自己了解的实现思路,再从源码中验证一下是否是采用相同的实现思路。如果实现和自己想的一样,可能你会想,啊哈,想到一块了。如果源码中是另一种实现,你就会想 Cool, 还可以这样玩。仿佛如同在和源码的作者对话一样

这次趁着国庆假期看了一些「Arthas」的源码,大致总结下。

从源码的包结构上,可以看到分为几个大的 模块:

  • Agent       -- VM 加载的自定义 Agent

  • Client       -- Telnet 客户端实现

  • Core        -- Arthas 核心实现,包含连接 VM, 解析各类命令等

  • Site          -- Arthas 的帮助手册站点内容

我主要看了以下几个功能:

  • 连接进程

  • 反编译class,获取源码

  • 查询指定加载的 class

连接进程

连接到指定的进程,是后续监控与诊断的基础。只有先 attach 到进程之上,才能获取 VM 对应的信息,查询 ClassLoader 加载的类等等。

怎样连接到进程呢?
用于类似诊断工具的读者可能都有印象,像 JProfile、 VisualVM 等工具,都会让你选择一个要连接到的进程。然后再在指定的 VM 上进行操作。比如查看对应的内存分区信息,内存垃圾收集信息,执行 BTrace脚本等等。

咱们先来想想,这些可供连接的进程列表,是怎么列出来的呢?
一般可能会是类似 ps aux | grep java 这种,或者是使用 Java 提供的工具 jps -lv 都可以列出包含进程id的内容。我在很早之前的文章里写过一点 jps 的内容(你可能不知道的几个java小工具),其背后实现,是会将本地启动的所有 Java 进程,以 pid 做为文件名存放在Java 的临时目录中。这个列表,遍历这些文件即可得出来。

Arthas 是怎么做的呢?
在启动脚本 as.sh 中,有关于进程列表的代码如下,实现也是通过jps 然后把Jps自己排除掉:

# check pid
    if [ -z ${TARGET_PID} ] && [ ${BATCH_MODE} = false ]; then
        local IFS_backup=$IFS
        IFS=$'\n'
        CANDIDATES=($(${JAVA_HOME}/bin/jps -l | grep -v sun.tools.jps.Jps | awk '{print $0}'))

        if [ ${#CANDIDATES[@]} -eq 0 ]; then
            echo "Error: no available java process to attach."
            # recover IFS
            IFS=$IFS_backup
            return 1
        fi

        echo "Found existing java process, please choose one and hit RETURN."

        index=0
        suggest=1
        # auto select tomcat/pandora-boot process
        for process in "${CANDIDATES[@]}"do
            index=$(($index+1))
            if [ $(echo ${process} | grep -c org.apache.catalina.startup.Bootstrap) -eq 1 ] \
                || [ $(echo ${process} | grep -c com.taobao.pandora.boot.loader.SarLauncher) -eq 1 ]
            then
               suggest=${index}
               break
            fi
        done

选择好进程之后,就是连接到指定进程了。连接部分在attach这里

# attach arthas to target jvm
# $1 : arthas_local_version
attach_jvm()
{
    local arthas_version=$1
    local arthas_lib_dir=${ARTHAS_LIB_DIR}/${arthas_version}/arthas

    echo "Attaching to ${TARGET_PID} using version ${1}..."

    if [ ${TARGET_IP} = ${DEFAULT_TARGET_IP} ]; then
        ${JAVA_HOME}/bin/java \
            ${ARTHAS_OPTS} ${BOOT_CLASSPATH} ${JVM_OPTS} \
            -jar ${arthas_lib_dir}/arthas-core.jar \
                -pid ${TARGET_PID} \
                -target-ip ${TARGET_IP} \
                -telnet-port ${TELNET_PORT} \
                -http-port ${HTTP_PORT} \
                -core "${arthas_lib_dir}/arthas-core.jar" \
                -agent "${arthas_lib_dir}/arthas-agent.jar"
    fi
}

对于 JVM 内部的 attach 实现,
是通过tools.jar这个包中的com.sun.tools.attach.VirtualMachine以及 VirtualMachine.attach(pid) 这种方式来实现的。

底层则是通过JVMTI。之前的文章简单分析过 JVMTI 这种技术(当我们谈Debug时,我们在谈什么(Debug实现原理)),在运行前或者运行时,将自定义的 Agent加载并和 VM 进行通信
上面具体执行的内容在 arthas-core.jar 的主类中,我们来看具体的内容:

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);
            }

            Properties targetSystemProperties = virtualMachine.getSystemProperties();
            String targetJavaVersion = targetSystemProperties.getProperty("java.specification.version");
            String currentJavaVersion = System.getProperty("java.specification.version");
            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 {}, try to set the same JAVA_HOME.",
                                    targetSystemProperties.getProperty("java.home"));
                }
            }

            virtualMachine.loadAgent(configure.getArthasAgent(),
                            configure.getArthasCore() + ";" + configure.toString());
        } finally {
            if (null != virtualMachine) {
                virtualMachine.detach();
            }
        }
    }

通过 VirtualMachine, 可以attach到当前指定的pid上,或者是通过 VirtualMachineDescriptor 实现指定进程的attach,最核心的就是这一句:

virtualMachine.loadAgent(configure.getArthasAgent(),
                            configure.getArthasCore() + ";" + configure.toString());

这样,就和指定进程的 VM建立了连接,此时就可以进行通信啦。

类的反编译实现

我们在问题诊断中,有些时候需要了解当前加载的 class 对应的内容,方便确认加载的类是否正确等,一般通过 javap 只能显示类似摘要的内容,并不直观。 在桌面端我们可以通过 jd-gui之类的工具,在命令行里一般可选的不多。 
Arthas 则集成了这一功能。
大致的步骤如下:

  1. 通过指定class名称的内容,先进行类的查找

  2. 根据选项,判断是否进行Inner Class之类的查找

  3. 进行反编译

我们来看 Arthas 的实现。
对于 VM 中指定名称的 class 的查找,我们看下面这几行代码:

    public void process(CommandProcess process) {
        RowAffect affect = new RowAffect();
        Instrumentation inst = process.session().getInstrumentation();
        Set<Class<?>> matchedClasses = SearchUtils.searchClassOnly(inst, classPattern, isRegEx, code);

        try {
            if (matchedClasses == null || matchedClasses.isEmpty()) {
                processNoMatch(process);
            } else if (matchedClasses.size() > 1) {
                processMatches(process, matchedClasses);
            } else {
                Set<Class<?>> withInnerClasses = SearchUtils.searchClassOnly(inst,  classPattern + "(?!.*\\$\\$Lambda\\$).*"true, code);
                processExactMatch(process, affect, inst, matchedClasses, withInnerClasses);
    }

关键的查找内容,做了封装,在SearchUtils里,这里有一个核心的参数:Instrumentation,都是这个哥们给实现的。

    /**
     * 根据类名匹配,搜已经被JVM加载的类
     *
     * @param inst             inst
     * @param classNameMatcher 类名匹配
     * @return 匹配的类集合
     */

    public static Set<Class<?>> searchClass(Instrumentation inst, Matcher<String> classNameMatcher, int limit) {
        for (Class<?> clazz : inst.getAllLoadedClasses()) {
            if (classNameMatcher.matching(clazz.getName())) {
                matches.add(clazz);
            }
        }
        return matches;
    }

inst.getAllLoadedClasses(),它才是背后的大玩家。
查找到了 Class 之后,怎么反编译的呢?

 private String decompileWithCFR(String classPath, Class<?> clazz, String methodName) {
        List<String> options = new ArrayList<String>();
        options.add(classPath);
//        options.add(clazz.getName());
        if (methodName != null) {
            options.add(methodName);
        }
        options.add(OUTPUTOPTION);
        options.add(DecompilePath);
        options.add(COMMENTS);
        options.add("false");
        String args[] = new String[options.size()];
        options.toArray(args);
        Main.main(args);
        String outputFilePath = DecompilePath + File.separator + Type.getInternalName(clazz) + ".java";
        File outputFile = new File(outputFilePath);
        if (outputFile.exists()) {
            try {
                return FileUtils.readFileToString(outputFile, Charset.defaultCharset());
            } catch (IOException e) {
                logger.error(null"error read decompile result in: " + outputFilePath, e);
            }
        }

        return null;
    }

通过这样一个方法:decompileWithCFR,所以我们大概了解到反编译是通过第三方工具「CFR」来实现的。上面的代码也是拼 Option然后传给 CFR的 Main方法实现,再保存下来。感兴趣的朋友可以查询 benf cfr 了解具体用法。

查询加载类的实现

看过上面反编译 class 的内容之后,我们知道封装了一个 SearchUtil的类,后面许多地方都会用到,而且上面反编译也是在查询到类的之后再进行的。查询的过程,也是在Instrument的基础之上,再加上各种匹配规则过滤,所以更多的具体内容不再赘述。

我们发现上面几个功能的实现中,有两个关键的东西:

  • VirtualMachine

  • Instrumentation

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

Instrumentation 是个好同志! :)

推荐↓↓↓
Java编程
上一篇:一网打尽dubbo的8种集群容错模式 下一篇:Java 11与 IntelliJ IDEA