Java開發 中運用動態掛載實現 Bug 的熱修復

大多數 JVM 具備 Java 的 HotSwap 特性,大部分開發者認爲它僅僅是一個調試工具。利用這一特性,有可能在不重啓 Java 進程條件下,改變 Java 方法的實現。典型的例子是使用 IDE 來編碼。然而 HotSwap 可以在生產環境中實現這一功能。通過這種方式,不用停止運行程序,就可以擴展在線的應用程序,或者在運行的項目上修復小的錯誤。這篇文章中,我將演示動態綁定、應用運行期代碼變化進行綁定、介紹一些工具 API 以及 Byte Buddy 庫,這個庫提供了一些 API 代碼改變更方便。

假設有一個正在運行的應用程序,通過校驗 HTTP 請求中的 X-Priority 頭部,來執行服務器的特殊處理。該校驗使用下面的工具類來實現:

class HeaderUtility {

    static boolean isPriorityCall(HttpServletRequest request) {
        return request.getHeader("X-Pirority") != null;
    }

}

你發現錯誤了嗎?這樣的錯誤很常見,尤其是在測試代碼中常量值分解爲靜態字段重用。在不太理想的情況下,這個錯誤只會在產品被安裝的時候才被發現,其中頭通過另外一個應用生成並沒有拼寫錯誤。

修復這樣的錯誤並不難。在持續交付的時代,重新部署一個新的版本只需要點擊一下按鈕。但在其他情況下,變更可能就不是那麼簡單了,重新部署過程可能比較複雜,其中停機是不允許的,帶着錯誤運行可能會比較好。但 HotSwap 給我們提供了另外一種選擇:在不重啓應用的前提下進行小幅改動。

Attach API:使用動態附件來滲透另外一個 JVM

爲了修改一個運行中的 Java 程序,我們首先需要一種可以同處在運行狀態的 JVM 進行通信的方式。因爲 Java 的虛擬機實現是一個受到管理的系統,因此擁有進行這些操作的標準 API。提問中涉及到的 API 被稱作 attachment API,它是官方 Java 工具的一部分。使用這個由運行之中的 JVM 所暴露的 API,能讓第二個 Java 進程來同其進行通信。

事實上,我們已經用到了該 API: 它已經由諸如 VisualVM 或者 Java Mission Control 這樣的調試和模擬工具進行了應用。應用這些附件的 API 並沒有同日常使用的標準 Java API 打包在一起,而是被打包到了一個特殊的文件之中,叫做 tools.jar,它只包含了一個虛擬機的 JDK 打包發佈版本。更糟糕的是,這個 JAR 文件的位置並沒有進行設置,它在 Windows、Linux,特別是在 Macintosh 上的 VM 都存在差別,不光文件的位置,連文件名也各異,有些發行版上就被叫做 classes.jar。最後,IBM 甚至決定對這個 JAR 中包含的一些類的名稱進行修改,將所有 com.sun 類挪到 com.ibm 命名空間之中, 又添了一個亂子。在 Java 9 中,亂糟糟的狀態才最終得以清理,tools.jar 被 Jigsaw 的模塊 jdk.attach 所替代。

在對 API 的 JAR (或者模塊) 進行了定位之後,我們就該讓其對附件進程可用。在 OpenJDK 上,被用來連接到另外一個 JVM 的類叫做 VirtualMachine,它向任何由位於同一臺物理機器上的 JDK 或者是一個普通的 HtpSpot JVM 所運行的 VM 提供了一個入口點。在通過進程 id 附加到另外一臺虛擬機上之後,我們就能夠在目標 VM 指定的一個線程中運行一個 JAR 文件:

// the following strings must be provided by usString processId = processId();String jarFileName = jarFileName();
VirtualMachine virtualMachine = VirtualMachine.attach(processId);try {
    virtualMachine.loadAgent(jarFileName, "World!");
} finally {
    virtualMachine.detach();
}

在收到一個 JAR 文件之後,目標虛擬機會查看該 JAR 的程序清單描述文件(manifest),並定位處在 Premain-Class 屬性之下的類。這非常類似於 VM 執行一個主方法的方式。有了一個 Java 代理,VM 和指定的進程 id 就可以查找到一個名爲 agentmain 的方法,該方法可以由指定線程中的遠程進程來執行:

public class HelloWorldAgent {

    public static void agentmain(String arg) {
        System.out.println("Hello, " + arg);
    }

}

使用該 API,只要我們知道一個 JVM 的進程 id,就可以來在其上運行代碼,打印出一條 Hello, World! 消息。甚至有可能同並不熟 JDK 發行版一部分的 JVM 進行通信,只要附加的 VM 是一個用來訪問 tools.jar 的 JDK 安裝程序。

Instrumentation API:修改目標 VM 的程序

到目前來看一切順利。但是除了成功地同目標 VM 建立起了通信之外,我們還不能夠修改目標 VM 上的代碼以及 BUG。後續的修改,Java 代理可以定義第二參數來接收一個 Instrumentation 的實例 。稍後要實現的接口提供了向幾個底層方法的訪問途徑,它們中的一個就能夠對已經加載的代碼進行修改。

爲了修正 “X-Pirority” 錯字,我們首先來假設爲 HeaderUtility 引入了一個修復類,叫做 typo.fix,就在我們下面所開發的 BugFixAgent 後面的代理的 JAR 文件中。此外,我們需要給予代理通過向 manifest 文件添加 Can-Redefine-Classes: true 來替換現有類的能力。有了現在這些東西,我們就可以使用 instrumentation 的 API 來對類進行重新定義,該 API 會接受一對已經加載的類以及用來執行類重定義的字節數組:

public class BugFixAgent {

    public static void agentmain(String arg, Instrumentation inst)            throws Exception {
        // only if header utility is on the class path; otherwise,        // a class can be found within any class loader by iterating        // over the return value of Instrumentation::getAllLoadedClasses        Class<?> headerUtility = Class.forName("HeaderUtility");

        // copy the contents of typo.fix into a byte array        ByteArrayOutputStream output = new ByteArrayOutputStream();
        try (InputStream input =
                BugFixAgent.class.getResourceAsStream("/typo.fix")) {
            byte[] buffer = new byte[1024];
            int length;
            while ((length = input.read(buffer)) != -1) {
                output.write(buffer, 0, length);
            }
        }

        // Apply the redefinition        instrumentation.redefineClasses(
                new ClassDefinition(headerUtility, output.toByteArray()));
    }

}

運行上述代碼後,HeaderUtility 類會被重定義以對應其修補的版本。對 isPrivileged 的任何後續調用現在將讀取正確的頭信息。作爲一個小的附加說明,JVM 可能會在應用類重定義時執行完全的垃圾回收,並且會對受影響的代碼進行重新優化。 總之,這會導致應用程序性能的短時下降。然而,在大多數情況下,這是較之完全重啓進程更好的方式。

當應用代碼更改時,要確保新類定義了與它替換的類完全相同的字段、方法和修飾符。 嘗試修改任何此類屬性的類重定義行爲都會導致 UnsupportedOperationException。現在 HotSpot 團隊正試圖去掉這個限制。此外,基於 OpenJDK 的動態代碼演變虛擬機支持預覽此功能。

使用 Byte Buddy 來追蹤內存泄漏

一個如上述示例的簡單的 BUG 修復代理在你熟悉了 instrumentation 的 API 的時候是比較容易實現的。只要更加深入一點,也可以在運行代理的時候,無需手動創建附加的 class 文件,而是通過重寫現有的 class 來應用更多通用的代碼修改。

字節碼操作

編譯好的 Java 代碼所呈現的是一系列字節碼指令。從這個角度來看,一個 Java 方法無非就是一個字節數組,其每一個字節都是在表示一個向運行時發出的指令,或者是最近一個指令的參數。每個字節對應其意義的映射在《Java 虛擬機規範》中進行了定義,例如字節 0xB1 就是在指示 VM 從一個帶有 void 返回類型的方法返回。因此,對字節碼進行增強就是對一個方法的字節數字進行擴展,將我們想要應用的表示額外的業務邏輯指令包含進去。

當然,逐個字節的操作會特別麻煩,而且容易出錯。爲了避免手工的處理,許多的庫都提供了更高級一點的 API,使用它們不需要我們直接同 Java 字節碼打交道。這樣的庫其中就有一個叫做 Byte Buddy (當然我就是該庫的作者)。它的功能之一就是能夠定義可以在方法原來的代碼之前和之後被執行的模板方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章