Java熱部署技術


1 Java熱部署

1.1 熱部署問題

Java 開發領域,熱部署一直是一個難以解決的問題,目前的 Java 虛擬機只能實現方法體的修改熱部署,對於整個類的結構修改,仍然需要重啟虛擬機,對類重新加載才能完成更新操作。對於某些大型的應用來說,每次的重啟都需要花費大量的時間成本。
雖然 osgi 架構的出現,讓模塊重啟成為可能,但是如果模塊之間有調用關系的話,這樣的操作依然會讓應用出現短暫的功能性休克。本文將探索如何在不破壞 Java 虛擬機現有行為的前提下,實現某個單一類的熱部署,讓系統無需重啟就完成某個類的更新。

1.2 類加載的探索

首先談一下何為熱部署(hotswap),熱部署是在不重啟 Java 虛擬機的前提下,能自動偵測到 class 文件的變化,更新運行時 class 的行為。Java 類是通過 Java 虛擬機加載的,某個類的class 文件在被 classloader 加載后,會生成對應的 Class 對象,之后就可以創建該類的實例。默認的虛擬機行為只會在啟動時加載類,如果后期有一個類需要更新的話,單純替換編譯的 class 文件,Java 虛擬機是不會更新正在運行的 class。如果要實現熱部署,最根本的方式是修改虛擬機的源代碼,改變 classloader 的加載行為,使虛擬機能監聽 class 文件的更新,重新加載 class 文件,這樣的行為破壞性很大,為后續的 JVM 升級埋下了一個大坑。
另一種友好的方法是創建自己的 classloader 來加載需要監聽的 class,這樣就能控制類加載的時機,從而實現熱部署。本文將具體探索如何實現這個方案。首先需要了解一下 Java 虛擬機現有的加載機制。目前的加載機制,稱為雙親委派,系統在使用一個 classloader 來加載類時,會先詢問當前 classloader 的父類是否有能力加載,如果父類無法實現加載操作,才會將任務下放到該 classloader 來加載。這種自上而下的加載方式的好處是,讓每個 classloader 執行自己的加載任務,不會重復加載類。但是這種方式卻使加載順序非常難改變,讓自定義 classloader 搶先加載需要監聽改變的類成為了一個難題。
不過我們可以換一個思路,雖然無法搶先加載該類,但是仍然可以用自定義 classloader 創建一個功能相同的類,讓每次實例化的對象都指向這個新的類。當這個類的 class 文件發生改變的時候,再次創建一個更新的類,之后如果系統再次發出實例化請求,創建的對象講指向這個全新的類。
下面來簡單列舉一下需要做的工作。
創建自定義的 classloader,加載需要監聽改變的類,在 class 文件發生改變的時候,重新加載該類。
改變創建對象的行為,使他們在創建時使用自定義 classloader 加載的 class

1.4 自定義加載器的實現

自定義加載器仍然需要執行類加載的功能。這里卻存在一個問題,同一個類加載器無法同時加載兩個相同名稱的類,由於不論類的結構如何發生變化,生成的類名不會變,而 classloader 只能在虛擬機停止前銷毀已經加載的類,這樣 classloader 就無法加載更新后的類了。這里有一個小技巧,讓每次加載的類都保存成一個帶有版本信息的 class,比如加載 Test.class 時,保存在內存中的類是 Test_v1.class,當類發生改變時,重新加載的類名是 Test_v2.class。但是真正執行加載 class 文件創建 classdefineClass 方法是一個 native 的方法,修改起來又變得很困難。所以面前還剩一條路,那就是直接修改編譯生成的 class 文件。

1.5 利用ASM修改class文件

可 以修改字節碼的框架有很多,比如 ASM,CGLIB。先來介紹一下 class 文件的結構,class 文件包含了以下幾類信息,一個是類的基本信息,包含了訪問權限信息,類名信息,父類信息,接口信息。第二個是類的變量信息。第三個是方法的信息。ASM 會先加載一個 class 文件,然后嚴格順序讀取類的各項信息,用戶可以按照自己的意願定義增強組件修改這些信息,最后輸出成一個新的 class。
首先看一下如何利用 ASM 修改類信息。
利用 ASM 修改字節碼

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassReader cr = null;    
String enhancedClassName = classSource.getEnhancedName();
try {
    cr = new ClassReader(new FileInputStream(
            classSource.getFile()));
} catch (IOException e) {
    e.printStackTrace();
    return null;
}
ClassVisitor cv = new EnhancedModifier(cw,
        className.replace(".", "/"),
        enhancedClassName.replace(".", "/"));
cr.accept(cv, 0);

ASM 修改字節碼文件的流程是一個責任鏈模式,首先使用一個 ClassReader 讀入字節碼,然后利用 ClassVisitor 做個性化的修改,最后利用 ClassWriter 輸出修改后的字節碼。
之前提過,需要將讀取的 class 文件的類名做一些修改,加載成一個全新名字的派生類。這里將之分為了 2 個步驟。
第一步,先將原來的類變成接口。
重定義的原始類

public Class<?> redefineClass(String className){
       ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
       ClassReader cr = null;
       ClassSource cs = classFiles.get(className);
       if(cs==null){
           return null;
       }
       try {
           cr = new ClassReader(new FileInputStream(cs.getFile()));
       } catch (IOException e) {
           e.printStackTrace();
           return null;
       }
       ClassModifier cm = new ClassModifier(cw);
       cr.accept(cm, 0);
       byte[] code = cw.toByteArray();
       return defineClass(className, code, 0, code.length);
}

首先 load 原始類的class 文件,此處定義了一個增強組件 ClassModifier,作用是修改原始類的類型,將它轉換成接口。原始類的所有方法邏輯都會被去掉。
第二步,生成的派生類都實現這個接口,即原始類,並且復制原始類中的所有方法邏輯。之后如果該類需要更新,會生成一個新的派生類,也會實現這個接口。這樣做的目的是不論如何修改,同一個 class 的派生類都有一個共同的接口,他們之間的轉換變得對外不透明。
定義一個派生類

// 在 class 文件發生改變時重新定義這個類
   private Class<?> redefineClass(String className, ClassSource classSource){
       ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
       ClassReader cr = null;
       classSource.update();
       String enhancedClassName = classSource.getEnhancedName();      
       try {
           cr = new ClassReader(
                   new FileInputStream(classSource.getFile()));
       } catch (IOException e) {
           e.printStackTrace();
           return null;
       }
       EnhancedModifier em = new EnhancedModifier(cw, className.replace(".", "/"),
               enhancedClassName.replace(".", "/"));
       ExtendModifier exm = new ExtendModifier(em, className.replace(".", "/"),
               enhancedClassName.replace(".", "/"));
       cr.accept(exm, 0);
       byte[] code = cw.toByteArray();
       classSource.setByteCopy(code);
       Class<?> clazz = defineClass(enhancedClassName, code, 0, code.length);
       classSource.setClassCopy(clazz);
       return clazz;
}

再次 load 原始類的 class 文件,此處定義了兩個增強組件,一個是 EnhancedModifier,這個增強組件的作用是改變原有的類名。第二個增強組件是 ExtendModifier,這個增強組件的作用是改變原有類的父類,讓這個修改后的派生類能夠實現同一個原始類(此時原始類已經轉成接口了)。
自定義 classloader 還有一個作用是監聽會發生改變的 class 文件,classloader 會管理一個定時器,定時依次掃描這些 class 文件是否改變。

1.6 改變創建對象的行為

Java 虛擬機常見的創建對象的方法有兩種,一種是靜態創建,直接 new 一個對象,一種是動態創建,通過反射的方法,創建對象。
由於已經在自定義加載器中更改了原有類的類型,把它從類改成了接口,所以這兩種創建方法都無法成立。我們要做的是將實例化原始類的行為變成實例化派生類。
對於第一種方法,需要做的是將靜態創建,變為通過 classloader 獲取 class,然后動態創建該對象。
替換后的指令集所對應的邏輯

// 原始邏輯  
  Greeter p = new Greeter();
// 改變后的邏輯
  IGreeter p = (IGreeter)MyClassLoader.getInstance().
  findClass("com.example.Greeter").newInstance();

這里又需要用到 ASM 來修改 class 文件了。查找到所有 new 對象的語句,替換成通過 classloader 的形式來獲取對象的形式。
利用 ASM 修改方法體

@Override
public void visitTypeInsn(int opcode, String type) {
    if(opcode==Opcodes.NEW &&
     type.equals(className)){
        List<LocalVariableNode> variables = node.localVariables;
        String compileType = null;
        for(int i=0;i<variables.size();i++){
            LocalVariableNode localVariable = variables.get(i);
            compileType = formType(localVariable.desc);
            if(matchType(compileType)&&!valiableIndexUsed[i]){
                valiableIndexUsed[i] = true;
                break;
            }
        }
    mv.visitMethodInsn(Opcodes.INVOKESTATIC, CLASSLOAD_TYPE,
        "getInstance", "()L"+CLASSLOAD_TYPE+";");
    mv.visitLdcInsn(type.replace("/", "."));
    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, CLASSLOAD_TYPE,
        "findClass", "(Ljava/lang/String;)Ljava/lang/Class;");
    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class",
        "newInstance", "()Ljava/lang/Object;");
    mv.visitTypeInsn(Opcodes.CHECKCAST, compileType);
    flag = true;
    } else {
        mv.visitTypeInsn(opcode, type);
    }
 }

對於第二種創建方法,需要通過修改 Class.forName()ClassLoader.findClass()的行為,使他們通過自定義加載器加載類。

1.7 使用 JavaAgent 攔截默認加載器的行為

之前實現的類加載器已經解決了熱部署所需要的功能,可是 JVM 啟動時,並不會用自定義的加載器加載 classpath 下的所有 class 文件,取而代之的是通過應用加載器去加載。如果在其之后用自定義加載器重新加載已經加載的 class,有可能會出現 LinkageErrorexception。所以必須在應用啟動之前,重新替換已經加載的 class。在 jdk5.0 之后,我們有了另一種侵略性更小的辦法,這就是 JavaAgent 方法,JavaAgent 可以在 JVM 啟動之后,應用啟動之前的短暫間隙,提供空間給用戶做一些特殊行為。比較常見的應用,是利用 JavaAgent 做面向方面的編程,在方法間加入監控日志等。
JavaAgent 的實現很容易,只要在一個類里面,定義一個 premain 的方法。
一個簡單的 JavaAgent

public class ReloadAgent {
   public static void premain(String agentArgs, Instrumentation inst){
       GeneralTransformer trans = new GeneralTransformer();
       inst.addTransformer(trans);
   }
}

然后編寫一個 manifest 文件,將 Premain-Class屬性設置成定義一個擁有 premain方法的類名即可。
生成一個包含這個 manifest 文件的 jar 包。

manifest-Version: 1.0
Premain-Class: com.example.ReloadAgent
Can-Redefine-Classes: true

最后需要在執行應用的參數中增加 -javaagent參數 , 加入這個 jar。同時可以為 Javaagent增加參數,下圖中的參數是測試代碼中 test project 的絕對路徑。這樣在執行應用的之前,會優先執行premain方法中的邏輯,並且預解析需要加載的 class
在這里插入圖片描述
這里利用 JavaAgent替換原始字節碼,阻止原始字節碼被 Java 虛擬機加載。只需要實現 一個 ClassFileTransformer的接口,利用這個實現類完成 class 替換的功能。
替換 class

@Override
public byte [] transform(ClassLoader paramClassLoader, String paramString,
     Class<?> paramClass, ProtectionDomain paramProtectionDomain,
     byte [] paramArrayOfByte) throws IllegalClassFormatException {
    String className = paramString.replace("/", ".");
    if(className.equals("com.example.Test")){
        MyClassLoader cl = MyClassLoader.<em>getInstance</em>();
        cl.defineReference(className, "com.example.Greeter");
        return cl.getByteCode(className);
    }else if(className.equals("com.example.Greeter")){
        MyClassLoader cl = MyClassLoader.getInstance();
        cl.redefineClass(className);
        return cl.getByteCode(className);
    }
    return null;
 }

至此,所有的工作大功告成,欣賞一下 hotswap 的結果吧。
Test 執行結果
在這里插入圖片描述

解決 hotswap 是個困難的課題,本文解決的僅僅是讓新實例化的對象使用新的邏輯,並不能改變已經實例化對象的行為,如果 JVM 能夠重新設計 class 的生命周期,支持運行時重新更新一個 classhotswap 就會成為 Java 的一個閃亮新特性


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM