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
文件創建 class
的 defineClass
方法是一個 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
,有可能會出現 LinkageError
的 exception
。所以必須在應用啟動之前,重新替換已經加載的 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
的生命周期,支持運行時重新更新一個 class
,hotswap
就會成為 Java
的一個閃亮新特性