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

oschina發表於2017-01-22

大多數 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 us
String 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 (當然我就是該庫的作者)。它的功能之一就是能夠定義可以在方法原來的程式碼之前和之後被執行的模板方法。

相關文章