Java在线诊断利器之Arthas

一. 简介

Arthas是阿里在2019年9月份开源的一款java在线诊断工具,能够分析、诊断、定位java应用问题,例如:JVM信息、线程信息、搜索类中的方法、 跟踪代码执行、观测方法的入参和返回参数等等。

Java在线诊断利器之Arthas_第1张图片

Arthas最大的特点是能在不修改代码和不需要重新发布的情况下,对业务问题进行诊断,包括查看方法调用的出参入参、异常、监测方法执行耗时、类加载信息等,大大提升线上问题排查效率。

二. 适用场景

  1. 线上环境某个方法数据处理有问题,但没有日志埋点等记录入参和出参信息,无法debug,并且本地环境无法重现问题
  2. 线上接口调用响应缓慢,耗时高,但接口逻辑复杂,接口内部又调用很多其他系统的接口或第三方的jar,不确定是哪个方法导致的耗时高,无法定位到具体方法
  3. 出问题的方法被执行的路径非常多,无法确定该方法是在哪些具体的地方被调用或执行,这个方法也可能是第三方的jar包里的
  4. 无法确定线上环境是否是最新提交的代码,只能把服务器上的class文件下载下来使用反编译工具打开确认
  5. 线上出现偶发问题或只是某些条件下才会触发,通过日志不容易排查

三. 安装使用

目前的arthas版本都是基于命令行的交互方式,所以下面会按照上面的适用场景列出一些重要和常用的命令,全部命令请查看官方安装。

这里有一个坑,如果在widows环境安装,本地之前安装了多个版本的jdk,在Attach到目标进程时有可能会提示tools.jar包找不到的异常,如下图(没有这个问题可以忽略):

Java在线诊断利器之Arthas_第2张图片

因为Arthas使用了非系统环境变量版本的jdk运行自身,而不是环境变量JAVA_HOME设置的jdk,可以先切换到JAVA_HOME设置的目录,然后再运行 java -jar arthas-boot.jar 即可,这个算是arthas的一个bug,后续版本会优化掉。

四. 常用指令

  1. watch命令(观察指定方法的调用情况,包括返回值、异常、入参、对象属性值)

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

  1. trace命令(方法内部调用路径,并输出方法路径上的每个节点上耗时)

该命令主要用于统计整个调用链路上的所有性能开销和追踪调用链路,使用下来感觉这个命令也是很有用的,包括本地环境,尤其是要排查接口响应时间慢这样的场景下,可以快速定位到具体哪个方法或哪些方法导致的,甚至包括第三方jar包的方法

  1. stack命令(输出当前方法被调用的路径),同样也可以查看依赖的jar里的方法被谁调用
  2. tt命令(time tunnel 时间轴,记录下指定方法每次调用的入参和返回信息)

相当于watch指令的多次记录)但watch命令需要提前观察并拼写表达式,tt则不需要,这里着重说下 -n 参数,当你执行一个调用量不高的方法时可能你还能有足够的时间用 CTRL+C 中断 tt 命令记录的过程,但如果遇到调用量非常大的方法,瞬间就能将你的 JVM 内存撑爆!当我们改了问题后,比如改了配置,需要在线上测试下是否修复的时候,可能会用到该功能,因为环境和数据的问题本地可能无法验证,但线上环境不可能让用户再调用一次,所以这个参数 -p 就可以再重新发起一次调用。但是是由阿尔萨斯内部发起的线程实现的,所以调用方不一样,而且如果之前的调用数据有从threaLocal里获取的话,这次调用代码里也无法获取,使用时需要注意。其实最重要的还是要结合实际场景,因为线上真实环境去模拟用户再次发起调用如果牵涉到下单或支付流程的话还是要慎重的,否则可能引起一些非幂等的后果。

  1. jobs 后台异步任务命令

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

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

  1. redefine命令(加载外部的.class文件)

类似于热加载或热修复的功能,修改java文件后,将替换掉jvm已加载的class类,但是因为jdk本身的限制,修改的class文件里不允许新增加成员变量和方法。

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

  1. jad命令(反编译指定已加载类的源码,可以查看部署在线上服务器的.class文件对应的java源码)

该功能基于一个第三方的反编译工具CFR实现

全部命令请查看官方文档: Arthas用户文档

五. 实现原理

  • Java Agent
  • JDK Instrumentation 和 Attach API 机制
  • ASM字节码增强技术
  • JVMTI
  1.  sun.instrument.InstrumentationImpl 通过instrument机制]的实现可以构建一个独立于应用程序的代理程序Agent,再结合attach机制来绑定我们的应用程序的pid就可以实现监控和协助运行在JVM上的程序,还可以替换和修改类的定义(主要通过redefine,addTransformer函数),比如实现虚拟机级别支持的AOP实现方式。attach机制可以提供一种jvm进程间通信的能力,能让一个进程传命令给另外一个进程,并让它执行内部的一些操作,instrument 和AttachAPI 是btrace,greys,arthas等监控工具的原理基础。
  2. ASM是一个java字节码操作框架,它能被用来动态生成类或者增强既有类的功能。ASM可以从类文件中读入信息后,能够改变类行为,分析类信息,能够根据用户要求生成新类,当然除了asm还有javassist字节码工具,虽然在反射性能上不如asm(但肯定优于jdk原生的反射),但提供了基于java语法实现操作字节码api,学习成本上比asm低。
  3. JVMTI是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)实现的,如下图:

Java在线诊断利器之Arthas_第3张图片

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

六. 源码分析

源码部分目前只列出主要实现, 一些细节来不及看, 感兴趣的可以自己去git上下载下来看 https://github.com/alibaba/arthas

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

Arthas实现原理

这是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

这样就建立了连接,在运行前或者运行时,将自定义的 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 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> matchedClasses, Set> 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来进行的,通过InstrumentloadAllClass方法将所有的JVM加载的class按名字进行匹配,再进行后续处理

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

七. 注意事项

  1. 只有应用在线上业务的诊断上,才能体现它的价值。但是真正将这种类似的技术落地还是有很多事情要做的,阿里也只是开源了他的源码,并没有开源他的具体实施过程,因为这个东西不可能让所有人都在线上搞的,肯定有一套严格的审核权限机制,以及配合这个工具使用的相关配套设施,比如只能针对一台机器操作,线上环境一般都是集群部署,需要OPS和架构组的支持,在可行性上还有很多事情要做。
  2. 对应用程序所在的服务器性能的影响。个别命令使用不当的话,可能会撑爆jvm内存或导致应用程序响应变慢,命令的输出太多,接口调用太频繁会记录过多的数据变量到内存里,比如tt指令,建议加 -n 参数 限制输出次数,sc * 通配符的使用不当,范围过大,使用异步任务时,请勿同时开启过多的后台异步命令,以免对目标JVM性能造成影响,一把双刃剑(它甚至可以修改jdk里的原生类),所以在线上运行肯定是需要权限和流程控制的。

文章来源http://javakk.com/153.html

八. 相关资料

git地址:https://github.com/alibaba/arthas

官方文档:https://alibaba.github.io/arthas/index.html

你可能感兴趣的