引言
classloader顧名思義,即是類加載。虛擬機把描述類的數據從class字節碼文件加載到內存,並對數據進行檢驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。了解java的類加載機制,可以快速解決運行時的各種加載問題並快速定位其背后的本質原因,也是解決疑難雜症的利器。因此學好類加載原理也至關重要。
一、classloader的加載過程
類從被加載到虛擬機內存到被卸載,整個完整的生命周期包括:類加載、驗證、准備、解析、初始化、使用和卸載七個階段。其中驗證,准備,解析三個部分統稱為連接。接下來我們可以詳細了解下類加載的各個過程。
classloader的整個加載過程還是非常復雜的,具體的細節可以參考《深入理解java虛擬機》進行深入了解。為了方便記憶,我們可以使用一句話來表達其加載的整個過程,“家宴准備了西式菜”,即家(加載)宴(驗證)准備(准備)了西(解析)式(初始化)菜。保證你以后能夠很快的想起來。
雖然classloader的加載過程有復雜的5步,但事實上除了加載之外的四步,其它都是由JVM虛擬機控制的,我們除了適應它的規范進行開發外,能夠干預的空間並不多。而加載則是我們控制classloader實現特殊目的最重要的手段了。也是接下來我們介紹的重點了。
二、 classloader雙親委托機制
classloader的雙親委托機制是指多個類加載器之間存在父子關系的時候,某個class類具體由哪個加載器進行加載的問題。其具體的過程表現為:當一個類加載的過程中,它首先不會去加載,而是委托給自己的父類去加載,父類又委托給自己的父類。因此所有的類加載都會委托給頂層的父類,即Bootstrap Classloader進行加載,然后父類自己無法完成這個加載請求,子加載器才會嘗試自己去加載。使用雙親委派模型,Java類隨着它的加載器一起具備了一種帶有優先級的層次關系,通過這種層次模型,可以避免類的重復加載,也可以避免核心類被不同的類加載器加載到內存中造成沖突和混亂,從而保證了Java核心庫的安全。
整個java虛擬機的類加載層次關系如上圖所示,啟動類加載器(Bootstrap Classloader)負責將<JAVA_HOME>/lib目錄下並且被虛擬機識別的類庫加載到虛擬機內存中。我們常用基礎庫,例如java.util.**,java.io.**,java.lang.**等等都是由根加載器加載。
擴展類加載器(Extention Classloader)負責加載JVM擴展類,比如swing系列、內置的js引擎、xml解析器等,這些類庫以javax開頭,它們的jar包位於<JAVA_HOME>/lib/ext目錄中。
應用程序加載器(Application Classloader)也叫系統類加載器,它負責加載用戶路徑(ClassPath)上所指定的類庫。我們自己編寫的代碼以及使用的第三方的jar包都是由它來加載的。
自定義加載器(Custom Classloader)通常是我們為了某些特殊目的實現的自定義加載器,后面我們得會詳細介紹到它的作用以及使用場景。
雙親委托機制看起來比較復雜,但是其本身的核心代碼邏輯卻是非常的清晰簡單,我們着重抽取了類加載的雙親委托的核心代碼如下,不過二十行左右。
三、classloader的應用場景
類加載器是java語言的一項創新,也是java語言流行的重要原因這一。通過靈活定義classloader的加載機制,我們可以完成很多事情,例如解決類沖突問題,實現熱加載以及熱部署,甚至可以實現jar包的加密保護。接下來,我們會針對這些特殊場景進行逐一介紹。
3.1、 依賴沖突
做過多人協同開發的大型項目的同學可能深有感觸。基於maven的pom進制可以方便的進行依賴管理,但是由於maven依賴的傳遞性,會導致我們的依賴錯綜復雜,這樣就會導致引入類沖突的問題。最典型的就是NoSuchMethodException異常了。
例如一家大型互聯網公司,甚至是一般的企業,內部也很多成熟的中間件,由不同的中間件團隊來負責。那么當一個項目引入不同的中間件的時候,該如何避免依賴沖突的問題呢?首先我們用一個非常簡單的場景來描述為什么會出現類沖突的問題。
某個業務引用了消息中間件(例如metaq)和微服務中間件(例如dubbo),這兩個中間件也同時引用了fastjson-2.0和fastjson-3.0版本,而業務自己本身也引用了fastjson-1.0版本。這三個版本表現不同之處在於classA類中方法數目不相同,我們根據maven依賴處理的機制,引用路徑最短的fastjson-1.0會真正作為應用最終的依賴,其它兩個版本的fastjson則會被忽略,那么中間件在調用method2()方法的時候,則會拋出方法找不到異常。或許你會說,將所有依賴fastjson的版本都升級到3.0不是就能解解決問題嗎?確實這樣能夠解決問題,但是在實際操作中不太現實,首先,中間件團隊和業務團隊之間並不是一個團隊,並不能做到高效協同,其次是中間件的穩定性是需要保障的,不可能因為包沖突問題,就升級版本,更何況一個中間件依賴的包可能有上百個,如果純粹依賴包升級來解決,不僅穩定性難以保障,排包耗費的時間恐怕就讓人窒息了。
那如何解決包沖突的問題呢?答案就是pandora(潘多拉),通過自定義類加載器,為每個中間件自定義一個加載器,這些加載器之間的關系是平行的,彼此沒有依賴關系。這樣每個中間件的classloader就可以加載各自版本的fastjson。因為一個類的全限定名以及加載該類的加載器兩者共同形成了這個類在JVM中的惟一標識,這也是阿里pandora實現依賴隔離的基礎。
可能到這里,你又會有新的疑惑,根據雙親委托模型,App Classloader分別繼承了Custom Classloader.那么業務包中的fastjson的class在加載的時候,會先委托到Custom ClassLoader。這樣不就會導致自身依賴的fastjson版本被忽略嗎?確實如此,所以潘多拉又是如何做的呢?
首先每個中間件對應的ModuleClassLoader在加載中間對應的class文件的同時,根據中間件配置的export.index負責將要需要透出的class(主要是提供api接口的相關類)索引到exportedClassHashMap中,然后應用程序的類加載器會持有這個exportedClassHashMap,因此應用程序代碼在loadClass的時候,會優先判斷exportedClassHashMap是否存在當前類,如果存在,則直接返回,如果不存在,則再使用傳統的雙親委托機制來進行類加載。這樣中間件MoudleClassloader不僅實現了中間件的加載,也實現了中間件關鍵服務類的透出。
我們可以大概看下應用程序類加載的過程:
3.2、熱加載
在開發項目的時候,我們需要頻繁的重啟應用進行程序調試,但是java項目的啟動少則幾十秒,多則幾分鍾。如此慢的啟動速度極大地影響了程序開發的效率,那是否可以快速的進行啟動,進而能夠快速的進行開發驗證呢?答案也是肯定的,通過classloader我們可以完成對變更內容的加載,然后快速的啟動。
常用的熱加載方案有好幾個,接下來我們介紹下spring官方推薦的熱加載方案,即spring boot devtools。
首先我們需要思考下,為什么重新啟動一個應用會比較慢,那是因為在啟動應用的時候,JVM虛擬機需要將所有的應用程序重新裝載到整個虛擬機。可想而知,一個復雜的應用程序所包含的jar包可能有上百兆,每次微小的改動都是全量加載,那自然是很慢了。那么我們是否可以做到,當我們修改了某個文件后,在JVM中替換到這個文件相關的部分而不全量的重新加載呢?而spring boot devtools正是基於這個思路進行處理的。
如上圖所示,通常一個項目的代碼由以上四部分組成,即基礎類、擴展類、二方包/三方包、以及我們自己編寫的業務代碼組成。上面的一排是我們通常的類加載結構,其中業務代碼和二方包/三方包是由應用加載器加載的。而實際開發和調試的過程中,主要變化的是業務代碼,並且業務代碼相對二方包/三方包的內容來說會更少一些。因此我們可以將業務代碼單獨通過一個自定義的加載器Custom Classloader來進行加載,當監控發現業務代碼發生改變后,我們重新加載啟動,老的業務代碼的相關類則由虛擬機的垃圾回收機制來自動回收。其工程流程大概如下。有興趣的同學可以去看下源碼,會更加清楚。
RestartClassLoader為自定義的類加載器,其核心是loadClass的加載方式,我們發現其通過修改了雙親委托機制,默認優先從自己加載,如果自己沒有加載到,從從parent進行加載。這樣保證了業務代碼可以優先被RestartClassLoader加載。進而通過重新加載RestartClassLoader即可完成應用代碼部分的重新加載。
3.3、熱部署
熱部署本質其實與熱加載並沒有太大的區別,通常我們說熱加載是指在開發環境中進行的classloader加載,而熱部署則更多是指在線上環境使用classloader的加載機制完成業務的部署。所以這二者使用的技術並沒有本質的區別。那熱部署除了與熱加載具有發布更快之外,還有更多的更大的優勢就是具有更細的發布粒度。我們可以想像以下的一個業務場景。
假設某個營銷投放平台涉及到4個業務方的開發,需要對會場業務進行投放。而這四個業務方的代碼全部都在一個應用里面。因此某個業務方有代碼變更則需要對整個應用進行發布,同時其它業務方也需要跟着回歸。因此每個微小的發動,則需要走整個應用的全量發布。這種方式帶來的穩定性風險估且不說,整個發布迭代的效率也可想而知了。這在整個互聯網里,時間和效率就是金錢的理念下,顯然是無法接受的。
那么我們完全可以通過類加載機制,將每個業務方通過一個classloader來加載。基於類的隔離機制,可以保障各個業務方的代碼不會相互影響,同時也可以做到各個業務方進行獨立的發布。其實在移動客戶端,每個應用模塊也可以基於類加載,實現插件化發布。本質上也是一個原理。
在阿里內部像阿拉丁投放平台,以及crossbow容器化平台,本質都是使用classloader的熱加載技術,實現業務細粒度的開發部署以及多應用的合並部署。
3.4、 加密保護
眾所周期,基於java開發編譯產生的jar包是由.class字節碼組成,由於字節碼的文件格式是有明確規范的。因此對於字節碼進行反編譯,就很容易知道其源碼實現了。因此大致會存在如下兩個方面的訴求。例如在服務端,我們向別人提供三方包實現的時候,不希望別人知道核心代碼實現,我們可以考慮對jar包進行加密,在客戶端則會比較普遍,那就是我們打包好的apk的安裝包,不希望被人家反編譯而被人家翻個底朝天,我們也可以對apk進行加密。
jar包加密的本質,還是對字節碼文件進行操作。但是JVM虛擬機加載class的規范是統一的,因此我們在最終加載class文件的時候,還是需要滿足其class文件的格式規范,否則虛擬機是不能正常加載的。因此我們可以在打包的時候對class進行正向的加密操作,然后,在加載class文件之前通過自定義classloader先進行反向的解密操作,然后再按照標准的class文件標准進行加載,這樣就完成了class文件正常的加載。因此這個加密的jar包只有能夠實現解密方法的classloader才能正常加載。
我們可以貼一下簡單的實現方案:
這樣整個jar包的安全性就有一定程度的提高,至於更高安全的保障則取決於加密算法的安全性了以及如何保障加密算法的密鑰不被泄露的問題了。這有種套娃的感覺,所謂安全基本都是相對的。並且這些方法也不是絕對的,例如可以通過對classloader進行插碼,對解密后的class文件進行存儲;另外大多數JVM本身並不安全,還可以修改JVM,從ClassLoader之外獲取解密后的代碼並保存到磁盤,從而繞過上述加密所做的一切工作,當然這些操作的成本就比單純的class反編譯就高很多了。所以說安全保障只要做到使對方破解的成本高於收益即是安全,所以一定程度的安全性,足以減少很多低成本的攻擊了。
四、總結
本文對classloader的加載過程和加載原理進行了介紹,並結合類加載機制的特征,介紹了其相應的使用場景。由於篇幅限制,並沒有對每種場景的具體實現細節進行介紹,而只是闡述了其基本實現思路。或許大家覺得classloader的應用有些復雜,但事實上只要大家對class從哪里加載,搞清楚loadClass的機制,就已經成功了一大半。正所謂萬變不離其宗,抓住了本質,其它問題也就迎刃而解了。