1. 程式人生 > >24、有哪些方法可以在執行時動態生成一個Java類?

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中新增新的需求。開始使用代理物件,不開啟則使用原來的方法,因為動態代理是在執行時建立。所以是零消耗。