24、有哪些方法可以在運行時動態生成一個Java類?

目錄

今天我要問你的問題是,有哪些方法可以在運行時動態生成一個 Java 類?

典型回答

考點分析

知識擴展

我們分析一下,動態代碼生成是具體發生在什麼階段呢?

最後一個問題,字節碼操縱技術,除了動態代理,還可以應用在什麼地方?


在開始今天的學習前,我建議你先複習一下專欄第 6 講有關動態代理的內容。作爲 Java 基礎模塊中的內容,考慮到不同基礎的同學以及一個循序漸進的學習過程,我當時並沒有在源碼層面介紹動態代理的實現技術,僅進行了相應的技術比較。但是,有了上一講的類加載的學習基礎後,我想是時候該進行深入分析了。

今天我要問你的問題是,有哪些方法可以在運行時動態生成一個 Java 類?

典型回答

我們可以從常見的 Java 類來源分析,通常的開發過程是,開發者編寫 Java 代碼,調用 javac 編譯成 class 文件,然後通過類加載機制載入 JVM,就成爲應用運行時可以使用的 Java 類了。

從上面過程得到啓發,其中一個直接的方式是從源碼入手,可以利用 Java 程序生成一段源碼,然後保存到文件等,下面就只需要解決編譯問題了。

有一種笨辦法,直接用 ProcessBuilder 之類啓動 javac 進程,並指定上面生成的文件作爲輸入,進行編譯。最後,再利用類加載器,在運行時加載即可。

前面的方法,本質上還是在當前程序進程之外編譯的,那麼還有沒有不這麼 low 的辦法呢?

你可以考慮使用 Java Compiler API,這是 JDK 提供的標準 API,裏面提供了與 javac 對等的編譯器功能,具體請參考java.compiler相關文檔。
 
進一步思考,我們一直圍繞 Java 源碼編譯成爲 JVM 可以理解的字節碼,換句話說,只要是符合 JVM 規範的字節碼,不管它是如何生成的,是不是都可以被 JVM 加載呢?我們能不能直接生成相應的字節碼,然後交給類加載器去加載呢?

當然也可以,不過直接去寫字節碼難度太大,通常我們可以利用 Java 字節碼操縱工具和類庫來實現,比如在專欄第 6 
講中提到的ASM、Javassist、cglib 等。

 

考點分析

雖然曾經被視爲黑魔法,但在當前複雜多變的開發環境中,在運行時動態生成邏輯並不是什麼罕見的場景。重新審視我們談到的動態代理,本質上不就是在特定的時機,去修改已有類型實現,或者創建新的類型。

明白了基本思路後,我還是圍繞類加載機制進行展開,面試過程中面試官很可能從技術原理或實踐的角度考察:

  •   字節碼和類加載到底是怎麼無縫進行轉換的?發生在整個類加載過程的哪一步?
  •   如何利用字節碼操縱技術,實現基本的動態代理邏輯?
  •   除了動態代理,字節碼操縱技術還有那些應用場景?


知識擴展

首先,我們來理解一下,類從字節碼到 Class 對象的轉換,在類加載過程中,這一步是通過下面的方法提供的功能,或者 defineClass 的其他本地對等實現。

protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                     ProtectionDomain protectionDomain)
protected final Class<?> defineClass(String name, java.nio.ByteBuffer b,
                                     ProtectionDomain protectionDomain)

我這裏只選取了最基礎的兩個典型的 defineClass 實現,Java 重載了幾個不同的方法。

可以看出,只要能夠生成出規範的字節碼,不管是作爲 byte 數組的形式,還是放到 ByteBuffer 裏,都可以平滑地完成字節碼到 Java 對象的轉換過程。JDK 提供的 defineClass 方法,最終都是本地代碼實現的。

static native Class<?> defineClass1(ClassLoader loader, String name, byte[] b, int off, int len,
                                    ProtectionDomain pd, String source);

static native Class<?> defineClass2(ClassLoader loader, String name, java.nio.ByteBuffer b,
                                    int off, int len, ProtectionDomain pd,
                                    String source);

更進一步,我們來看看 JDK dynamic proxy 的實現代碼。你會發現,對應邏輯是實現在 ProxyBuilder 這個靜態內部類中,ProxyGenerator 生成字節碼,並以 byte 數組的形式保存,然後通過調用 Unsafe 提供的 defineClass 入口。

byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
        proxyName, interfaces.toArray(EMPTY_CLASS_ARRAY), accessFlags);
try {
    Class<?> pc = UNSAFE.defineClass(proxyName, proxyClassFile,
                                     0, proxyClassFile.length,
                                     loader, null);
    reverseProxyCache.sub(pc).putIfAbsent(loader, Boolean.TRUE);
    return pc;
} catch (ClassFormatError e) {
// 如果出現 ClassFormatError,很可能是輸入參數有問題,比如,ProxyGenerator 有 bug
}

前面理順了二進制的字節碼信息到 Class 對象的轉換過程,似乎我們還沒有分析如何生成自己需要的字節碼,接下來一起來看看相關的字節碼操縱邏輯。

JDK 內部動態代理的邏輯,可以參考java.lang.reflect.ProxyGenerator的內部實現。我覺得可以認爲這是種另類的字節碼操縱技術,其利用了DataOutputStrem提供的能力,配合 hard-coded 的各種 JVM 指令實現方法,生成所需的字節碼數組。你可以參考下面的示例代碼。

private void codeLocalLoadStore(int lvar, int opcode, int opcode_0,
                                DataOutputStream out) throws IOException{
    assert lvar >= 0 && lvar <= 0xFFFF;
    
    // 根據變量數值,以不同格式,dump 操作碼
    if (lvar <= 3) {
        out.writeByte(opcode_0 + lvar);
    } else if (lvar <= 0xFF) {
        out.writeByte(opcode);
        out.writeByte(lvar & 0xFF);
    } else {
        // 使用寬指令修飾符,如果變量索引不能用無符號 byte
        out.writeByte(opc_wide);
        out.writeByte(opcode);
        out.writeShort(lvar & 0xFFFF);
    }
}

這種實現方式的好處是沒有太多依賴關係,簡單實用,但是前提是你需要懂各種JVM 指令,知道怎麼處理那些偏移地址等,實際門檻非常高,所以並不適合大多數的普通開發場景。

幸好,Java 社區專家提供了各種從底層到更高抽象水平的字節碼操作類庫,我們不需要什麼都自己從頭做。JDK 內部就集成了 ASM 類庫,雖然並未作爲公共 API 暴露出來,但是它廣泛應用在,如java.lang.instrumentation API 底層實現,或者Lambda Call Site生成的內部邏輯中,這些代碼的實現我就不在這裏展開了,如果你確實有興趣或有需要,可以參考類似 LamdaForm 的字節碼生成邏輯:java.lang.invoke.InvokerBytecodeGenerator。

從相對實用的角度思考一下,實現一個簡單的動態代理,都要做什麼?如何使用字節碼操縱技術,走通這個過程呢?

對於一個普通的 Java 動態代理,其實現過程可以簡化成爲:

  •   提供一個基礎的接口,作爲被調用類型(com.mycorp.HelloImpl)和代理類之間的統一入口,如 com.mycorp.Hello。
  •   實現InvocationHandler,對代理對象方法的調用,會被分派到其 invoke 方法來真正實現動作。
  •   通過 Proxy 類,調用其 newProxyInstance 方法,生成一個實現了相應基礎接口的代理類實例,可以看下面的方法簽名。
public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h)

 

我們分析一下,動態代碼生成是具體發生在什麼階段呢?

不錯,就是在 newProxyInstance 生成代理類實例的時候。我選取了 JDK 自己採用的 ASM 作爲示例,一起來看看用 ASM 實現的簡要過程,請參考下面的示例代碼片段。第一步,生成對應的類,其實和我們去寫 Java 代碼很類似,只不過改爲用 ASM 方法和指定參數,代替了我們書寫的源碼。

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

cw.visit(V1_8,                      // 指定 Java 版本
        ACC_PUBLIC,                 // 說明是 public 類型
        "com/mycorp/HelloProxy",    // 指定包和類的名稱
        null,                       // 簽名,null 表示不是泛型
        "java/lang/Object",                 // 指定父類
        new String[]{ "com/mycorp/Hello" }); // 指定需要實現的接口

更進一步,我們可以按照需要爲代理對象實例,生成需要的方法和邏輯。

MethodVisitor mv = cw.visitMethod(
        ACC_PUBLIC,                 // 聲明公共方法
        "sayHello",                 // 方法名稱
        "()Ljava/lang/Object;",     // 描述符
        null,                       // 簽名,null 表示不是泛型
        null);                      // 可能拋出的異常,如果有,則指定字符串數組

mv.visitCode();
// 省略代碼邏輯實現細節
cw.visitEnd();                      // 結束類字節碼生成

上面的代碼雖然有些晦澀,但總體還是能多少理解其用意,不同的 visitX 方法提供了創建類型,創建各種方法等邏輯。ASM API,廣泛的使用了Visitor模式,如果你熟悉這個模式,就會知道它所針對的場景是將算法和對象結構解耦,非常適合字節碼操縱的場合,因爲我們大部分情況都是依賴於特定結構修改或者添加新的方法、變量或者類型等。

按照前面的分析,字節碼操作最後大都應該是生成 byte 數組,ClassWriter 提供了一個簡便的方法。

cw.toByteArray();

然後,就可以進入我們熟知的類加載過程了,我就不再贅述了,如果你對 ASM 的具體用法感興趣,可以參考這個教程。

 

最後一個問題,字節碼操縱技術,除了動態代理,還可以應用在什麼地方?

這個技術似乎離我們日常開發遙遠,但其實已經深入到各個方面,也許很多你現在正在使用的框架、工具就應用該技術,下面是我能想到的幾個常見領域。

  •   各種 Mock 框架
  •   ORM 框架
  •   IOC 容器
  •   部分 Profiler 工具,或者運行時診斷工具等
  •   生成形式化代碼的工具

甚至可以認爲,字節碼操縱技術是工具和基礎框架必不可少的部分,大大減少了開發者的負擔。

今天我們探討了更加深入的類加載和字節碼操作方面技術。爲了理解底層的原理,我選取的例子是比較偏底層的、能力全面的類庫,如果實際項目中需要進行基礎的字節碼操作,可以考慮使用更加高層次視角的類庫,例如Byte現 Buddy 等。

一課一練

關於今天我們討論的題目你做到心中有數了嗎?試想,假如我們有這樣一個需求,需要添加某個功能,例如對某類型資源如網絡通信的消耗進行統計,重點要求是,不開啓時必須是 ** 零開銷,而不是低開銷** 可以利用我們今天談到的或者相關的技術實現嗎?

答:將資源消耗的這個實例,用動態代理的方式創建這個實例動態代理對象,在動態代理的invoke中添加新的需求。開始使用代理對象,不開啓則使用原來的方法,因爲動態代理是在運行時創建。所以是零消耗。

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