Arthas源码分析

1,790 阅读11分钟

在日常开发中,经常会使用到arthas排查线上问题,觉得arthas的功能非常强大,所以打算花了点时间了解一下其实现原理。并试着回答一下使用Arthas时存在的一些疑问。

Arthas主要基于是Instrumentation + JavaAgent + Attach API + ASM + 反射 + OGNL等技术实现的。在不停止应用服务的情况下,将Arthas的jar包代码动态加载到应用的JVM中,再配合Instrumentation类,动态修改应用JVM中运营的字节码,实现对目标应用增强,如获取某方法的参数、返回值、耗时等信息、调用JVM相关类获取JVM运行时信息,最后再通过OGNL过滤、存取对象属性。

1. 如何attach到应用?

1.1 如何debug?

Arthas的启动很简单,从github上把Arthas的代码clone到本地,然后直接运行/bin目录下的as.sh脚本便能启动。为了弄明白Arthas attach到应用的过程,可以加上--debug-attach参数,同时为了查看脚本的详细执行流程,bash加上-x选项。

bash -x ./as.sh --debug-attach

根据打印的执行流程,开始主要是进行一些配置检查、目录创建和运行参数的构造。前置工作准备好后,会调用jps命令列出系统当前所有运行的JVM。

image.png

选择我们需要attach的JVM,会判断本地目录$HOME/.arthas/中是否存在Arthas对应版本的jar包,如果不存在,则下载并解压到指定目$HOME/.arthas/

image.png

image.png

最后通过java命令运行Arthas client,尝试连接Arthas server(127.0.0.1:3568)

image.png

java -agentlib:jdwp=transport=dt_socket,address=8888,server=y,suspend=y -Djava.awt.headless=true -jar /Users/banzhe/.arthas/lib/3.6.6/arthas/arthas-client.jar 127.0.0.1 3658 -c session --execution-timeout 2000

从启动的命令可以看到,主要是运行了arthas-client.jar包,同时开启了远程debug,远程debug端口号8888,因为设置了suspend=y,启动流程被阻塞,等待debugger attach。打开IDEA,配置远程debug,然后点击debug,流程即可继续。

image.png

因为当前Arthas server还没有启动(attach到应用JVM),所以抛了一个ConnectException异常

image.png

接着启动Arthas server,将其attach到指定的应用JVM

image.png

java -Xbootclasspath/a:/Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/lib/tools.jar -agentlib:jdwp=transport=dt_socket,address=8888,server=y,suspend=y -Djava.awt.headless=true -jar /Users/banzhe/.arthas/lib/3.6.6/arthas/arthas-core.jar -pid 57840 -core /Users/banzhe/.arthas/lib/3.6.6/arthas/arthas-core.jar -agent /Users/banzhe/.arthas/lib/3.6.6/arthas/arthas-agent.jar

从启动的命令可以看到,主要是运行了arthas-core.jar和arthas-agent.jar两个jar包,同时开启了远程debug,远程debug端口号8888。查看arthas-core和arthas-agent两个模块下的pom文件,可以发现main方法在arthas-core模块下的com.taobao.arthas.core.Arthas类中,所以在arthas-core模块的main方法设置断点,然后点击debug,即可开始attach过程的debug。

image.png

远程debug连接成功之后,attach流程就很容易弄明白了。attach的过程主要是在attachAgent中完成的。

1.2 Arthas attach到应用JVM

attach是使用sun提供JVM Attach API完成的。核心代码如下:

VirtualMachineDescriptor virtualMachineDescriptor = null;
// 1. 列出当前系统运行的所有JVM,和jps类似
for (VirtualMachineDescriptor descriptor : VirtualMachine.list()) {
    String pid = descriptor.id();
    // 找到指定PID对应的JVM
    if (pid.equals(Long.toString(configure.getJavaPid()))) {
        virtualMachineDescriptor = descriptor;
        break;
    }
}
// attach到指定JVM
VirtualMachine virtualMachine = VirtualMachine.attach(virtualMachineDescriptor);
// 指定agent jar包和相关配置
virtualMachine.loadAgent(arthasAgentPath, configure.getArthasCore() + ";" + configure.toString());

image.png

至此,Arthas完成attach到目标应用JVM的过程。

2. Arthas与attach应用隔离

Arthas的代码attach到应用对应的JVM后,将由应用JVM加载运行,为了避免Arthas代码对应用的影响,Arthas进行了代码隔离。在介绍代码隔离的具体实现之前,先看一下如何进行debug。因为attach完成之后,Arthas的代码是由应用JVM进行加载和运行的,所以需要应用代码中进行debug。但是应用中并没有引入Arthas的jar包,无法直接进行debug。可以参考attach过程的debug,在启动应用的时候开启远程debug,然后在Arthas源码中进行debug。

2.1 在应用JVM中debug Arthas

直接借助官网的例子,假设应用代码如下:

package com.banzhe.loader.demo;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Demo {

    static class Counter {
        private static final AtomicInteger count = new AtomicInteger(0);

        public static void increment() {
            count.incrementAndGet();
        }

        public static int value() {
            return count.get();
        }
    }

    public static void main(String[] args) {

        while (true) {
            Counter.increment();
            try {
                System.out.println("counter: " + Counter.value());
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException ignored) {
                break;
            }
        }
    }
}

以支持远程debug的方式启动,debug端口为:8000

# 编译
javac com/banzhe/loader/demo/*.java
# 运行
java -agentlib:jdwp=transport=dt_socket,address=8000,server=y,suspend=n com.banzhe.loader.demo.Demo

因为Arthas是在应用运行时attach到应用JVM的,attach完成之后,应用JVM会以Agent-Class的agentmain方法作为入口方法执行。所以在agentmain方法中打断点,然后运行bash ./as.sh启动Arthas即可。从arthas-agent模块的pom文件中可知道Agent-Class为com.taobao.arthas.agent334.AgentBootstrap

image.png

2.2 类隔离

为了避免Arthas代码对应用的影响,Arthas进行了代码隔离。在JVM中一个类型实例是通过类加载器+全类名确定的。也就是说为了避免不同模块代码间相互影响(两个jar中可能会存在全类名相同,但是逻辑完全不同的类),可以通过使用不同的ClassLoader进行加载来实现隔离。如pandora boot、tomcat等都是基于ClassLoader实现代码隔离的,Arthas也是通过定义了自己的ClassLoader——ArthasClassLoader来实现与应用代码隔离的。

public class ArthasClassloader extends URLClassLoader {
    public ArthasClassloader(URL[] urls) {
        super(urls, ClassLoader.getSystemClassLoader().getParent());
    }

    @Override
    protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        final Class<?> loadedClass = findLoadedClass(name);
        if (loadedClass != null) {
            return loadedClass;
        }

        // 优先从parent(SystemClassLoader)里加载系统类,避免抛出ClassNotFoundException
        if (name != null && (name.startsWith("sun.") || name.startsWith("java."))) {
            return super.loadClass(name, resolve);
        }
        try {
            Class<?> aClass = findClass(name);
            if (resolve) {
                resolveClass(aClass);
            }
            return aClass;
        } catch (Exception e) {
            // ignore
        }
        return super.loadClass(name, resolve);
    }
}

image.png

可以发现arthas-core.jar中的类都是由ArthasClassLoader加载的。

由于类加载有一个原则:

加载当前类的加载器,也会用于加载其所依赖的类(当然不一定是它加载,也可能遵循双亲委派原则,由双亲加载器加载)

因为Agent-Class——com.taobao.arthas.agent334.AgentBootstrap的agentmain是agent的入口方法,attach完成之后由应用JVM加载,一般是ApplicationClassLoader加载器加载。按照上述类加载器的原则,Arthas的代码也都会由应用的ApplicationClassLoader加载,无法实现代码隔离。所以Arthas通过反射打破了这个规则,实现Arthas代码与应用代码的隔离。

// 使用ArthasClassLoader加载arthas-core的初始化类
Class<?> bootstrapClass = agentLoader.loadClass(""com.taobao.arthas.core.server.ArthasBootstrap"");
// 通过反射调用初始化函数
Object bootstrap = bootstrapClass.getMethod("getMethod", Instrumentation.class, String.class).invoke(null, inst, args);
  1. 首先通过ArthasClassLoader加载arthas-core的初始化类ArthasBootstrap
  2. 通过反射调用的方式调用getInstance实现Arthas server端的初始化

因为ArthasBootstrap是由ArthasClassLoader加载器加载的,ArthasBootstrap负责初始化arthas-core,按照加载当前类的加载器,也会用于加载其所依赖的类原则,ArthasBootstrap依赖的类也会由ArthasClassLoader加载,所以就是实现了Arthas与应用的隔离。

注意:不能将反射调用结果强制转换成ArthasBootstrap,不然会抛ClassCastException。因为左边的ArthasBootstrap的class实例是由应用的ApplicationClassLoader加载的,而右边的ArthasBootstrap的class实例是由ArthasClassLoader加载的,class实例不同不能进行转换。

ArthasBootstrap bootstrap = (ArthasBootstrap) bootstrapClass.getMethod("getMethod", Instrumentation.class, String.class).invoke(null, inst, args);

2.3 应用代码调用arthas代码

根据上一节我们可以知道,Arthas的代码和应用的代码是通过类加载器隔离的,其类加载器结构如下图。

image.png

根据类加载器的双亲委派原则,父类加载器加载的类对子类加载器是可见的,而子类加载器加载的类对父类加载器是不可见的,兄弟类加载器加载的类互相是不可见的。也就是说Arthas和应用JVM之间共享了JMX等底层API(由BootstrapClassLoader和ExtClassLoader加载的类),所以Arthas可以通过调用JDK的一些API获取应用JVM相应的运行时数据,比如dashboard/thread/mbean等命令。但是对于增强型命令如watch/trace/tt,Arthas会对应用代码注入一些代码,当被增强的应用代码执行时,会执行到Arthas注入的代码,从而实现功能的增强。但是由于ApplicationClassLoaderArthasClassLoader加载器的类之间是不可见的,也就是说应用代码是不能直接调用Arthas代码的,会有ClassNotFoundException或者ClassCastException。

Arthas采用了一种很巧的方案,引入了一个arthas-spy模块,相当于在应用和Arthas之间架起了一座桥梁。arthas-spy模块中只有一个SpyAPI类文件。ApyAPI类由BoostrapClassLoader加载,所以SpyAPI对于ApplicationClassLoaderArthasClassLoader都是可见的,应用通过SpyAPI实现对Arthas的调用,从而实现功能的增强。

下面我们通过代码来看一下具体是如何实现的:

private void initSpy() throws Throwable {
    // TODO init SpyImpl ?

    // 将Spy添加到BootstrapClassLoader
    ClassLoader parent = ClassLoader.getSystemClassLoader().getParent();
    Class<?> spyClass = null;
    if (parent != null) {
        try {
            spyClass =parent.loadClass("java.arthas.SpyAPI");
        } catch (Throwable e) {
            // ignore
        }
    }
    if (spyClass == null) {
        CodeSource codeSource = ArthasBootstrap.class.getProtectionDomain().getCodeSource();
        if (codeSource != null) {
            File arthasCoreJarFile = new File(codeSource.getLocation().toURI().getSchemeSpecificPart());
            File spyJarFile = new File(arthasCoreJarFile.getParentFile(), "arthas-spy.jar");
            instrumentation.appendToBootstrapClassLoaderSearch(new JarFile(spyJarFile));
        } else {
            throw new IllegalStateException("can not find " + "arthas-spy.jar");
        }
    }
}
  1. 首先通过BootstrapClassLoader直接加载SpyAPI
  2. 如果加载失败(可能是CLASSPATH中找不SpyAPI),将arthas-spy.jar添加到应用的BootstrapClassLoader的搜索路径中
  3. 按照类加载的双亲委派原则,加载SpyAPI类时,会优先委托给BootstrapClassLoader,所以SpyAPI会被根类加载器加载,而不是ArthasClassLoader加载

SpyAPI中定义了不同时机的静态增强处理函数,具体的处理逻辑由抽象类AbstractSpy的子类SpyImpl实现。

public class SpyAPI {
        
    private static volatile AbstractSpy spyInstance = NOPSPY;

    public static void setSpy(AbstractSpy spy) {
        spyInstance = spy;
    }

    public static void atEnter(Class<?> clazz, String methodInfo, Object target, Object[] args) {
        spyInstance.atEnter(clazz, methodInfo, target, args);
    }

    public static void atExit(Class<?> clazz, String methodInfo, Object target, Object[] args,
            Object returnObject) {
        spyInstance.atExit(clazz, methodInfo, target, args, returnObject);
    }

    public static void atExceptionExit(Class<?> clazz, String methodInfo, Object target,
            Object[] args, Throwable throwable) {
        spyInstance.atExceptionExit(clazz, methodInfo, target, args, throwable);
    }

    public static void atBeforeInvoke(Class<?> clazz, String invokeInfo, Object target) {
        spyInstance.atBeforeInvoke(clazz, invokeInfo, target);
    }

    public static void atAfterInvoke(Class<?> clazz, String invokeInfo, Object target) {
        spyInstance.atAfterInvoke(clazz, invokeInfo, target);
    }

    public static void atInvokeException(Class<?> clazz, String invokeInfo, Object target, Throwable throwable) {
        spyInstance.atInvokeException(clazz, invokeInfo, target, throwable);
    }

    public static abstract class AbstractSpy {
        public abstract void atEnter(Class<?> clazz, String methodInfo, Object target,
                Object[] args);

        public abstract void atExit(Class<?> clazz, String methodInfo, Object target, Object[] args,
                Object returnObject);

        public abstract void atExceptionExit(Class<?> clazz, String methodInfo, Object target,
                Object[] args, Throwable throwable);

        public abstract void atBeforeInvoke(Class<?> clazz, String invokeInfo, Object target);

        public abstract void atAfterInvoke(Class<?> clazz, String invokeInfo, Object target);

        public abstract void atInvokeException(Class<?> clazz, String invokeInfo, Object target, Throwable throwable);
    }
}

因为AbstractSpySpyAPI的内部静态类,并且在SpyAPI中定义了一个静态属性,所以AbstractApy也会由BoostrapClassLoader加载。而SpyImpl在arthas-core模块中实现,所以会被ArthasClassLoader加载。

所以Arthas可以通过调用SpyAPIsetSpy方法设置增强代码的具体执行逻辑。因为AbstractApyBoostrapClassLoader加载,SpyImplArthasClassLoader加载,所以SpyImpl实例可以向上类型转换成AbstractApy实例,完成赋值操作。

应用代码在调用Arthas的增强代码时,是通过调用SpyAPI的静态方法,然后调用AbstractSpy实例实现方法增强。

image.png

3. 如何支持OGNL?

Arthas是支持OGNL表达式的,所以Arthas的时候可以非常灵活,例如下面查看第一个参数大于10的命令。

watch com.cm4j.loader.demo.Demo random '{params[0]}' 'params[0] > 10'

那它具体是怎么支持的呢?通过断点我们很容易可以定位到OGNL的处理逻辑:

image.png

Arthas会将增强执行的结果全部放在Advice实例中,主要包括增强的方法名、参数、执行结果、耗时等数据,在返回是先判断是否有OGNL表达式,如果有OGNL表达式,会执行OGNL表达式,针对OGNL设置的条件进行过滤或者数值筛选。主要依赖了ognl对应的jar包。

image.png

4. Arthas命令分类及原理

image.png

下面主要介绍一下watch/trace/tt等增强命令的实现的原理。Arthas的字节码增强是基于bytekit实现,bytekit是对ASM的封装,基于提供更高层的字节码处理能力,面向诊断/APM领域的字节码库,同时提供一套简洁的API,让开发人员可以很轻松的完成字节码增强。例如下面对Sample类的hello方法进行增强,打印hello方法的耗时:

public class ByteKitDemo {
    public static class Sample {
        public void hello(String name) {
            System.out.println("Hello " + name + "!");
        }
    }

    public static class SampleInterceptor {
        private static long start;
        @AtEnter(inline = true)
        public static void atEnter() {
            start = System.currentTimeMillis();
        }

        @AtExit(inline = true)
        public static void atEit() {
            System.out.println(System.currentTimeMillis() - start);
        }
    }

    public static void main(String[] args) throws Exception {
        // 解析定义的 Interceptor类 和相关的注解
        DefaultInterceptorClassParser interceptorClassParser = new DefaultInterceptorClassParser();
        List<InterceptorProcessor> processors = interceptorClassParser.parse(SampleInterceptor.class);
        // 加载字节码
        ClassNode classNode = AsmUtils.loadClass(Sample.class);
        // 对加载到的字节码做增强处理
        for (MethodNode methodNode : classNode.methods) {
            MethodProcessor methodProcessor = new MethodProcessor(classNode, methodNode);
            for (InterceptorProcessor interceptor : processors) {
                interceptor.process(methodProcessor);
            }
        }
        // 获取增强后的字节码
        byte[] bytes = AsmUtils.toBytes(classNode);
        // 查看反编译结果
        System.out.println(Decompiler.decompile(bytes));
    }
}

执行结果:

public static class ByteKitDemo.Sample {
    public ByteKitDemo.Sample() {
        ByteKitDemo.SampleInterceptor.start = System.currentTimeMillis();
        System.out.println(System.currentTimeMillis() - ByteKitDemo.SampleInterceptor.start);
    }

    /*
     * WARNING - void declaration
     */
    public void hello(String string) {
        void name;
        ByteKitDemo.SampleInterceptor.start = System.currentTimeMillis();
        System.out.println("Hello " + (String)name + "!");
        System.out.println(System.currentTimeMillis() - ByteKitDemo.SampleInterceptor.start);
    }
}

可以发现整个增强实现的代码可读性是非常好的。

Arthas的watch命令增强核心代码如下:

// SpyInterceptor1对应函数调用之前增强
interceptorProcessors.addAll(defaultInterceptorClassParser.parse(SpyInterceptor1.class));
// SpyInterceptor2对应函数返回之后增强
interceptorProcessors.addAll(defaultInterceptorClassParser.parse(SpyInterceptor2.class));
// SpyInterceptor3对应函数异常之后增强
interceptorProcessors.addAll(defaultInterceptorClassParser.parse(SpyInterceptor3.class));

所有增强的实现都在SpyImpl中实现,Arthas会将方法名、参数、返回值等信息统一保存到Advice中,然后在按照对应格式进行处理返回。

可以验证一下,使用watch命令时,arthas会对相关的方法进行字节码增强。首先打开一个arthas终端执行:

watch com.cm4j.loader.demo.Demo random  -n 1000

然后打开另一个arthas终端,查看最新加载的Demo类

jad com.cm4j.loader.demo.Demo

image.png

可以发现,arthas会对相关方法进行3处增强:调用之前增强、函数返回之后增强、函数异常之后增强。

5. 参考链接

  1. Arthas原理:如何做到与应用代码隔离
  2. Arthas原理:Arthas的命令分类及原理
  3. Debug Arthas In IDEA
  4. java attach api