深入探索 Java 熱部署
在 Java 開發領域,熱部署一直是一個難以解決的問題,目前的 Java 虛擬機器只能實現方法體的修改熱部署,對於整個類的結構修改,仍然需要重啟虛擬機器,對類重新載入才能完成更新操作。對於某些大型的應用來說,每次的重啟都需要花費大量的時間成本。雖然 osgi 架構的出現,讓模組重啟成為可能,但是如果模組之間有呼叫關係的話,這樣的操作依然會讓應用出現短暫的功能性休克。本文將探索如何在不破壞 Java 虛擬機器現有行為的前提下,實現某個單一類的熱部署,讓系統無需重啟就完成某個類的更新。
類載入的探索
首先談一下何為熱部署(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。
自定義載入器的實現
自定義載入器仍然需要執行類載入的功能。這裡卻存在一個問題,同一個類載入器無法同時載入兩個相同名稱的類,由於不論類的結構如何發生變化,生成的類名不會變,而 classloader 只能在虛擬機器停止前銷燬已經載入的類,這樣 classloader 就無法載入更新後的類了。這裡有一個小技巧,讓每次載入的類都儲存成一個帶有版本資訊的 class,比如載入 Test.class 時,儲存在記憶體中的類是 Test_v1.class,當類發生改變時,重新載入的類名是 Test_v2.class。但是真正執行載入 class 檔案建立 class 的 defineClass 方法是一個 native 的方法,修改起來又變得很困難。所以面前還剩一條路,那就是直接修改編譯生成的 class 檔案。
利用 ASM 修改 class 檔案
可以修改位元組碼的框架有很多,比如 ASM,CGLIB。本文使用的是 ASM。先來介紹一下 class 檔案的結構,class 檔案包含了以下幾類資訊,一個是類的基本資訊,包含了訪問許可權資訊,類名資訊,父類資訊,介面資訊。第二個是類的變數資訊。第三個是方法的資訊。ASM 會先載入一個 class 檔案,然後嚴格順序讀取類的各項資訊,使用者可以按照自己的意願定義增強元件修改這些資訊,最後輸出成一個新的 class。
首先看一下如何利用 ASM 修改類資訊。
清單 1. 利用 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 個步驟。
第一步,先將原來的類變成介面。
清單 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 的派生類都有一個共同的介面,他們之間的轉換變得對外不透明。
清單 3. 定義一個派生類
// 在 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 檔案是否改變。
改變建立物件的行為
Java 虛擬機器常見的建立物件的方法有兩種,一種是靜態建立,直接 new 一個物件,一種是動態建立,通過反射的方法,建立物件。
由於已經在自定義載入器中更改了原有類的型別,把它從類改成了介面,所以這兩種建立方法都無法成立。我們要做的是將例項化原始類的行為變成例項化派生類。
對於第一種方法,需要做的是將靜態建立,變為通過 classloader 獲取 class,然後動態建立該物件。
清單 4. 替換後的指令集所對應的邏輯
// 原始邏輯 Greeter p = new Greeter(); // 改變後的邏輯 IGreeter p = (IGreeter)MyClassLoader.getInstance(). findClass("com.example.Greeter").newInstance();
這裡又需要用到 ASM 來修改 class 檔案了。查詢到所有 new 物件的語句,替換成通過 classloader 的形式來獲取物件的形式。
清單 5. 利用 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()
的行為,使他們通過自定義載入器載入類。
使用 JavaAgent 攔截預設載入器的行為
之前實現的類載入器已經解決了熱部署所需要的功能,可是 JVM 啟動時,並不會用自定義的載入器載入 classpath 下的所有 class 檔案,取而代之的是通過應用載入器去載入。如果在其之後用自定義載入器重新載入已經載入的 class,有可能會出現 LinkageError 的 exception。所以必須在應用啟動之前,重新替換已經載入的 class。如果在 jdk1.4 之前,能使用的方法只有一種,改變 jdk 中 classloader 的載入行為,使它指向自定義載入器的載入行為。好在 jdk5.0 之後,我們有了另一種侵略性更小的辦法,這就是 JavaAgent 方法,JavaAgent 可以在 JVM 啟動之後,應用啟動之前的短暫間隙,提供空間給使用者做一些特殊行為。比較常見的應用,是利用 JavaAgent 做面向方面的程式設計,在方法間加入監控日誌等。
JavaAgent 的實現很容易,只要在一個類裡面,定義一個 premain 的方法。
清單 6. 一個簡單的 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。
圖 1. 增加執行引數
這裡利用 JavaAgent
替換原始位元組碼,阻止原始位元組碼被 Java 虛擬機器載入。只需要實現 一個 ClassFileTransformer
的介面,利用這個實現類完成 class 替換的功能。
清單 7. 替換 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.getInstance(); 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 的結果吧。
圖 2. Test 執行結果
結束語
解決 hotswap 是個困難的課題,本文解決的僅僅是讓新例項化的物件使用新的邏輯,並不能改變已經例項化物件的行為,如果 JVM 能夠重新設計 class 的生命週期,支援執行時重新更新一個 class,hotswap 就會成為 Java 的一個閃亮新特性。官方的 JVM 一直沒有解決熱部署這個問題,可能也是由於無法完全克服其中的諸多難點,希望未來的 Jdk 能解決這個問題,讓 Java 應用對於更新更友好,避免不斷重啟應用浪費的時間。
相關文章
- Java 熱部署深入探索Java熱部署
- 深入探索 Java 8 Lambda 表示式Java
- 筆記 深入探索Android熱修復技術原理筆記Android
- Java熱部署外掛JRebelJava熱部署
- Promise深入探索Promise
- 一種簡單快捷的 java 熱部署方式Java熱部署
- Java伺服器熱部署的實現原理Java伺服器熱部署
- 深入探索Android熱修復技術原理讀書筆記 —— 熱修復技術介紹Android筆記
- 深入探索Android熱修復技術原理讀書筆記 —— 程式碼熱修復技術Android筆記
- 深入探索Android熱修復技術原理讀書筆記 —— 資源熱修復技術Android筆記
- 使用springloaded進行java應用熱部署SpringJava熱部署
- idea熱部署Idea熱部署
- eclipse 熱部署Eclipse熱部署
- javarebel熱部署 (轉)Java熱部署
- 從Java的類載入機制談起:聊聊Java中如何實現熱部署(熱載入)Java熱部署
- Java系列 | 遠端熱部署在美團的落地實踐Java熱部署
- springboot熱部署Spring Boot熱部署
- Spring Boot :熱部署Spring Boot熱部署
- 原型和原型鏈的深入探索原型
- Git探祕:深入探索(2/2)Git
- 深入探索LangChain的高階功能LangChain
- SpringBoot熱部署加持Spring Boot熱部署
- 一、SpringBoot熱部署Spring Boot熱部署
- Linux深入探索04-Bash shellLinux
- 深入探索Chrome開發者工具:開發者的利器Chrome
- nginx使用熱部署新增新模組Nginx熱部署
- SpringBoot專案熱部署Spring Boot熱部署
- springboot實現熱部署Spring Boot熱部署
- SpringBoot 實現熱部署Spring Boot熱部署
- Spring Boot整合熱部署框架Spring Boot熱部署框架
- idea中實現熱部署Idea熱部署
- Maven遠端Tomcat熱部署MavenTomcat熱部署
- 【Play】熱部署是如何工作的?熱部署
- iOS記憶體深入探索之VM TrackeriOS記憶體
- [深入探索] VueJS Scoped CSS 實現原理VueJSCSS
- Flutter圖片載入優化深入探索Flutter優化
- buffer cache 內部機制深入探索【一】
- Windows Vista安全原理深入探索薦Windows