類加載器不喜歡我,想我慢下來

一些背景:我們難道不能和諧相處嗎?

自從二十世紀九十年代Java首次創建的時候,Java資源和類的加載就已經是一個問題了。通過增加啓動和初始化的次數,Java應用程序服務器放大了這個問題。爲了緩解這個這個問題,程序員們做了很多的努力,其中包括exploded deployment到應用程序服務器的方法,但它只是在非常小的程序中才會起作用,還有2001年創建的Java HotSwap。HotSwap啓用時,可以讓你已有的方法中立馬讓你的更改生效。一般它應用在調試session中,但是因爲方法邊界的限制它並不是總是有效的。構建、部署和重啓經常要等上一段時間,甚至等5到15分鐘也是經常發生的。對更大的應用程序服務器,就容易引起更嚴重的問題。

問題

一旦一個Java類被一個類加載器加載,它就是不可變的並且和類加載器的存在時間一樣久。唯一的標識就是類名類加載器,所以爲了重新加載一個應用程序,你實際上需要創建一個新的類加載器,這個新創建的類加載器用來加載最新版本的應用程序的類。你不能將一個新類映射到現有對象中,所以在重新加載時遷移狀態是很重要的。這可能意味着要重新初始化應用程序和配置狀態等,對用戶會話狀態複製,從而重新創建整個應用程序對象圖。往往這也是耗費時間的,而且也很容易引起內存泄漏。

當用類加載器來處理內存泄露時,一行代碼的小泄露通過Java使用的引用模型可能被放大。比如,一個類加載器實例會針對它所加載的所有類和隨後被創建的對象的實例都擁有一個引用。所以即使是一個小泄露,或許是在重載期間在應用程序實例之間的狀態遷移時被加入的,都可能會產生很大的影響。

所以,作爲一個開發者對你來說這意味找什麼呢?這意味着持續的編譯、構建、打包、重新部署、以及應用程序服務重新啓動會妨礙你的專注和有趣的卓有成效的工作。

這篇文章旨在向開發者闡明 JRebel的奧祕所在,一探這個產品在幕後所做的事情,與此同時讓你注意到你可能忽略的或者認爲理所當然的JVM的其他方面。這篇文章將更多的關注 JRebel解決的主要問題

讓我們看看類加載器

一個類加載器只是一個普通的Java對象

對,和JVM中的系統類加載器相比它一點也不高明,一個類加載器只是一個Java對象。它是一個抽象類,CLassLoader,能夠被你創建的類所繼承。下面就是API:

1
2
3
4
5
6
7
public abstract class ClassLoader {
  public Class loadClass(String name);
  protected Class defineClass(byte[] b);
  public URL getResource(String name);
  public Enumeration getResources(String name);
  public ClassLoader getParent();
}

看起來相當的簡單,是不是?讓我們看看這個類的方法。這個中心方法是loadClass,它有一個String類型的class類型參數並且返回一個實際的Class對象。如果你以前使用類加載器那麼它方法可能是你最熟悉的方法,因爲它是日常編碼中最常用的。defineClass 是JVM中一個final類型的方法,它需要一個從字節數組的參數,參數來自於一個文件或者在網絡中的一個位置,並且產生相同的輸出,一個Class對象。

一個類加載器也可以從classPath(類路徑—一個環境變量)中找到資源。它和loadClass方法起作用的方式類似。有很多方法getResourcegetResources,他們返回一個URL或者是一個枚舉類型的URLS,這些URL指向代表傳遞給方法的參數的名稱的資源。

每一個類加載器都有父類;getParent 方法返回了一個類加載器的父類,這個不是Java繼承的關係,而是通過一個鏈表方式連接。我們稍後將稍稍深入的看一下這個問題。

類加載器是懶惰的,所以類只有在運行時被需要纔會被加載。類是在當被資源請求它的時候才被加載,所以在運行時一個類可能被多個類加載器加載,這取決於它們從哪裏被引用以及哪些類加載器加載這些類……哎呀,我已經和它對上眼了!讓我們看看一些代碼。

1
2
3
4
5
6
public class A {
  public void doSmth() {
    B b = new B();
    b.doSmthElse();
  }
}

我們讓類A在doSmth方法中調用類B的構造器。下面說明了發生了什麼

1
A.class.getClassLoader().loadClass(“B”);

最開始加載類A的類加載器被請求去加載類B。

類加載器是分層次等級的,可是像孩紙們一樣,他們卻不經常請求他們的父類。

每一個類加載器擁有一個父加載器。當向一個類加載器請求一個類的時候,它通常會直接跑到父類加載器那裏,調用loadClass方法。如果兩個具有相同父類加載器的類加載器被要求去加載相同的類,它將只由父類進行一次。當兩個類加載器分別的去分別加載相同的類的時候會變得很麻煩,因爲這可能會導致問題,我們稍後再看看。

當一個J2EE應用程序服務器實現了一個J2EE規範的時候,他們中的一些更願意把這個任務委派給父類,而其他則選擇首先在WEB應用程序類加載中查找。讓我們更加深入這一點,使用圖1作爲我們的例子。

在這個例子中,模塊 WAR1 有它自己的類加載器而且更願意去用它自己來加載類而不是委派給他的父類,這個類加載器被App1.ear限定了作用域。這意味着不同的WAR模塊,像WAR1和WAR2一樣,不能看見彼此的類。App1.ear 模塊有他自己的類加載器並且它是WAR1和WAR2類加載器的父類。當WAR1和WAR2需要沿着層級層次向上發出請求時(也就是需要WAR類加載器作用域外的類時),App1.ear類加載器被WAR1和WAR2使用。當兩個都存在的時候,WAR的類會覆蓋EAR的類。最後EAR類加載器的父類是容器類加載器。EAR類加載器將委派請求發給容器類加載器,但是它不會像WAR類加載一樣使用相同的方法,因爲EAR類加載器實際上會向上委託請求而不是通過本地的類。正如您所看到的,這是相當難以理解的,和普通的JavaSE的類的加載行爲是不一樣的。

我們應該怎麼重新加載應用程序中的類

通過先前我們查看的類加載器API我們知道,你只能裝載類。也就是說,沒有其他的方式去卸載或者重新加載這些類,所以爲了在運行時重新創建一個類,你實際上必須拋棄整個類的層次結構然後重新創建它,這樣才能加載新的類,並在運行的時候使用它,正如圖2 展示的一樣:

如果你已經有過一段的Java編程經歷,你知道內存泄露經常會發生的。譬如集合含有指向對象的引用,本應該清除引用,但是沒有清除。類加載器是一種很特殊的情況,不幸的是在Java平臺的現狀中,這些泄露既是難以避免的又是代價很大的:在幾次重部署後,應用程序經常導致outofmemoryerror錯誤。

每一個對象都有一個指向它的類的引用,也就是有一個指向它的類加載器的引用。關鍵在於通過這個每一個類加載器都有一個指向它加載的每一個類的引用,這些類都擁有靜態域,如圖3。

這意味着:

1.如果一個類加載器內存泄露了,那麼它會佔用它加載的所有類和它們所有的靜態域。靜態域通常含有緩存、單例對象和不同的配置以及應用程序狀態。即使你的應用程序沒有一些大的靜態緩存,這也不意味着你使用的框架不佔用着它們(如Log4J是一種常見的罪魁禍首,因爲它通常是放在服務器類路徑中)。這就說明了爲什麼加載器泄露的代價會很大。

2.類加載器發生內存泄露很容易,只要類加載器加載了一個類,類創建了一個對象,然後給對象一個引用就可以了。就算對於一個看起來無害的對象(譬如沒有域),但是它也會保留它的類加載器和所有應用程序的狀態。就算在部署中沒有出現問題,但是沒有做適當的清理工作,這個也足夠去埋下了泄露的隱患。在一個典型的應用程序將會有幾個這樣的地方,因爲第三方庫構建的原因,其中一些幾乎不可能修復。因此,類加載器泄露是相當普遍的。

這就是存在的技術問題。爲了更新我們在運行時的代碼,我們通常需要建立,打包,重新部署,甚至重啓容器看到更新的代碼。接下來,我們將看看針對Java中這一核心問題的解決方法,包括在Java 1.4中引入的HotSwap類重載框架和JRebe的解決方法。

關於作者(SIMON MAPLE):

Simon是一個ZeroTurnaround的技術人員,他喜歡討論和交流,不喜歡說教。他對技術社區投入了極大的熱情,他既是倫敦Java社區組織(London Java Community, LJC)的成員,也是LJC JCP EC委員會的一員。Simon過去在IBM從事WebSphere Application Server項目的測試、開發以及技術教學的工作,一共超過了十年,那之後他加入了ZeroTurnaround。他喜歡看足球比賽(各種各樣的足球),踢足球,喝茶還有陪伴家人。

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