Java動態編譯和熱更新

clouder發表於2018-12-27

在日常開發過程中,我們經常遇到臨時開發一些額外的功能(需要在Test介面中手動呼叫),每次都必須重新提交程式碼,打包釋出,無疑費時費力。

那麼有什麼方法可以跳過繁瑣的打包過程呢?

答案是有的,Java 從6開始提供了動態編譯API

Java Compiler

Java Compiler API,這是JDK6開始提供的標準API,提供了與javac對等的編譯功能,即動態編譯,文件地址

步驟

  1. 通過 Controller 介面,提交Java程式碼,程式碼統一實現Callable介面;
  2. 動態編譯Java程式碼為位元組碼;
  3. 使用類載入器載入編譯的位元組碼;
  4. 使用反射拿到類的後設資料資訊,執行call方法,完成對應的功能。

程式碼實現

public class ClassGenerator {

    private String classRootDir;

    public ClassGenerator() {
        this(".");
    }

    public ClassGenerator(String classRootDir) {
        this.classRootDir = classRootDir;
    }


    public Class<?> generate(String classFullName, String code, String jarFile) throws MalformedURLException, ClassNotFoundException {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        JavaFileObject fileObject = new JavaSourceFromString(classFullName, code);

        File root = new File(classRootDir);
        if (!root.exists()) {
            root.mkdirs();
        }

        String jars = getJars(jarFile);
        Iterable<String> options = Arrays.asList("-d", classRootDir, "-cp", jars + File.pathSeparator + classRootDir);
        Iterable<? extends JavaFileObject> compilationUnits = Arrays.asList(fileObject);
        JavaCompiler.CompilationTask task = compiler.getTask(null, null, null, options, null, compilationUnits);

        // 動態編譯
        boolean success = task.call();
        if (!success) {
            return null;
        }
        SLogger.info("compile success root path = " + root.getPath());
        URL[] urls = new URL[]{root.toURI().toURL()};
        // 設定父類載入器
        URLClassLoader classLoader = new URLClassLoader(urls, ClassGenerator.class.getClassLoader());
        return Class.forName(classFullName, true, classLoader);
    }

    private String getJars(String jarFile) {
        File file = new File(jarFile);
        if (!file.exists()) {
            return "";
        }

        StringBuilder builder = new StringBuilder();
        for (File jar : Objects.requireNonNull(file.listFiles())) {
            builder.append(jar.getPath()).append(File.pathSeparator);
        }

        return builder.toString();
    }
}
複製程式碼
public class JavaSourceFromString extends SimpleJavaFileObject {

    private String code;

    JavaSourceFromString(String name, String code) {
        super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE);
        this.code = code;
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
        return code;
    }
}
複製程式碼
public class DynamicCompile {

    public static Object compileInvoke(String code, String classFullName) throws Throwable {
        String root = DynamicCompile.class.getClassLoader().getResource("").getPath();
        String jarFile = root.replace("/classes", "/lib");

        ClassGenerator builder = new ClassGenerator(root);
        Class<?> cls = builder.generate(classFullName, code, jarFile);
        Object instance = cls.newInstance();
        if (!(instance instanceof Callable)) {
            throw new RuntimeException("only support Callable");
        }

        MethodType methodType = MethodType.methodType(Object.class);
        MethodHandle methodHandle = MethodHandles.lookup().findVirtual(cls, "call", methodType);
        return methodHandle.invoke(instance);
    }
  
}

複製程式碼
@RestController
@RequestMapping("/backend")
public class ExtendController {

    @PostMapping("/extend")
    public JSONResult executeByCode(@RequestParam("code") String code,
                                    @RequestParam("class") String cls) {
        if (StringUtils.isBlank(code)) {
            return JSONResult.paramErrorResult("code is null");
        }
        if (StringUtils.isBlank(cls)) {
            return JSONResult.paramErrorResult("class is null");
        }
        try {
            Object invoke = DynamicCompile.compileInvoke(code, cls);
            return JSONResult.okResult(invoke);
        } catch (Exception e) {
            SLogger.error(e.getMessage(), e);
            return JSONResult.failureResult(e.getMessage());
        }
    }

    /**
     * Java 檔案提交
     * @param java
     * @param cls
     * @return
     * @throws IOException
     */
    @PostMapping("/extend/java")
    public JSONResult executeByJava(MultipartFile java,
                             @RequestParam("class") String cls) throws IOException {
        if (java == null) {
            return JSONResult.paramErrorResult("java is null");
        }
        if (StringUtils.isBlank(cls)) {
            return JSONResult.paramErrorResult("class is null");
        }
        byte[] bytes = java.getBytes();
        String code = new String(bytes);
        try {
            Object invoke = DynamicCompile.compileInvoke(code, cls);
            return JSONResult.okResult(invoke);
        } catch (Exception e) {
            SLogger.error(e.getMessage(), e);
            return JSONResult.failureResult(e.getMessage());
        }
    }

}
複製程式碼

熱更新

這樣基本上可以實現我們的需求了,但是如果想要實現通過替換class檔案來動態修復線上Bug,會發現並沒有生效,執行的還是原來的結果,這是因為 Java 的類載入是存在快取的,程式碼有刪減:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
{
    // 先檢查快取是否載入過
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        // 未載入過委託父類載入
        if (parent != null) {
            c = parent.loadClass(name, false);
        } else {
            c = findBootstrapClassOrNull(name);
        }
        // 載入類資訊
        if (c == null) {
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}
複製程式碼

有什麼辦法可以跳過快取呢?是否可以主動解除安裝類呢?Java 沒有提供對應的API,只能通過自定義類載入器實現。

思路:通過 Map 快取類的最新修改時間,每次載入的時候檢查 class 檔案的修改時間是否和快取一致,不一樣則重新載入。

類生命週期

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段。其中準備、驗證、解析3個部分統稱為連線(Linking)

image

JVM中的Class只有滿足以下三個條件,才能被GC回收,也就是該Class被解除安裝(unload):

  • 該類所有的例項都已經被GC。
  • 載入該類的ClassLoader例項已經被GC。
  • 該類的java.lang.Class物件沒有在任何地方被引用。

類載入機制

Java 類載入機制是“雙親委派”模型,即優先讓父ClassLoader去載入。

Java 預設使用以下三種類載入器:

  1. 啟動類載入器(Bootstrap ClassLoader):這個載入器是Java虛擬機器實現的一部分,不是Java語言實現的,一般是C++實現的,它負責載入Java的基礎類,主要是<JAVA_HOME>/lib/rt.jar,我們日常用的Java類庫比如String、ArrayList等都位於該包內。
  2. 擴充套件類載入器(Extension ClassLoader):這個載入器的實現類是sun.misc.Laun-cher$ExtClassLoader,它負責載入Java的一些擴充套件類,一般是<JAVA_HOME>/lib/ext目錄中的jar包。
  3. 應用程式類載入器(Application ClassLoader):這個載入器的實現類是sun.misc. Launcher$AppClassLoader,它負責載入應用程式的類,包括自己寫的和引入的第三方法類庫,即所有在類路徑中指定的類。

這三個類載入器有一定的關係,可以認為是父子關係,Application ClassLoader的父親是Extension ClassLoader, Extension的父親是Bootstrap ClassLoader。注意不是父子繼承關係,而是父子委派關係,子ClassLoader有一個變數parent指向父ClassLoader,在子Class-Loader載入類時,一般會首先通過父ClassLoader載入,具體來說,在載入一個類時,基本過程是:

  1. 判斷是否已經載入過了,載入過了,直接返回Class物件,一個類只會被一個Class-Loader載入一次。
  2. 如果沒有被載入,先讓父ClassLoader去載入,如果載入成功,返回得到的Class物件。
  3. 在父ClassLoader沒有載入成功的前提下,自己嘗試載入類。

注意,不同類載入器載入同一類的得到的Class物件是不同的,Tomcat 通過 WebappClassLoader 隔離不同的web應用。

需要說明的是,Java 9引入了模組的概念。在模組化系統中,類載入的過程有一些變化,擴充套件類的目錄被刪除掉了,原來的擴充套件類載入器沒有了,增加了一個平臺類載入器(Platform Class Loader),角色類似於擴充套件類載入器,它分擔了一部分啟動類載入器的職責,另外,載入的順序也有一些變化。

總結:

使用動態編譯可以在不打包編譯重啟整個應用的情況下,實現功能的擴充套件,結合自定義類載入器,可以替換已載入過的類,實現線上Bug的修復,不過需要替換Tomcat 預設的類載入器 WebappClassLoader 此外不適合API這樣的多節點,需要其他手段來保證程式碼提交到各個節點

相關文章