乐趣区

关于java:Java在线诊断利器之Arthas

一. 简介

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

Arthas 最大的特点是能在 不批改代码和不须要从新公布的状况下,对业务问题进行诊断,包含查看办法调用的出参入参、异样、监测办法执行耗时、类加载信息等,大大晋升线上问题排查效率。

二. 实用场景

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

三. 装置应用

目前的 arthas 版本都是基于命令行的交互方式,所以上面会依照下面的实用场景列出一些重要和罕用的命令,全副命令请查看官网装置。

这里有一个坑,如果在 widows 环境装置,本地之前装置了多个版本的 jdk,在 Attach 到指标过程时有可能会提醒 tools.jar 包找不到的异样,如下图(没有这个问题能够疏忽):

因为 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)实现的,如下图:

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 机制)

这是 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<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 来进行的, 通过 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

退出移动版