1. 程式人生 > >手把手教你實現熱更新功能,帶你瞭解 Arthas 熱更新背後的原理

手把手教你實現熱更新功能,帶你瞭解 Arthas 熱更新背後的原理

文章來源:https://studyidea.cn/java-hotswap

一、前言

一天下午正在摸魚的時候,測試小姐姐走了過來求助,說是需要改動測試環境 mock 應用。但是這個應用一時半會又找不到原始碼存在何處。但是測試小姐姐的活還是一定要幫,突然想起了 Arthas 可以熱更新應用程式碼,按照網上的步驟,反編譯應用程式碼,加上需要改動的邏輯,最後熱更新成功。對此,測試小姐姐很滿意,並表示下次會少提 Bug。

嘿嘿,以前一直對熱更新背後原理很好奇,藉著這個機會,研究一下熱更新的原理。

二、Arthas 熱更新

我們先來看下 Arthas 是如何熱更新的。

詳情參考:阿里巴巴Arthas實踐--jad/mc/redefine線上熱更新一條龍

假設我們現在有一個 HelloService 類,邏輯如下,現在我們使用 Arthas 熱更新程式碼,讓其輸出 hello arthas

public class HelloService {

    public static void main(String[] args) throws InterruptedException {

        while (true){
            TimeUnit.SECONDS.sleep(1);
            hello();
        }
    }

    public static void hello(){
        System.out.println("hello world");
    }

}

2.1、jad 反編譯程式碼

首先執行 jad 命令反編譯 class 檔案獲取原始碼,執行命令如下:。

jad --source-only com.andyxh.HelloService > /tmp/HelloService.java

2.2、修改反編譯之後的程式碼

拿到原始碼之後,使用 VIM 等文字編輯工具編輯原始碼,加入需要改動的邏輯。

2.3、查詢 ClassLoader

然後使用 sc 命令查詢載入修改類的 ClassLoader,執行命令如下:

$ sc -d  com.andyxh.HelloService | grep classLoaderHash
 classLoaderHash   4f8e5cde

這裡執行之後將會得到 ClassLoader 雜湊值。

2.4、 mc 記憶體編譯原始碼

使用 mc 命令編譯上一步修改儲存的原始碼,生成最終 class 檔案。

$ mc -c 4f8e5cde  /tmp/HelloService.java  -d /tmp
Memory compiler output:
/tmp/com/andyxh/HelloService.class
Affect(row-cnt:1) cost in 463 ms.

2.5、redefine 熱更新程式碼

執行 redefine 命令:

$ redefine /tmp/com/andyxh/HelloService.class
redefine success, size: 1

熱更新成功之後,程式輸出結果如下:

一般情況下,我們本地將會有原始碼,上面的步驟我們可以進一步省略,我們可以先在自己 IDE 上改動程式碼,編譯生成 class 檔案。這樣我們只需要執行 redefine 命令即可。也就是說實際上起到作用只是 redefine

三、 Instrumentation 與 attach 機制

Arthas 熱更新功能看起來很神奇,實際上離不開 JDK 一些 API,分別為 instrument API 與 attach API。

3.1 Instrumentation

Java Instrumentation 是 JDK5 之後提供介面。使用這組介面,我們可以獲取到正在執行 JVM 相關資訊,使用這些資訊我們構建相關監控程式檢測 JVM。另外, 最重要我們可以替換和修改類的,這樣就實現了熱更新。

Instrumentation 存在兩種使用方式,一種為 pre-main 方式,這種方式需要在虛擬機器引數指定 Instrumentation 程式,然後程式啟動之前將會完成修改或替換類。使用方式如下:

java -javaagent:jar Instrumentation_jar -jar xxx.jar

有沒有覺得這種啟動方式很熟悉,仔細觀察一下 IDEA 執行輸出視窗。

另外很多應用監控工具,如:zipkin、pinpoint、skywalking。

這種方式只能在應用啟動之前生效,存在一定的侷限性。

JDK6 針對這種情況作出了改進,增加 agent-main 方式。我們可以在應用啟動之後,再執行 Instrumentation 程式。啟動之後,只有連線上相應的應用,我們才能做出相應改動,這裡我們就需要使用 Java 提供 attach API。

3.2 Attach API

Attach API 位於 tools.jar 包,可以用來連線目標 JVM。Attach API 非常簡單,內部只有兩個主要的類,VirtualMachineVirtualMachineDescriptor

VirtualMachine 代表一個 JVM 例項, 使用它提供 attach 方法,我們就可以連線上目標 JVM。

 VirtualMachine vm = VirtualMachine.attach(pid);

VirtualMachineDescriptor 則是一個描述虛擬機器的容器類,通過該例項我們可以獲取到 JVM PID(程序 ID),該例項主要通過 VirtualMachine#list 方法獲取。

        for (VirtualMachineDescriptor descriptor : VirtualMachine.list()){

            System.out.println(descriptor.id());
        }

介紹完熱更新涉及的相關原理,接下去使用上面 API 實現熱更新功能。

四、實現熱更新功能

這裡我們使用 Instrumentation agent-main 方式。

4.1、實現 agent-main

首先需要編寫一個類,包含以下兩個方法:

public static void agentmain (String agentArgs, Instrumentation inst);          [1]
public static void agentmain (String agentArgs);            [2]

上面的方法只需要實現一個即可。若兩個都實現, [1] 優先順序大於 [2],將會被優先執行。

接著讀取外部傳入 class 檔案,呼叫 Instrumentation#redefineClasses,這個方法將會使用新 class 替換當前正在執行的 class,這樣我們就完成了類的修改。

public class AgentMain {
    /**
     *
     * @param agentArgs 外部傳入的引數,類似於 main 函式 args
     * @param inst
     */
    public static void agentmain(String agentArgs, Instrumentation inst) {
        // 從 agentArgs 獲取外部引數
        System.out.println("開始熱更新程式碼");
        // 這裡將會傳入 class 檔案路徑
        String path = agentArgs;
        try {
            // 讀取 class 檔案位元組碼
            RandomAccessFile f = new RandomAccessFile(path, "r");
            final byte[] bytes = new byte[(int) f.length()];
            f.readFully(bytes);
            // 使用 asm 框架獲取類名
            final String clazzName = readClassName(bytes);

            // inst.getAllLoadedClasses 方法將會獲取所有已載入的 class
            for (Class clazz : inst.getAllLoadedClasses()) {
                // 匹配需要替換 class
                if (clazz.getName().equals(clazzName)) {
                    ClassDefinition definition = new ClassDefinition(clazz, bytes);
                    // 使用指定的 class 替換當前系統正在使用 class
                    inst.redefineClasses(definition);
                }
            }

        } catch (UnmodifiableClassException | IOException | ClassNotFoundException e) {
            System.out.println("熱更新資料失敗");
        }


    }

    /**
     * 使用 asm 讀取類名
     *
     * @param bytes
     * @return
     */
    private static String readClassName(final byte[] bytes) {
        return new ClassReader(bytes).getClassName().replace("/", ".");
    }
}

完成程式碼之後,我們還需要往 jar 包 manifest 寫入以下屬性。

## 指定 agent-main 全名
Agent-Class: com.andyxh.AgentMain
## 設定許可權,預設為 false,沒有許可權替換 class
Can-Redefine-Classes: true

我們使用 maven-assembly-plugin,將上面的屬性寫入檔案中。

<plugin>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>3.1.0</version>
    <configuration>
        <!--指定最後產生 jar 名字-->
        <finalName>hotswap-jdk</finalName>
        <appendAssemblyId>false</appendAssemblyId>
        <descriptorRefs>
            <!--將工程依賴 jar 一塊打包-->
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifestEntries>
                <!--指定 class 名字-->
                <Agent-Class>
                    com.andyxh.AgentMain
                </Agent-Class>
                <Can-Redefine-Classes>
                    true
                </Can-Redefine-Classes>
            </manifestEntries>
            <manifest>
                <!--指定 mian 類名字,下面將會使用到-->
                <mainClass>com.andyxh.JvmAttachMain</mainClass>
            </manifest>
        </archive>
    </configuration>
    <executions>
        <execution>
            <id>make-assembly</id> <!-- this is used for inheritance merges -->
            <phase>package</phase> <!-- bind to the packaging phase -->
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

到這裡我們就完成熱更新主要程式碼,接著使用 Attach API,連線目標虛擬機器,觸發熱更新的程式碼。

public class JvmAttachMain {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        // 輸入引數,第一個引數為需要 Attach jvm pid 第二引數為 class 路徑
        if(args==null||args.length<2){
            System.out.println("請輸入必要引數,第一個引數為 pid,第二引數為 class 絕對路徑");
            return;
        }
        String pid=args[0];
        String classPath=args[1];
        System.out.println("當前需要熱更新 jvm pid 為 "+pid);
        System.out.println("更換 class 絕對路徑為 "+classPath);
        // 獲取當前 jar 路徑
        URL jarUrl=JvmAttachMain.class.getProtectionDomain().getCodeSource().getLocation();
        String jarPath=jarUrl.getPath();

        System.out.println("當前熱更新工具 jar 路徑為 "+jarPath);
        VirtualMachine vm = VirtualMachine.attach(pid);//7997是待繫結的jvm程序的pid號
        // 執行最終 AgentMain 中方法
        vm.loadAgent(jarPath, classPath);
    }
}

在這個啟動類,我們最終呼叫 VirtualMachine#loadAgent,JVM 將會使用上面 AgentMain 方法使用傳入 class 檔案替換正在執行 class。

4.2、執行

這裡我們繼續開頭使用的例子,不過這裡加入一個方法獲取 JVM 執行程序 ID。

public class HelloService {

    public static void main(String[] args) throws InterruptedException {
        System.out.println(getPid());
        while (true){
            TimeUnit.SECONDS.sleep(1);
            hello();
        }
    }

    public static void hello(){
        System.out.println("hello world");
    }

    /**
     * 獲取當前執行 JVM PID
     * @return
     */
    private static String getPid() {
        // get name representing the running Java virtual machine.
        String name = ManagementFactory.getRuntimeMXBean().getName();
        System.out.println(name);
        // get pid
        return name.split("@")[0];
    }

}

首先執行 HelloService,獲取當前 PID,接著複製 HelloService 程式碼到另一個工程,修改 hello 方法輸出 hello agent,重新編譯生成新的 class 檔案。

最後在命令列執行生成的 jar 包。

HelloService 輸出效果如下所示:

原始碼地址:https://github.com/9526xu/hotswap-example

4.3、除錯技巧

普通的應用我們可以在 IDE 直接使用 Debug 模式除錯程式,但是上面的程式無法直接使用 Debug。剛開始執行的程式碰到很多問題,無奈之下,只能選擇最原始的辦法,列印錯誤日誌。後來檢視 arthas 的文件,發現上面一篇文章介紹使用 IDEA Remote Debug 模式除錯程式。

首先我們需要在 HelloService JVM 引數加入以下引數:

-Xrunjdwp:transport=dt_socket,server=y,address=8001  

此時程式將會被阻塞,直到遠端除錯程式連線上 8001 埠,輸出如下:

然後在 Agent-main 這個工程增加一個 remote 除錯。

圖中引數如下:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8001

Agent-main 工程打上斷點,執行遠端除錯, HelloService 程式將會被啟動。

最後在命令列視窗執行 Agent-main 程式,遠端除錯將會暫停到相應斷點處,接下來除錯就跟普通 Debug 模式一樣,不再敘述。

4.4、相關問題

由於 Attach API 位於 tools.jar 中,而在 JDK8 之前 tools.jar 與我們常用JDK jar 包並不在同一個位置,所以編譯與執行過程可能找不到該 jar 包,從而導致報錯。

如果 maven 編譯與執行都使用 JDK9 之後,不用擔心下面問題。

maven 編譯問題

maven 編譯過程可能發生如下錯誤。

解決辦法為在 pom 下加入 tools.jar 。

        <dependency>
            <groupId>jdk.tools</groupId>
            <artifactId>jdk.tools</artifactId>
            <scope>system</scope>
            <version>1.6</version>
            <systemPath>${java.home}/../lib/tools.jar</systemPath>
        </dependency>

或者使用下面依賴。

        <dependency>
            <groupId>com.github.olivergondza</groupId>
            <artifactId>maven-jdk-tools-wrapper</artifactId>
            <version>0.1</version>
            <scope>provided</scope>
            <optional>true</optional>
        </dependency>

程式執行過程 tools.jar 找不到

執行程式時丟擲 java.lang.NoClassDefFoundError,主要原因還是系統未找到 tools.jar 導致。

在執行引數加入 -Xbootclasspath/a:${java_home}/lib/tools.jar,完整執行命令如下:

4.5、熱更新存在一些限制

並不是所有改動熱更新都將會成功,當前使用 Instrumentation#redefineClasses 還是存在一些限制。我們僅只能修改方法內部邏輯,屬性值等,不能新增,刪除方法或欄位,也不能更改方法的簽名或繼承關係。

五、彩蛋

寫完熱更新程式碼,收到一封系統郵件提示 xxx bug 待修復。恩,說好的少提 Bug 呢 o(╥﹏╥)o。

六、幫助

1.深入探索 Java 熱部署
2.Instrumentation 新功能

歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:studyidea.cn