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的一些特殊語法
  2. trace命令(方法內部調用路徑,並輸出方法路徑上的每個節點上耗時),該命令主要用於統計整個調用鏈路上的所有性能開銷和追蹤調用鏈路,使用下來感覺這個命令也是很有用的,包括本地環境,尤其是要排查接口響應時間慢這樣的場景下,可以快速定位到具體哪個方法或哪些方法導致的,甚至包括第三方jar包的方法
  3. stack命令(輸出當前方法被調用的路徑),同樣也可以查看依賴的jar里的方法被誰調用
  4. tt命令(time tunnel 時間軸,記錄下指定方法每次調用的入參和返回信息),相當於watch指令的多次記錄)但watch命令需要提前觀察並拼寫表達式,tt則不需要,這里着重說下 -n 參數,當你執行一個調用量不高的方法時可能你還能有足夠的時間用 CTRL+C 中斷 tt 命令記錄的過程,但如果遇到調用量非常大的方法,瞬間就能將你的 JVM 內存撐爆,當我們改了問題后,比如改了配置,需要在線上測試下是否修復的時候,可能會用到該功能,因為環境和數據的問題本地可能無法驗證,但線上環境不可能讓用戶再調用一次,所以這個參數 -p 就可以再重新發起一次調用。但是是由阿爾薩斯內部發起的線程實現的,所以調用方不一樣,而且如果之前的調用數據有從threaLocal里獲取的話,這次調用代碼里也無法獲取,使用時需要注意。其實最重要的還是要結合實際場景,因為線上真實環境去模擬用戶再次發起調用如果牽涉到下單或支付流程的話還是要慎重的,否則可能引起一些非冪等的后果。
  5. jobs 后台異步任務命令,當線上出現偶發的問題時,比如需要watch某個條件,而這個條件一天可能才會出現一次時,這種情況可以使用異步任務將命令在后台運行,而且可以保存到指定的文件,方便查看。這里需要注意:使用異步任務時,請勿同時開啟過多的后台異步命令,以免對目標JVM性能造成影響
  6. redefine命令(加載外部的.class文件),類似於熱加載或熱修復的功能,修改java文件后,將替換掉jvm已加載的class類,但是因為jdk本身的限制,修改的class文件里不允許新增加成員變量和方法。基於這個功能可以模擬一個簡單的監控功能,比如在java文件的某個方法里加上調用耗時和請求參數的打印功能,然后使用redefine即可看到該方法的耗時時間和參數值,並且不用重啟服務。
  7. jad命令(反編譯指定已加載類的源碼,可以查看部署在線上服務器的.class文件對應的java源碼),該功能基於一個第三方的反編譯工具CFR實現

全部命令請查看官方文檔: Arthas用戶文檔

五. 實現原理

  • Java Agent
  • JDK Instrumentation 和 Attach API 機制
  • ASM字節碼增強技術
  • JVMTI
  1. sun.instrument.InstrumentationImpl 通過instrument機制]的實現可以構建一個獨立於應用程序的代理程序Agent,再結合attach機制來綁定我們的應用程序的pid就可以實現監控和協助運行在JVM上的程序,還可以替換和修改類的定義(主要通過redefineaddTransformer函數),比如實現虛擬機級別支持的AOP實現方式。attach機制可以提供一種jvm進程間通信的能力,能讓一個進程傳命令給另外一個進程,並讓它執行內部的一些操作,instrumentAttachAPI 是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上下載下來看 github.com/alibaba/art…

根據官網入門手冊里的 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里的原生類),所以在線上運行肯定是需要權限和流程控制的。

文章來源:javakk.com/153.html

八. 相關資料

git地址:github.com/alibaba/art…

官方文檔:alibaba.github.io/arthas/inde…


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM