從Java的類加載機制談起:聊聊Java中如何實現熱部署(熱加載)

一 class的熱替換

ClassLoader中重要的方法

loadClass
ClassLoader.loadClass(...) 是ClassLoader的入口點。當一個類沒有指明用什麼加載器加載的時候,JVM默認採用AppClassLoader加載器加載沒有加載過的class,調用的方法的入口就是loadClass(…)。如果一個class被自定義的ClassLoader加載,那麼JVM也會調用這個自定義的ClassLoader.loadClass(…)方法來加載class內部引用的一些別的class文件。重載這個方法,能實現自定義加載class的方式,拋棄雙親委託機制,但是即使不採用雙親委託機制,比如java.lang包中的相關類還是不能自定義一個同名的類來代替,主要因爲JVM解析、驗證class的時候,會進行相關判斷。

defineClass
系統自帶的ClassLoader,默認加載程序的是AppClassLoader,ClassLoader加載一個class,最終調用的是defineClass(…)方法,這時候就在想是否可以重複調用defineClass(…)方法加載同一個類(或者修改過),最後發現調用多次的話會有相關錯誤:

java.lang.LinkageError 
attempted duplicate class definition

所以一個class被一個ClassLoader實例加載過的話,就不能再被這個ClassLoader實例再次加載(這裏的加載指的是,調用了defileClass(…)放方法,重新加載字節碼、解析、驗證。)。而系統默認的AppClassLoader加載器,他們內部會緩存加載過的class,重新加載的話,就直接取緩存。所與對於熱加載的話,只能重新創建一個ClassLoader,然後再去加載已經被加載過的class文件。

二 class卸載

在Java中class也是可以unload。JVM中class和Meta信息存放在PermGen space區域。如果加載的class文件很多,那麼可能導致PermGen space區域空間溢出。引起:java.lang.OutOfMemoryErrorPermGen space. 對於有些Class我們可能只需要使用一次,就不再需要了,也可能我們修改了class文件,我們需要重新加載 newclass,那麼oldclass就不再需要了。那麼JVM怎麼樣才能卸載Class呢。

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

  • 該類所有的實例都已經被GC。
  • 加載該類的ClassLoader實例已經被GC。
  • 該類的java.lang.Class對象沒有在任何地方被引用。

GC的時機我們是不可控的,那麼同樣的我們對於Class的卸載也是不可控的。

1、有啓動類加載器加載的類型在整個運行期間是不可能被卸載的(jvm和jls規範).
2、被系統類加載器和標準擴展類加載器加載的類型在運行期間不太可能被卸載,因爲系統類加載器實例或者標準擴展類的實例基本上在整個運行期間總能直接或者間接的訪問的到,其達到unreachable的可能性極小.(當然,在虛擬機快退出的時候可以,因爲不管ClassLoader實例或者Class(java.lang.Class)實例也都是在堆中存在,同樣遵循垃圾收集的規則).
3、被開發者自定義的類加載器實例加載的類型只有在很簡單的上下文環境中才能被卸載,而且一般還要藉助於強制調用虛擬機的垃圾收集功能纔可以做到.可以預想,稍微複雜點的應用場景中(尤其很多時候,用戶在開發自定義類加載器實例的時候採用緩存的策略以提高系統性能),被加載的類型在運行期間也是幾乎不太可能被卸載的(至少卸載的時間是不確定的).

綜合以上三點, 一個已經加載的類型被卸載的機率很小至少被卸載的時間是不確定的.同時,我們可以看的出來,開發者在開發代碼時候,不應該對虛擬機的類型卸載做任何假設的前提下來實現系統中的特定功能.

三 Tomcat中關於類的加載與卸載

Tomcat中與其說有熱加載,還不如說是熱部署來的準確些。因爲對於一個應用,其中class文件被修改過,那麼Tomcat會先卸載這個應用(Context),然後重新加載這個應用,其中關鍵就在於自定義ClassLoader的應用。這裏有篇文章很好的介紹了tomcat中對於ClassLoader的應用。

Tomcat啓動的時候,ClassLoader加載的流程:

1 Tomcat啓動的時候,用system classloader即AppClassLoader加載{catalina.home}/bin裏面的jar包,也就是tomcat啓動相關的jar包。 2 Tomcat啓動類Bootstrap中有3個classloader屬性,catalinaLoader、commonLoader、sharedLoader在Tomcat7中默認他們初始化都爲同一個StandardClassLoader實例。具體的也可以在{catalina.home}/bin/bootstrap.jar包中的catalina.properites中進行配置。 3 StandardClassLoader加載{catalina.home}/lib下面的所有Tomcat用到的jar包。 4 一個Context容器,代表了一個app應用。Context–>WebappLoader–>WebClassLoader。並且Thread.contextClassLoader=WebClassLoader。應用程序中的jsp文件、class類、lib/*.jar包,都是WebClassLoader加載的。

當Jsp文件修改的時候,Tomcat更新步驟:

1 但訪問1.jsp的時候,1.jsp的包裝類JspServletWrapper會去比較1.jsp文件最新修改時間和上次的修改時間,以此判斷1.jsp是否修改過。 2 1.jsp修改過的話,那麼jspservletWrapper會清除相關引用,包括1.jsp編譯後的servlet實例和加載這個servlet的JasperLoader實例。 3 重新創建一個JasperLoader實例,重新加載修改過後的1.jsp,重新生成一個Servlet實例。 4 返回修改後的1.jsp內容給用戶。

當app下面的class文件修改的時候,Tomcat更新步驟:

1 Context容器會有專門線程監控app下面的類的修改情況。 2 如果發現有類被修改了。那麼調用Context.reload()。清楚一系列相關的引用和資源。 3 然後創新創建一個WebClassLoader實例,重新加載app下面需要的class。

在一個有一定規模的應用中,如果文件修改多次,重啓多次的話,java.lang.OutOfMemoryErrorPermGen space這個錯誤的的出現非常頻繁。主要就是因爲每次重啓重新加載大量的class,超過了PermGen space設置的大小。兩種情況可能導致PermGen space溢出。 一、GC(Garbage Collection)在主程序運行期對PermGen space沒有進行清理(GC的不可控行), 二、重啓之前WebClassLoader加載的class在別的地方還存在着引用。

原文地址:http://www.blogjava.net/heavensay/archive/2012/11/07/389685.html

在 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。

這裏利用 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 的結果吧。

原文地址:http://www.ibm.com/developerworks/cn/java/j-lo-hotdeploy/

java的熱部署和熱加載

ps:熱部署和熱加載其實是兩個類似但不同的概念,之前理解不深,so,這篇文章重構了下。

一、熱部署與熱加載

在應用運行的時升級軟件,無需重新啓動的方式有兩種,熱部署和熱加載。

對於Java應用程序來說,熱部署就是在服務器運行時重新部署項目,熱加載即在在運行時重新加載class,從而升級應用。

二、實現原理

熱加載的實現原理主要依賴java的類加載機制,在實現方式可以概括爲在容器啓動的時候起一條後臺線程,定時的檢測類文件的時間戳變化,如果類的時間戳變掉了,則將類重新載入。

對比反射機制,反射是在運行時獲取類信息,通過動態的調用來改變程序行爲;
熱加載則是在運行時通過重新加載改變類信息,直接改變程序行爲。

熱部署原理類似,但它是直接重新加載整個應用,這種方式會釋放內存,比熱加載更加乾淨徹底,但同時也更費時間。

三、在java中應用

1.生產環境

熱部署作爲一個比較靈活的機制,在實際的生產上運用還是有,但相對很少,熱加載則基本沒有應用。分析如下

  • 一、安全性

熱加載這種直接修改jvm中字節碼的方式是難以監控的,不同於sql等執行可以記錄日誌,直接字節碼的修改幾乎無法記錄代碼邏輯的變化,對既有代碼行爲的影響難以控制,對於越注重安全的應用,熱加載帶來的風險越大,這好比給飛行中的飛機更換髮動機。

  • 二、適用的情景

技術大部分是跟需求掛鉤的,而需要熱部署的情景很少。

  1. 頻繁的部署並且啓動耗時長的應用
  2. 無法停止服務的應用

在生產中,並沒有需要頻繁部署的應用,即使是敏捷,再快也是一週一次的迭代,並且通過業務劃分和模塊化編程,部署的代價完全可以忽略不計,對於現有的應用,啓動耗時再長,也並非長到無法忍受,如果真的這麼長,那更應該考慮的是如何進行模塊拆分,分佈式部署了。

對於無法停止服務的應用,比如現在的雲計算平臺這樣分佈式應用,採用分批上線也可以滿足需求,類似熱部署方案應該是放在最後考慮的解決方案。

2.開發環境

在生產中,不會有頻繁的部署並且啓動耗時長的應用,但由於雲計算的興起,熱部署還是有其應用。
而熱加載有點玩火,太危險了。但在開發和debug中,頻繁啓動應用卻隨處可見,熱加載機制可以極大的提升開發效率。這兩種機制,在開發中還有另外一種稱呼—開發者模式。

對於大型項目:往往啓/停需要等待幾分鐘時間。更浪費時間的是,對於一個類中的方法的調試過程,如果修改多次,需要反覆的啓停服務器,浪費的時間更多。

以目前的crm項目爲例,其啓動時間爲5m,以一天debug重啓十次,一個月工作20天來算,每年重啓耗時25人日,如果能完全使用熱加載,每年節省重啓時間近1人月。

crm pool啓動耗時

1.struts2熱加載

在struts2中熱加載即開發者模式,在struts.xml配置

<constant name="struts.devMode" value="true" />

從而當更改struts.xml文件後不需要重新啓動服務器就可以進行程序調試。

2.開發時使用tomcat熱加載

tomcat本身默認開啓了熱部署方式,但熱部署是直接重新加載整個應用,耗時跟重啓服務器差不多,我們需要的其實是熱加載,即修改了哪個class,只重新加載這一個class,這樣耗時幾乎爲0。
對於tomcat5.x 以上版本,均已支持一定程度上得熱加載,但這種方式只針對代碼行級別的,也就是說如果新刪方法,註解,類,或者變量時是無效的,只能重啓,這是我目前在公司開發時用的方式,可以顯著降低debug時的重啓次數,提高開發效率

1.將tomcat server.xml文件的context reloadable 值置爲false 或者在web modules中編輯取消Auto reloading選項。

    <Context reloadable="false"/>

2.修改eclipse中的server配置

這樣做可以在在修改代碼之後,不會自動重啓服務器,而只加載代碼,新增一行java代碼ctrl+s後直接刷新頁面或調用接口即可看到效果,無需重啓tomcat。

3.遠程debug中使用tomcat熱加載

tomcat的熱加載機制不僅可以在本地debug時,tomcat的遠程調試也支持熱部署,通過eclipse debug遠程到遠程tomcat上,修改本地代碼,ctrl+s後直接刷新頁面後調用接口,即可發現遠程tomcat已將本地代碼進行了熱加載。

4.jrebel插件方式

jrebel插件可以進行更徹底的熱加載,不僅包括類,甚至支持spring 等配置文件的熱加載,但公司項目開發環境複雜,目前在eclipse中配置一直沒有成功,只能使用tomcat自帶的熱加載機制。

總結

在實際生產中熱部署在雲計算中運用挺多,但熱加載沒有,而在開發中,熱加載可以顯著的提升工作效率,強烈推薦使用熱加載方式,不僅tomcat,大多數其他servlet容器也支持這種方式,大家可以自行搜索相關技巧。

參考文檔 :

1.Tomcat 熱部署實現方式源碼分析總結

2.提高開發效率-jrebel插件安裝

HotSwap和JRebel原理

HotSwap和Instrumentation

在2002年的時候,Sun在Java 1.4的JVM中引入了一種新的被稱作HotSwap的實驗性技術,這一技術被合成到了Debugger API內部,其允許調試者使用同一個類標識來更新類的字節碼。這意味着所有對象都可以引用一個更新後的類,並在它們的方法被調用的時候執行新的代碼,這就避免了無論何時只要有類的字節碼被修改就要重載容器的這種要求。所有新式的IDE(包括Eclipse、IDEA和NetBeans)都支持這一技術,從Java 5開始,這一功能還通過Instrumentation API直接提供給Java應用使用。

hotswap

不幸的是,這種重定義僅限於修改方法體——除了方法體之外,它既不能添加方法或域,也不能修改其他任何東西。這限制了HotSwap的實用性,且其還因其他的一些問題而變得更糟:

Java編譯器常常會創建合成的方法或是域,儘管你僅是修改了一個方法體(比如說,在添加一個類字面常量(class literal)、匿名的和內部的類的時候等等)。 在調試模式下運行常常會降低應用的速度或是引入其他的問題。

這些情況導致了HotSwap很少被使用,較之應該可能被使用的頻度要低。

爲什麼HotSwap僅限於對方法體起作用?

自從引入了HotSwap之後,在最近的10年,這一問題已經被問了非常多次。在支持做整組改變的JVM調用的bug中,這是一個得票率最高的bug ,但到目前爲止,這一問題一直沒有被落實。

一個聲明:我不能說是一個JVM專家,我對JVM是如何實現的在總體上有着一個很好的理解,這幾年來我有和少數幾個(前)Sun工程師談過,不過我並沒有驗證我在這裏說的每一件事情。不過話雖如此,對於這個bug依然處開發狀態的原因我確實是有一些想法的(不過如果你更清楚其中的原因的話,歡迎指正)。

JVM是一種做了重度優化的軟件,運行在多個平臺上。性能和穩定性是其最高的優先事項。爲了在不同的環境中支持這些事項,Sun的JVM提供了這樣的功能特色:

 兩個重度優化的即時編譯器(-client和-server)
 幾個多代(multi-generational )垃圾收集器

這些功能特性使得類模式(schema)的發展變成了一個相當大的挑戰。爲了理解這其中的原因,我們需要稍微靠近一點看一看,到底是需要用什麼來支持方法和域的添加操作(甚至更深入一些,修改繼承的層次結構)。

在被加載到JVM中時,對象是由內存中的結構來表示的,結構佔據了某個特定大小(它的域加上元數據)的連續的內存區域。爲了添加一個域,我們需要調整結構的大小,但因爲臨近的區域可能已被佔用,我們就需要把整個結構重新分配到一個不同的區域中,這一區域中有足夠可用的空間來把它填寫進來。現在,由於我們實際上是更新了一個類(並不僅是某個對象),所以我們不得不對該類的每一個對象都做這樣的一件事。

這本身並不難實現——Java垃圾收集器就已經是隨時都在做重分配對象的工作的了。問題是,一個“堆”的抽象就僅是一個抽象而已。內存的實際佈局取決於當前活動的垃圾收集器,而且,爲了能與所有這些對象兼容,重分配應該有可能會被委派給活動的垃圾收集器。JVM在重分配期間還需要掛起,因此其在此期間同時進行GC工作也是合理的。

添加一個方法並不要求更新對象的結構,但確實是需要更新類的結構的,這也會體現在堆上。不過考慮一下這種情況:從類被載入之後的那一刻起,其從本質上來說就是被永久凍結了的。這使得JIT(Just-In-Time)能夠完成JVM執行的主要優化操作——內聯。應用程序熱點中的大多數方法調用會被取消,這些代碼會被拷貝到對其做調用的方法中。一個簡單的檢測會被插進來,用以確保目標對象確實是我們所認爲的對象。

於是就有了這樣可笑的事:在我們能夠添加方法到類中的時候,這種“簡單的檢查”是不夠的。我們需要的是一個相當複雜的檢查,需要這樣更復雜的檢查來確保沒有使用了相同名字的方法被添加到目標類以及目標類的超類中。另外,我們也可以跟蹤所有的內聯點和它們的依賴,並在類被更新時,解除對它們所做的優化。兩種方式可選擇,或是付出性能方面的代價,或是帶來更高的複雜性。

最重要的是,考慮到我們正在討論的是有着不同的內存模型和指令集的多個平臺,它們可能多多少少需要一些特定的處理,因此你給自己帶來的是一個代價過高而沒有太多投資回報的問題。

jrebel-agent

JRebel介紹

2007年,ZeroTurnaround宣佈提供一種被稱作JRebel(當時是JavaRebel)的工具,該工具可以在無需動態類加載器的情況下更新類,且只做極少的限制。不像HotSwap要依賴於IDE的集成,這一工具的工作方式是,監控磁盤上實際已編譯的.class文件,無論何時只要有文件被更新就更新類。這意味着如果願意的話,你可以把JRebel和文本編輯器、命令行的編譯器放在一起使用。當然,它也被巧妙地整合到了Eclipse、InteliJ和NetBeans中。與動態的類加載器不一樣,JRebel保留了所有現有的對象和類的標識和狀態,允許開發者繼續使用他們的應用而不會產生延遲。

如何使之生效?

對於初學者來說,JRebel工作在與HotSwap不同的一個抽象層面上。鑑於HotSwap是工作在虛擬機層面上,且依賴於JVM的內部運作,JRebel用到了JVM的兩個顯著的功能特徵——抽象的字節碼和類加載器。類加載器允許JRebel辨別出類被加載的時刻,然後實時地翻譯字節碼,用以在虛擬機和可執行代碼之間創建另一個抽象層。

也有人使用這一功能特性來提供分析器、性能監控、後續(continuation)、軟件事務性內存以及甚至是分佈式的堆。 把字節碼抽象和類加載器結合在一起,這是一種強大的組合,可被用來實現各種比類重載還要不尋常的功能。當我們越是深入地研究這一問題,我們就會看到面臨的挑戰並不僅是在類重載這件事上,而且是還要在性能和兼容性方面沒有明顯退化的情況下來做這件事情,

正如我們在Reloading Java Classes 101 一文中所做的回顧一樣,重載類存在的問題是,一旦類被載入,它就不能被卸載或是改變;但是隻要我們願意,我們就可以自由地加載新的類。爲了理解在理論上我們是如何重載類的,讓我們來研究一下Java平臺上的動態語言。具體來說,讓我們先來看一看JRudy(我們做了許多的簡化,以免對任何重要人物造成折磨)。

儘管JRuby以“類(class)”作爲其功能特性,但在運行時,其每個對象都是動態的,任何時候都可以加入新的域和方法。這意味着JRuby對象與Map沒有什麼兩樣,有着從方法名字到方法實現的映射,以及域名到其值的映射。這些方法的實現被包含在匿名的類中,在遇到方法時這些類就會被生成。如果你添加了一個方法,則所有JRuby要做的事情就是生成一個新的匿名類,該類包含了這一方法的方法體。因爲每個匿名類都有一個唯一的名稱,因此在加載該類是不會有問題的,而這樣做的結果是,應用被實時動態地更新了。

從理論上來說,由於字節碼翻譯通常是用來修改類的字節碼,因此若僅僅是爲了根據需要創建足夠多的類來履行類的功能的話,我們沒有什麼理由不能使用類中的信息。這樣的話,我們就可以使用如JRuby所做的相同轉換來把所有的Java類分割成持有者類和方法體類。不幸的是,這樣的一種做法會遭受(至少是)如下的問題:

性能。這樣的設置將意味着,每個方法調用都會遭遇重定向。我們可以做優化,但應用程序的速度將會變慢至少一個數量級,內存的使用也會扶搖直上,因爲有這麼多的類被創建。 Java的SDK類。Java SDK中的類明顯地比應用或是庫中的類更加難以處理。此外它們通常會以本地的代碼來實現,因此不能以“JRuby”的方式做轉換。然而,如果我們讓它們保持原樣的話,那麼就會引發各種的不兼容性錯誤,這些錯誤有可能是無法繞開的。 兼容性。儘管Java是一種靜態的語言,但是它包含了一些動態的特性,比如說反射和動態代理等。如果我們採用了“JRuby”式的轉換的話,這些功能特性就會失效,除非我們使用自己的類來替換掉Reflection API,而這些類知道這些要做的轉換。

因此,JRebel並沒有採用這樣的做法。相反,其使用了一種更復雜的方法,基於先進的編譯技術,留給我們一個主類和幾個匿名的支持類,這些類由JIT的轉換運行時做支持,其允許所進行的修改不會帶來任何明顯的性能或是兼容性的退化。它還

留有儘可能多完整的方法調用,這意味着JRebel把性能開銷降低到了最小,使其輕量級化。
避免了改編(instrument)Java SDK,除了少數幾個需要保持兼容性的地方外。
調整Reflection API的結果,這樣我們就能夠把這些結果中已添加/已刪除的成員正確地包含進來。這也意味着註解(Annotation)的改變對於應用來說是可見的。

除了類重載之外——還有歸檔文件

重載類是一件Java開發者已經抱怨了很久的事情,不過一旦我們解決了它之後,另外的一些問題就隨之而來了。

Java EE標準的制定並未怎麼關注開發的週轉期(Turnaround)(指的是從對代碼做修改到觀察到改變在應用中造成的影響這一過程所花費的時間)。其設想的是,所有的應用和它們的模塊都被打包到歸檔文件(JAR、WAR和EAR)中,這意味着在能夠更新應用中的任何文件之前,你需要更新歸檔文件——這通常是一個代價高昂的操作,涉及了諸如Ant或是Maven這一類的構建系統。正如我們在Reloading Java Classes 301 所做的討論那樣,可以通過使用展開式的開發和增量的IDE構建來儘量減少花銷,不過對於大型的應用來說,這種做法通常不是一個可行的選擇。

爲了解決這一問題,在JRebel 2.x中,我們爲用戶開發了一種方式來把歸檔的應用和模塊映射回到工作區中——用戶在每個應用和模塊中創建一個rebel.xml配置文件,該文件告訴JRebel在哪裏可以找到源文件。JRebel與應用服務器整合在一起,當某個類或是資源被更新時,其被從工作區中而不是從歸檔文件中讀入。

workspace-map

這一做法不僅允許類的即時更新,且允許諸如HTML、XML、JSP、CSS、.properties等之類的任何類型的資源的即時更新。Maven用戶甚至不需要創建一個rebel.xml文件,因爲Maven插件會自動地生成該文件。

除了類重載之外——還有配置和元數據

在消除週轉期的這一過程中,另一個問題變得明顯起來:現如今的應用已不僅僅是類和資源,它們還通過大量的配置和元數據綁定在一起。當配置發生改變時,改變應該被反映到那個正在運行的應用上。然而,僅把對配置文件的修改變成是可見的是不夠的,具體的框架必須要要重載配置,把改變反映到應用中才行。

conf

爲了在JRebel中支持這些類型的改變,我們開發了一個開源的API ,該API允許我們的團隊和第三方的捐獻者使用框架特有的插件來使用JRebel的功能特性,把配置中所做的改變傳播到框架中。例如,我們支持動態實時地在Spring中添加bean和依賴,以及支持在其他框架中所做的各種各樣的改變。

結論

本文總結了在未使用動態類加載器情況下的各種重載Java類的方法。我們還討論了導致HotSwap侷限性的原因,揭示了JRebel幕後的工作方式,以及討論了在解決類重載問題時出現的其他問題。

原文地址:http://article.yeeyan.org/view/213582/186226

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章