談談Java Intrumentation和相關應用

本文轉自http://www.fanyilun.me/2017/07/18/%E8%B0%88%E8%B0%88Java%20Intrumentation%E5%92%8C%E7%9B%B8%E5%85%B3%E5%BA%94%E7%94%A8/

1 Overview

對于Java 程序員來說,Java Intrumentation、Java agent這些技術可能平時接觸的很少,聽上去陌生但又好像在哪里見到過。實際上,我們日常應用的各種工具中,有很多都是基于他們實現的,例如常見的熱部署(JRebel, spring-loaded)、各種線上診斷工具(btrace, Greys)、代碼覆蓋率工具(JaCoCo)等等。
??本文會介紹 Java Instrumentation及其相關概念,會涉及到的名詞包括

  • Java Intrumentation API
  • Java agent
  • Attach API
  • JVMTI
  • ……
    簡單的來看,如果需要通過Intrumentation操作或監控一個Java程序,相關的工具和流程如下:


    agent_related_tools.jpg

    下文會依次介紹圖中的相關概念,并談談原理和具體的應用場景。

2 Java Instrumentation

Instrumentation是Java提供的一個來自JVM的接口,該接口提供了一系列查看和操作Java類定義的方法,例如修改類的字節碼、向classLoader的classpath下加入jar文件等。使得開發者可以通過Java語言來操作和監控JVM內部的一些狀態,進而實現Java程序的監控分析,甚至實現一些特殊功能(如AOP、熱部署)。
??Instrumentation的一些主要方法如下:

public interface Instrumentation {
    /**
     * 注冊一個Transformer,從此之后的類加載都會被Transformer攔截。
     * Transformer可以直接對類的字節碼byte[]進行修改
     */
    void addTransformer(ClassFileTransformer transformer);
    
    /**
     * 對JVM已經加載的類重新觸發類加載。使用的就是上面注冊的Transformer。
     * retransformation可以修改方法體,但是不能變更方法簽名、增加和刪除方法/類的成員屬性
     */
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
    
    /**
     * 獲取一個對象的大小
     */
    long getObjectSize(Object objectToSize);
    
    /**
     * 將一個jar加入到bootstrap classloader的 classpath里
     */
    void appendToBootstrapClassLoaderSearch(JarFile jarfile);
    
    /**
     * 獲取當前被JVM加載的所有類對象
     */
    Class[] getAllLoadedClasses();
}

其中最常用的方法就是addTransformer(ClassFileTransformer transformer)了,這個方法可以在類加載時做攔截,對輸入的類的字節碼進行修改,其參數是一個ClassFileTransformer接口,定義如下:

/**
 * 傳入參數表示一個即將被加載的類,包括了classloader,classname和字節碼byte[]
 * 返回值為需要被修改后的字節碼byte[]
 */
byte[]
transform(  ClassLoader         loader,
            String              className,
            Class<?>            classBeingRedefined,
            ProtectionDomain    protectionDomain,
            byte[]              classfileBuffer)  throws IllegalClassFormatException;

addTransformer方法配置之后,后續的類加載都會被Transformer攔截。對于已經加載過的類,可以執行retransformClasses來重新觸發這個Transformer的攔截。類加載的字節碼被修改后,除非再次被retransform,否則不會恢復。

主流的JVM都提供了Instrumentation的實現,但是鑒于Instrumentation的特殊功能,并不適合直接提供在JDK的runtime里,而更適合出現在Java程序的外層,以上帝視角在合適的時機出現。因此如果想使用Instrumentation功能,拿到Instrumentation實例,我們必須通過Java agent

3 Java agent

Java agent是一種特殊的Java程序(Jar文件),它是Instrumentation的客戶端。與普通Java程序通過main方法啟動不同,agent并不是一個可以單獨啟動的程序,而必須依附在一個Java應用程序(JVM)上,與它運行在同一個進程中,通過Instrumentation API與虛擬機交互。

Java agent與Instrumentation密不可分,二者也需要在一起使用。因為Instrumentation的實例會作為參數注入到Java agent的啟動方法中。

3.1 Java agent 的格式

Java agent以jar包的形式部署在JVM中,jar文件的manifest需要指定agent的類名。根據不同的啟動時機,agent類需要實現不同的方法(二選一)。

/**
 * 以vm參數的形式載入,在程序main方法執行之前執行
 * 其jar包的manifest需要配置屬性Premain-Class
 */
public static void premain(String agentArgs, Instrumentation inst);
/**
 * 以Attach的方式載入,在Java程序啟動后執行
 * 其jar包的manifest需要配置屬性Agent-Class
 */
public static void agentmain(String agentArgs, Instrumentation inst);

因此,如果想自己寫一個java agent程序,只需定義一個包含premain或者agentmain的類,在方法中實現你的邏輯,然后在打包jar時配置一下manifest即可。可以參考如下的maven plugin配置:

<plugin>
    <artifactId>maven-assembly-plugin</artifactId>
    <configuration>
        <archive>
            <manifestEntries>
                <Premain-Class>**.**.InstrumentTest</Premain-Class>
                <Agent-Class>**.**..InstrumentTest</Agent-Class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

3.2 Java agent 的加載

一個Java agent既可以在VM啟動時加載,也可以在VM啟動后加載:

  • 啟動時加載:通過vm的啟動參數-javaagent:**.jar來啟動
  • 啟動后加載:在vm啟動后的任何時間點,通過attach api,動態地啟動agent
    如何通過attach api動態加載agent,請見下一小節

agent加載時,Java agent的jar包先會被加入到system class path中,然后agent的類會被system class loader加載。沒錯,這個system class loader就是所在的Java程序的class loader,這樣agent就可以很容易的獲取到想要的class。

對于VM啟動時加載的Java agent,其premain方法會在程序main方法執行之前被調用,此時大部分Java類都沒有被加載(“大部分”是因為,agent類本身和它依賴的類還是無法避免的會先加載的),是一個對類加載埋點做手腳(addTransformer)的好機會。如果此時premain方法執行失敗或拋出異常,那么JVM的啟動會被終止。

對于VM啟動后加載的Java agent,其agentmain方法會在加載之時立即執行。如果agentmain執行失敗或拋出異常,JVM會忽略掉錯誤,不會影響到正在running的Java程序。

3.3 舉個例子

一個最簡單的Java agent程序如下,該程序通過-javaagent參數附著在目標程序上啟動,實現了在類加載時做攔截,修改字節碼的功能。

public class InstrumentationExample {
    // Java agent指定的premain方法,會在main方法之前被調用
    public static void premain(String args, Instrumentation inst) {
        // Instrumentation提供的addTransformer方法,在類加載時會回調ClassFileTransformer接口
        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                                    ProtectionDomain protectionDomain, byte[] classfileBuffer)
                                    throws IllegalClassFormatException {
                // 開發者在此自定義做字節碼操作,將傳入的字節碼修改后返回
                // 通常這里需要字節碼操作框架
                // ......
                return transformResult;
            }
        });
    }
    
}

以上面的代碼文件,根據前一小節的要求打好jar包,就可以跟隨宿主Java應用一起啟動了。從執行的流程上來看,效果如下圖所示:


agent_start.PNG

可以看出,通過Java agent我們可以注冊類加載的回調方法,來實現通用的類加載攔截。

不過上述代碼并沒有給出transform方法的具體實現,我們舉一個具體場景細化一下這個方法的實現:例如,我想要監聽某個類,并對這個類的每個方法都做一層AOP,打印出方法調用的耗時。那么使用Instrumentation的解決方式,就是修改這個類的字節碼,對每個方法作如下改動:

// 原方法
public void method1(){
    dosomething();
}
    ↓ ↓ ↓ ↓ ↓
// 修改后的方法
public void method1(){
    long stime = System.currentTimeMillis();
    dosomething();
    System.out.println("method1 cost:" + (System.currentTimeMillis() - stime) + " ms");
}

要想實現這種效果,我們需要在transform方法的實現中,對指定的類,做指定的字節碼增強。通常來說,做字節碼增強都需要使用到框架,比如ASM,CGLIB,Byte Buddy,Javassist。不過如果你喜歡,你可以直接用位運算操作byte[],不需要任何框架,例如JDK反射(method.invoke())的實現,就真的是用位操作拼裝了一個類。
??言歸正傳,操作字節碼的高手可能更喜歡ASM,因為它提供的方法更底層,功能更強大更直白。對于字節碼不熟悉的開發者,更適合javassist,它可以直接以Java代碼方式直接修改方法體。我們以javassist為例,看看怎么實現上述的功能,完整代碼如下:

public class InstrumentationExample {
    // Java agent指定的premain方法,會在main方法之前被調用
    public static void premain(String args, Instrumentation inst) {
        // Instrumentation提供的addTransformer方法,在類加載時會回調ClassFileTransformer接口
        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                                    ProtectionDomain protectionDomain, byte[] classfileBuffer)
                                    throws IllegalClassFormatException {
                if (!"com/test/TestClass".equals(className)) {
                    // 只修改指定的Class
                    return classfileBuffer;
                }
        
                byte[] transformed = null;
                CtClass cl = null;
                try {
                    // CtClass、ClassPool、CtMethod、ExprEditor都是javassist提供的字節碼操作的類
                    ClassPool pool = ClassPool.getDefault();
                    cl = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
                    CtMethod[] methods = cl.getDeclaredMethods();
                    for (int i = 0; i < methods.length; i++) {
                        methods[i].instrument(new ExprEditor() {
        
                            @Override
                            public void edit(MethodCall m) throws CannotCompileException {
                                // 把方法體直接替換掉,其中 $proceed($$);是javassist的語法,用來表示原方法體的調用
                                m.replace("{ long stime = System.currentTimeMillis();" + " $_ = $proceed($$);"
                                          + "System.out.println(\"" + m.getClassName() + "." + m.getMethodName()
                                          + " cost:\" + (System.currentTimeMillis() - stime) + \" ms\"); }");
                            }
                        });
                    }
                    // javassist會把輸入的Java代碼再編譯成字節碼byte[]
                    transformed = cl.toBytecode();
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    if (cl != null) {
                        cl.detach();// ClassPool默認不會回收,需要手動清理
                    }                           
                }
                return transformed;
            }
        });
    }
    
}

4 Attach API

上面提到,Java agent可以在JVM啟動后再加載,就是通過Attach API實現的。當然,Attach API可不僅僅是為了實現動態加載agent,Attach API其實是跨JVM進程通訊的工具,能夠將某種指令從一個JVM進程發送給另一個JVM進程。
??加載agent只是Attach API發送的各種指令中的一種, 諸如jstack打印線程棧、jps列出Java進程、jmap做內存dump等功能,都屬于Attach API可以發送的指令。

4.1 Attach API 用法

由于是進程間通訊,那代表著使用Attach API的程序需要是一個獨立的Java程序,通過attach目標進程,與其進行通訊。下面的代碼表示了向進程pid為1234的JVM發起通訊,加載一個名為agent.jar的Java agent。

// VirtualMachine等相關Class位于JDK的tools.jar
VirtualMachine vm = VirtualMachine.attach("1234");  // 1234表示目標JVM進程pid
try {
    vm.loadAgent(".../agent.jar");    // 指定agent的jar包路徑,發送給目標進程
} finally {
    vm.detach();
}

vm.loadAgent之后,相應的agent就會被目標JVM進程加載,并執行agentmain方法。

4.2 Attach API 原理

按慣例,以Hotspot虛擬機,Linux系統為例。當external process執行VirtualMachine.attach時,需要通過操作系統提供的進程通信方法,例如信號、socket,進行握手和通信。其具體內部實現流程如下所示:

external process(attach發起的進程) target VM(目標JVM進程,假設pid為XXX)
1. 創建文件:.attach_pidXXX
2. 檢查.java_pidXXX 文件是否存在,如果存在則跳過4
3. 向目標JVM發送SIGQUIT信號 →
4. 輪詢等待.java_pidXXX 文件的創建(5秒超時) 1. JVM的Signal Dispatcher線程收到SIGQUIT信號
4. 輪詢等待 ………… 2. 檢查.attach_pidXXX 文件是否存在,若不存在則繼續,否則忽略信號
4. 輪詢等待 ………… 2. 創建一個新線程Attach Listener,專門負責接收各種attach請求指令
4. 輪詢等待 ………… 3. 創建.java_pidXXX文件
4. 輪詢等待 ………… 4. 開始監聽socket(. java_pidXXX)
5. 嘗試連接socket (.java_pidXXX )

上面提到了兩個文件:

  • attach_pidXXX 后面的XXX代表pid,例如pid為1234則文件名為.attach_pid1234。該文件目的是給目標JVM一個標記,表示觸發SIGQUIT信號的是attach請求。這樣目標JVM才可以把SIGQUIT信號當做attach連接請求,再來做初始化。其默認全路徑為/proc/XXX/cwd/.attach_pidXXX,若創建失敗則使用/tmp/attach_pidXXX

  • java_pidXXX 后面的XXX代表pid,例如pid為1234則文件名為.java_pid1234。由于Unix domain socket通訊是基于文件的,該文件就是表示external process與target VM進行socket通信所使用的文件,如果存在說明目標JVM已經做好連接準備。其默認全路徑為/proc/XXX/cwd/.java_pidXXX,若創建失敗則使用/tmp/java_pidXXX
    ??VirtualMachine.attach動作類似TCP創建連接的三次握手,目的就是搭建attach通信的連接。而后面執行的操作,例如vm.loadAgent,其實就是向這個socket寫入數據流,接收方target VM會針對不同的傳入數據來做不同的處理。

5 JVM Tool Interface(JVMTI)

JVM Tool Interface(JVMTI)是JVM提供的native編程接口,開發者可以通過JVMTI向JVM監控狀態、執行指令,其目的是開放出一套JVM接口用于 profile、debug、監控、線程分析、代碼覆蓋分析等工具。
??JVMTI和Instumentation API的作用很相似,都是一套 JVM操作和監控的接口,且都需要通過agent來啟動

  • Instumentation API需要打包成jar,并通過Java agent加載(-javaagent)
  • JVMTI需要打包成動態鏈接庫(隨操作系統,如.dll/.so文件),并通過JVMTI agent加載(-agentlib/-agentpath)
  • 既然都是agent,那么加載時機也同樣有兩種:啟動時(Agent_OnLoad)和運行時Attach(Agent_OnAttach)。
    ??不過相比于Instumentation API,JVMTI的功能強大的多,不知道高到哪里去了。它是實現Java調試器,以及其它Java運行態測試與分析工具的基礎。JVMTI能做的事情包括:
  • 獲取所有線程、查看線程狀態、線程調用棧、查看線程組、中斷線程、查看線程持有和等待的鎖、獲取線程的CPU時間、甚至將一個運行中的方法強制返回值……
  • 獲取Class、Method、Field的各種信息,類的詳細信息、方法體的字節碼和行號、向Bootstrap/System Class Loader添加jar、修改System Property……
  • 堆內存的遍歷和對象獲取、獲取局部變量的值、監測成員變量的值……
  • 各種事件的callback函數,事件包括:類文件加載、異常產生與捕獲、線程啟動和結束、進入和退出臨界區、成員變量修改、gc開始和結束、方法調用進入和退出、臨界區競爭與等待、VM啟動與退出……
  • 設置與取消斷點、監聽斷點進入事件、單步執行事件……

前面說的Instumentation API也是基于JVMTI來實現的,具體以addTransformer來說,通過Instrumentation注冊的ClassFileTransformer,實際上是注冊了JVMTI針對類文件加載事件(ClassFileLoadHook)的callback函數。這個callback函數長這個樣子:

void JNICALL
ClassFileLoadHook(jvmtiEnv *jvmti_env,
            JNIEnv* jni_env,
            jclass class_being_redefined,
            jobject loader,
            const char* name,
            jobject protection_domain,
            jint class_data_len,
            const unsigned char* class_data,
            jint* new_class_data_len,
            unsigned char** new_class_data)

注意到參數class_data和new_class_data分別對應了讀入的原字節碼數組,和提供的修改后的字節碼數組的指針。這樣,我們在方法的實現中就可以把修改后的類的字節碼寫回,實現 bytecode instrumentation。
??InstumentationImpl的實現中,在這個callback函數里,對ClassFileTransformer的transform方法再進行一次回調。這樣的一次封裝,就做到了通過Java語言實現字節碼攔截修改的能力。

6 相關技術的實際應用

6.1 btrace等診斷工具

6.1.1 btrace

btrace是一個安全的,動態追蹤Java程序的工具。btrace可以跟蹤到一個運行中的Java程序,監控到類和方法級別的狀態信息。由于其api的限制,對目標程序源碼無侵入性,不會影響到程序原有邏輯。
??btrace的使用方式和內部原理如下圖,使用者首先需要準備一份btrace腳本(btrace script),用來定義使用者想要追蹤的位置和信息。接下來啟動btrace client,啟動參數包括目標JVM的pid用于attach、以及寫好的btrace腳本文件。目標JVM會通過attach(或者啟動時參數指定-javaagent)加載上Java agent,并通過socket與brace client建立連接。btrace腳本會被編譯成字節碼然后發送給目標JVM的agent,通過解析其語義,轉換為對程序源碼的改寫,此處也是基于Instrumentation api完成的。

btrace.PNG

一份btrace腳本示例如下(來自官方文檔),這份腳本會跟蹤到javax.swing.*包下的所有class下的所有method,并在進入方法體時通過標準輸出打印出類名和方法名。

package samples;
import com.sun.btrace.annotations.*;
import static com.sun.btrace.BTraceUtils.*;
/**
 * This script traces method entry into every method of 
 * every class in javax.swing package! Think before using 
 * this script -- this will slow down your app significantly!!
 */
@BTrace public class AllMethods {
    @OnMethod(
        clazz="/javax\\.swing\\..*/",
        method="/.*/"
    )
    public static void m(@ProbeClassName String probeClass, @ProbeMethodName String probeMethod) {
        print(Strings.strcat("entered ", probeClass));
        println(Strings.strcat(".", probeMethod));
    }
}

這份例子僅僅是一個簡單的例子,btrace追蹤點的時機(對應例子里的@OnMethod)可以有很多,包括方法體進入/退出、方法調用與返回、行號、異常拋出、臨界區進入和退出等等,追蹤的內容(對應例子里的@ProbeClassName、@ProbeMethodName)除了提到的類名和方法名,還有對象的實例、入參和返回值、方法耗時等都可以作為參數注入到腳本方法的入參中。看得出,btrace腳本的語法強大且復雜,但是為了安全(不能修改程序自身邏輯)做了諸多的限制,例如不能新建對象、不能調用實例方法以及靜態方法(BTraceUtils等特有方法除外)、不能使用循環、不能拋出和捕獲異常等等。

6.1.2 Greys

從功能設計的角度上看,btrace在保證“安全”的前提下給予了用戶盡可能多的功能,這也因此導致了其api和使用起來的復雜性。在實際生產環境的實踐中,我更傾向于使用簡單易用的工具,畢竟一些常用的功能基本可以覆蓋絕大多數使用場景。例如Greys也是一個Java程序診斷工具(阿里內部叫Arthas,對其做了二次開發)其原理與btrace類似,區別在于用戶不需要編寫btrace腳本,直接通過命令行指令交互。因此它更像一個產品而不僅僅是工具,它提供了包括方法的出入參監控、類加載信息查看、調用堆棧查看、方法調用軌跡和耗時查看的功能。在實際線上問題診斷中,尤其是在無法debug的環境中定位問題,還是非常實用的。

舉個例子,Greys可以以下面這種使用方式來監控某個方法的調用軌跡和內部耗時,參數包括了監控的類名表達式、方法名、追蹤的路徑表達式等。

ga?>ptrace -t *alibaba*Test printAddress --path=*alibaba*
Press Ctrl+D to abort.
Affect(class-cnt:10 , method-cnt:36) cost in 148 ms.
`---+pTracing for : thread_name="agent-test-address-printer" thread_id=0xb;is_daemon=false;priority=5;process=1004;
`---+[2,2ms]com.alibaba.AgentTest:printAddress(); index=1021;
    +---+[1,1ms]com.alibaba.manager.DefaultAddressManager:newAddress(); index=1014;
    |   +---[1,1ms]com.alibaba.CountObject:<init>(); index=1012;
    |   `---[1,0ms]com.alibaba.Address:<init>(); index=1013;
    +---+[2,1ms]com.alibaba.manager.DefaultAddressManager:toString(); index=1020;
    |   +---+[2,1ms]com.alibaba.manager.DefaultAddressManager:toStringPass1(); index=1019;
    |   |   +---+[2,1ms]com.alibaba.manager.DefaultAddressManager:toStringPass2(); index=1017;
    |   |   |   +---[1,0ms]com.alibaba.Address:getAddressId(); index=1015;
    |   |   |   +---+[1,0ms]com.alibaba.manager.DefaultAddressManager:throwRuntimeException(); index=1016;
    |   |   |   |   `---[1,0ms]throw:java.lang.RuntimeException
    |   |   |   `---[1,0ms]throw:java.lang.RuntimeException
    |   |   +---[2,0ms]com.alibaba.AddressException:<init>(); index=1018;
    |   |   `---[2,0ms]throw:com.alibaba.AddressException
    |   `---[2,0ms]throw:com.alibaba.AddressException
    `---[2,0ms]throw:com.alibaba.AddressException

從Greys的原理來看,除了去掉了btrace腳本和Java Complier的部分以外,和btrace基本一樣,畢竟都是Instrumentation的實際應用。在一些細節上,例如類加載的隔離還是值得研究學習的,可以直接從開源項目里拉到源碼來看

6.2 熱部署

6.2.1 IDE提供的HotSwap

使用eclipse或IntelliJ IDEA通過debug模式啟動時,默認會開啟一項HotSwap功能。用戶可以在IDE里修改代碼時,直接替換到目標程序的類里。不過這個功能只允許修改方法體,而不允許對方法進行增刪改。

該功能的實現與debug有關。debug其實也是通過JVMTI agent來實現的,JVITI agent會在debug連接時加載到debugee的JVM中。debuger(IDE)通過JDI(Java debug interface)與debugee(目標Java程序)通過進程通訊來設置斷點、獲取調試信息。除了這些debug的功能之外,JDI還有一項redefineClass的方法,可以直接修改一個類的字節碼。沒錯,它其實就是暴露了JVMTI的bytecode instrument功能,而IDE作為debugger,也順帶實現了這種HotSwap功能。
??原理示意圖如下,順帶著也把Java debug的原理也畫了出來,畢竟知識都是相通的:)


Debug.PNG

由于是直接使用的JVM的原生的功能,其效果當然也一樣:只能修改方法體,否則會彈出警告。例如eclipse會彈出””Hot Code Replace Failed”。不過優點在于簡單實用,無需安裝。
??對了,如果你經常在生產環境debug的話,請在debug連接時不要修改本地代碼,因為如果你只修改了方法體,那么你的本地代碼修改能夠直接被hotswap到線上去 :)

6.2.2 Tomcat的自動reload

Tomcat在配置Context(對應一個web應用,一個host下可以有多個context)時,有一個屬性reloadable,當設置為true時,會監聽其classpath下的類文件變動情況,當它有變動時,會自動重啟所在的web應用(context)。
??這里的重啟,會先停止掉當前的Context,然后重新解析一遍xml,重新創建Webappclassloader,重新加載類。Tomcat的類加載機制分配給每個Context一個獨立的類加載器,這樣一來類的重新加載就成為了可能————直接使用新的類加載器重新加載一遍,避免了同一個類加載器不能重復加載一個類的限制。
??把Tomcat的reload機制分類到熱部署里的確有些牽強,我認為應該算作增量部署吧。不過這也算是熱部署的實現思路之一,通過新的classloader重新全部加載一遍。缺陷也很明顯:程序的狀態可能丟失,耗時可能很長,而且如果應用只配置了一個Context那就和重啟整個Tomcat沒有太大差別了。

6.2.3 JRebel,spring-loaded,hotcode2等熱部署工具

說到熱部署,這些工具應該算得上最適合使用的了,這些熱部署工具“突破”了只能修改方法體的JVM客觀限制,實現了很多額外的功能例如增刪改方法簽名、增刪改成員變量等等,盡最大可能讓代碼能夠自由自在的熱部署。目前了解到比較常見的有以下幾種:

  • JRebel:目前最常用的熱部署工具,是一款收費的商業軟件,因此在穩定性和兼容性上做的都比較好。
  • Spring-Loaded:Spring旗下的子項目,也是一款開源的熱部署工具。
  • Hotcode2:阿里內部開發和使用的熱部署工具,功能和上面基本一樣,同時針對各種框架做了很多適配。

這類熱部署工具的原理驚人的相似:首先都是通過Java agent,使用Instumentation API來修改已加載的類。既然Instumentation只能修改方法體,為什么這些工具突破了這個限制呢?實際上,這些工具在每個method call和field access的地方都做了一層代理,對于每次修改類,并不是直接retransformClasses,而是直接加載一個全新的類,由于方法調用和成員變量讀寫都被動態代理過,新修改的類自然能夠成功“篡位”了。

舉一個JRebel的簡化版的例子,假設一個類一開始長這樣:

public class C extends X {
 int y = 5;
 int method1(int x) {
   return x + y;
 }
 void method2(String s) {
   System.out.println(s);
 }
}

那么這個類在加載時,就會被JRebel的agent轉換掉:每個方法的方法體都變成了代理,其內容變成了調用某個具體實現類的同名方法。

public class C extends X {
 int y = 5;
 int method1(int x) {// 什么也不做,只把參數和方法名傳遞給名叫Runtime的代理
   Object[] o = new Object[1];
   o[0] = x;
   return Runtime.redirect(this, o, "C", "method1", "(I)I");
 }
 void method2(String s) {
   Object[] o = new Object[1];
   o[0] = s;
   return Runtime.redirect(this, o, "C", "method2", "(Ljava/lang/String;)V");
 }
}

原代碼的實現邏輯當然也不會丟掉,而是通過加載一個名叫C0的新類作為實現類。剛才通過Runtime.redirect的調用,會被路由到這個實現類的對應方法里。如果此時用戶再次更新了類C的代碼,那么會再重新加載一個C1類,然后C2,C3,C4,C5…

public abstract class C0 {
 public static int method1(C c, int x) {
   int tmp1 = Runtime.getFieldValue(c, "C", "y", "I");
   return x + tmp1;
 }
 public static void method2(C c, String s) {
   PrintStream tmp1 =
     Runtime.getFieldValue(
       null, "java/lang/System", "out", "Ljava/io/PrintStream;");
   Object[] o = new Object[1];
   o[0] = s;
   Runtime.redirect(tmp1, o, "java/io/PrintStream;", "println","(Ljava/lang/String;)V");
 }
}

通過這種方式,就可以在JVM既定的限制下,完成更自由的熱部署。當然這種熱部署行為,是需要做很多細節的兼容的,例如反射的各個方法都要做一些特殊的兼容處理,還有異常棧的獲取不能真的把這些代理類透傳出去……另外,由于很多類的行為是通過框架初始化的時候進行的,這些熱部署工具還要對一些框架深度加工,來完成xml和注解的自動初始化,比如spring的配置xml、mybatis的sqlmap等。

6.2.4 Dynamic Code Evolution VM (DCE VM)

DCEVM是一款基于Java HotSpot(TM) VM修改的JVM,其目的就是允許對加載過的類無限制的修改(redefinition)。從技術的角度來講,通過VM的修改實現熱部署是最合理也是性能最好的方案。不過由于使用成本比較高,加之這個項目的推廣程度不高,這種熱部署方案并不常見。

7 參考資料

https://docs.oracle.com/javase/7/docs/api/java/lang/instrument/package-summary.html
https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html
http://www.infoq.com/cn/articles/javaagent-illustrated
http://lovestblog.cn/blog/2014/06/18/jvm-attach/
http://www.lxweimin.com/p/b034f5bb6283
http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/6-b27/sun/tools/attach/LinuxVirtualMachine.java
https://zeroturnaround.com/rebellabs/why-hotswap-wasnt-good-enough-in-2001-and-still-isnt-today/

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372

推薦閱讀更多精彩內容