《深入理解Java虛擬機》第7章 虛擬機類加載機制

代碼編譯的結果從本地機器碼轉變爲字節碼,是存儲格式發展的一小步,卻是編程語言發展的一大步。

7.1 概述

上一章我們瞭解了Class文件存儲格式的具體細節,在Class文件中描述的各種信息,最終都需要加載到虛擬機中之後才能運行和使用。而虛擬機如何加載這些Class文件?Class文件中的信息進入到虛擬機後會發生什麼變化?這些都是本章將要講解的內容。

虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。

與那些在編譯時需要進行連接工作的語言不同,在Java語言裏面,類型的加載、連接和初始化過程都是在程序運行期間完成的,這種策略雖然會令類加載時稍微增加一些性能開銷,但是會爲Java應用程序提供高度的靈活性,Java裏天生可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的。例如,如果編寫一個面向接口的應用程序,可以等到運行時再指定其實際的實現類;用戶可以通過Java預定義的和自定義類加載器,讓一個本地的應用程序可以在運行時從網絡或其他地方加載一個二進制流作爲程序代碼的一部分,這種組裝應用程序的方式目前已廣泛應用於Java程序之中。從最基礎的Applet、JSP到相對複雜的OSGi技術,都使用了Java語言運行期類加載的特性。

爲了避免語言表達中可能產生的偏差,在本章正式開始之前,筆者先設立兩個語言上的約定:第一,在實際情況中,每個Class文件都有可能代表着Java語言中的一個類或接口,後文中直接對“類”的描述都包括了類和接口的可能性,而對於類和接口需要分開描述的場景會特別指明;第二,與前面介紹Class文件格式時的約定一致,筆者本章所提到的“Class文件”並非特指某個存在於具體磁盤中的文件,這裏所說的“Class文件”應當是一串二進制的字節流,無論以何種形式存在都可以。

7.2 類加載的時機

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱爲連接(Linking),這7個階段的發生順序如圖7-1所示。
這裏寫圖片描述

圖7-1中,加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。注意,這裏筆者寫的是按部就班地“開始”,而不是按部就班地“進行”或“完成”,強調這點是因爲這些階段通常都是互相交叉地混合式進行的,通常會在一個階段執行的過程中調用、激活另外一個階段。

什麼情況下需要開始類加載過程的第一個階段:加載?Java虛擬機規範中並沒有進行強制約束,這點可以交給虛擬機的具體實現來自由把握。但是對於初始化階段,虛擬機規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):

  • 1)遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
  • 2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 4)當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  • 5)當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

對於這5種會觸發類進行初始化的場景,虛擬機規範中使用了一個很強烈的限定語:“有且只有”,這5種場景中的行爲稱爲對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱爲被動引用。下面舉3個例子來說明何爲被動引用,分別見代碼清單7-1~代碼清單7-3。
這裏寫圖片描述
上述代碼運行之後,只會輸出“SuperClass init!”,而不會輸出“SubClass init!”。對於靜態字段,只有直接定義這個字段的類纔會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。至於是否要觸發子類的加載和驗證,在虛擬機規範中並未明確規定,這點取決於虛擬機的具體實現。對於Sun HotSpot虛擬機來說,可通過-XX:+TraceClassLoading參數觀察到此操作會導致子類的加載。
這裏寫圖片描述
爲了節省版面,這段代碼複用了代碼清單7-1中的SuperClass,運行之後發現沒有輸出“SuperClass init!”,說明並沒有觸發類org.fenixsoft.classloading.SuperClass的初始化階段。但是這段代碼裏面觸發了另外一個名爲“[Lorg.fenixsoft.classloading.SuperClass”的類的初始化階段,對於用戶代碼來說,這並不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承於java.lang.Object的子類,創建動作由字節碼指令newarray觸發。

這個類代表了一個元素類型爲org.fenixsoft.classloading.SuperClass的一維數組,數組中應有的屬性和方法(用戶可直接使用的只有被修飾爲public的length屬性和clone()方法)都實現在這個類裏。Java語言中對數組的訪問比C/C++相對安全是因爲這個類封裝了數組元素的訪問方法,而C/C++直接翻譯爲對數組指針的移動。在Java語言中,當檢查到發生數組越界時會拋出java.lang.ArrayIndexOutOfBoundsException異常。
這裏寫圖片描述
上述代碼運行之後,也沒有輸出“ConstClass init!”,這是因爲雖然在Java源碼中引用了ConstClass類中的常量HELLOWORLD,但其實在編譯階段通過常量傳播優化,已經將此常量的值“hello world”存儲到了NotInitialization類的常量池中,以後NotInitialization對常量ConstClass.HELLOWORLD的引用實際都被轉化爲NotInitialization類對自身常量池的引用了。也就是說,實際上NotInitialization的Class文件之中並沒有ConstClass類的符號引用入口,這兩個類在編譯成Class之後就不存在任何聯繫了。

接口的加載過程與類加載過程稍有一些不同,針對接口需要做一些特殊說明:接口也有初始化過程,這點與類是一致的,上面的代碼都是用靜態語句塊“static{}”來輸出初始化信息的,而接口中不能使用“static{}”語句塊,但編譯器仍然會爲接口生成“<clinit>()”類構造器,用於初始化接口中所定義的成員變量。接口與類真正有所區別的是前面講述的5種“有且僅有”需要開始初始化場景中的第3種:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)纔會初始化。

7.3 類加載的過程

接下來我們詳細講解一下Java虛擬機中類加載的全過程,也就是加載、驗證、準備、解析和初始化這5個階段所執行的具體動作。

7.3.1 加載

“加載”是“類加載”(Class Loading)過程的一個階段,希望讀者沒有混淆這兩個看起來很相似的名詞。在加載階段,虛擬機需要完成以下3件事情:

  • 1)通過一個類的全限定名來獲取定義此類的二進制字節流。
  • 2)將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  • 3)在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。

虛擬機規範的這3點要求其實並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的。例如“通過一個類的全限定名來獲取定義此類的二進制字節流”這條,它沒有指明二進制字節流要從一個Class文件中獲取,準確地說是根本沒有指明要從哪裏獲取、怎樣獲取。虛擬機設計團隊在加載階段搭建了一個相當開放的、廣闊的“舞臺”,Java發展歷程中,充滿創造力的開發人員則在這個“舞臺”上玩出了各種花樣,許多舉足輕重的Java技術都建立在這一基礎之上,例如:

  • 從ZIP包中讀取,這很常見,最終成爲日後JAR、EAR、WAR格式的基礎。
  • 從網絡中獲取,這種場景最典型的應用就是Applet。
  • 運行時計算生成,這種場景使用得最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass來爲特定接口生成形式爲“*$Proxy”的代理類的二進制字節流。
  • 由其他文件生成,典型場景是JSP應用,即由JSP文件生成對應的Class類。
  • 從數據庫中讀取,這種場景相對少見些,例如有些中間件服務器(如SAP Netweaver)可以選擇把程序安裝到數據庫中來完成程序代碼在集羣間的分發。
    ……

相對於類加載過程的其他階段,一個非數組類的加載階段(準確地說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的,因爲加載階段既可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器去完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式(即重寫一個類加載器的loadClass()方法)。

對於數組類而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。但數組類與類加載器仍然有很密切的關係,因爲數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終是要靠類加載器去創建,一個數組類(下面簡稱爲C)創建過程就遵循以下規則:

  • 如果數組的組件類型(Component Type,指的是數組去掉一個維度的類型)是引用類型,那就遞歸採用本節中定義的加載過程去加載這個組件類型,數組C將在加載該組件類型的類加載器的類名稱空間上被標識(這點很重要,在7.4節會介紹到,一個類必須與類加載器一起確定唯一性)。
  • 如果數組的組件類型不是引用類型(例如int[]數組),Java虛擬機將會把數組C標記爲與引導類加載器關聯。
  • 數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認爲public。

關於類加載器的話題,筆者將在本章的7.4節專門講述。

加載階段完成後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規範未規定此區域的具體數據結構。然後在內存中實例化一個java.lang.Class類的對象(並沒有明確規定是在Java堆中,對於HotSpot虛擬機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區裏面),這個對象將作爲程序訪問方法區中的這些類型數據的外部接口。

加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先後順序。

7.3.2 驗證

驗證是連接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

Java語言本身是相對安全的語言(依然是相對於C/C++來說),使用純粹的Java代碼無法做到諸如訪問數組邊界以外的數據、將一個對象轉型爲它並未實現的類型、跳轉到不存在的代碼行之類的事情,如果這樣做了,編譯器將拒絕編譯。但前面已經說過,Class文件並不一定要求用Java源碼編譯而來,可以使用任何途徑產生,甚至包括用十六進制編輯器直接編寫來產生Class文件。在字節碼語言層面上,上述Java代碼無法做到的事情都是可以實現的,至少語義上是可以表達出來的。虛擬機如果不檢查輸入的字節流,對其完全信任的話,很可能會因爲載入了有害的字節流而導致系統崩潰,所以驗證是虛擬機對自身保護的一項重要工作。

驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機是否能承受惡意代碼的攻擊,從執行性能的角度上講,驗證階段的工作量在虛擬機的類加載子系統中又佔了相當大的一部分。《Java虛擬機規範(第2版)》對這個階段的限制、指導還是比較籠統的,規範中列舉了一些Class文件格式中的靜態和結構化約束,如果驗證到輸入的字節流不符合Class文件格式的約束,虛擬機就應拋出一個java.lang.VerifyError異常或其子類異常,但具體應當檢查哪些方面,如何檢查,何時檢查,都沒有足夠具體的要求和明確的說明。直到2011年發佈的《Java虛擬機規範(Java SE 7版)》,大幅增加了描述驗證過程的篇幅(從不到10頁增加到130頁),這時約束和驗證規則才變得具體起來。受篇幅所限,本書無法逐條規則去講解,但從整體上看,驗證階段大致上會完成下面4個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

1.文件格式驗證

第一階段要驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理。這一階段可能包括下面這些驗證點:

  • 是否以魔數0xCAFEBABE開頭。
  • 主、次版本號是否在當前虛擬機處理範圍之內。
  • 常量池的常量中是否有不被支持的常量類型(檢查常量tag標誌)。
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據。
  • Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。

……

實際上,第一階段的驗證點還遠不止這些,上面這些只是從HotSpot虛擬機源碼中摘抄的一小部分內容,該驗證階段的主要目的是保證輸入的字節流能正確地解析並存儲於方法區之內,格式上符合描述一個Java類型信息的要求。這階段的驗證是基於二進制字節流進行的,只有通過了這個階段的驗證後,字節流纔會進入內存的方法區中進行存儲,所以後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作字節流。

2.元數據驗證

第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求,這個階段可能包括的驗證點如下:

  • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。

……

第二階段的主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規範的元數據信息。

3.字節碼驗證

第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件,例如:

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中。
  • 保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
  • 保證方法體中的類型轉換是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關係、完全不相干的一個數據類型,則是危險和不合法的。

……

如果一個類方法體的字節碼沒有通過字節碼驗證,那肯定是有問題的;但如果一個方法體通過了字節碼驗證,也不能說明其一定就是安全的。即使字節碼驗證之中進行了大量的檢查,也不能保證這一點。這裏涉及了離散數學中一個很著名的問題“Halting Problem”:通俗一點的說法就是,通過程序去校驗程序邏輯是無法做到絕對準確的——不能通過程序準確地檢查出程序是否能在有限的時間之內結束運行。

由於數據流驗證的高複雜性,虛擬機設計團隊爲了避免過多的時間消耗在字節碼驗證階段,在JDK 1.6之後的Javac編譯器和Java虛擬機中進行了一項優化,給方法體的Code屬性的屬性表中增加了一項名爲“StackMapTable”的屬性,這項屬性描述了方法體中所有的基本塊(Basic Block,按照控制流拆分的代碼塊)開始時本地變量表和操作棧應有的狀態,在字節碼驗證期間,就不需要根據程序推導這些狀態的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可。這樣將字節碼驗證的類型推導轉變爲類型檢查從而節省一些時間。

理論上StackMapTable屬性也存在錯誤或被篡改的可能,所以是否有可能在惡意篡改了Code屬性的同時,也生成相應的StackMapTable屬性來騙過虛擬機的類型校驗則是虛擬機設計者值得思考的問題。

在JDK 1.6的HotSpot虛擬機中提供了-XX:-UseSplitVerifier選項來關閉這項優化,或者使用參數-XX:+FailOverToOldVerifier要求在類型校驗失敗的時候退回到舊的類型推導方式進行校驗。而在JDK 1.7之後,對於主版本號大於50的Class文件,使用類型檢查來完成數據流分析校驗則是唯一的選擇,不允許再退回到類型推導的校驗方式。

4.符號引用驗證

最後一個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗,通常需要校驗下列內容:

  • 符號引用中通過字符串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
  • 符號引用中的類、字段、方法的訪問性(private、protected、public、default)是否可被當前類訪問。
    ……

符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

對於虛擬機的類加載機制來說,驗證階段是一個非常重要的、但不是一定必要(因爲對程序運行期沒有影響)的階段。如果所運行的全部代碼(包括自己編寫的及第三方包中的代碼)都已經被反覆使用和驗證過,那麼在實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

7.3.3 準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在Java堆中。其次,這裏所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義爲:

public static int value=123

那變量value在準備階段過後的初始值爲0而不是123,因爲這時候尚未開始執行任何Java方法,而把value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值爲123的動作將在初始化階段纔會執行。表7-1列出了Java中所有基本數據類型的零值。
這裏寫圖片描述
上面提到,在“通常情況”下初始值是零值,那相對的會有一些“特殊情況”:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化爲ConstantValue屬性所指定的值,假設上面類變量value的定義變爲:

public static final int value=123

編譯時Javac將會爲value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值爲123。

7.3.4 解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,符號引用在前一章講解Class文件格式的時候已經出現過多次,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所說的直接引用與符號引用又有什麼關聯呢?

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定已經加載到內存中。各種虛擬機實現的內存佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因爲符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。

直接引用(Direct References):直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。

虛擬機規範之中並未規定解析階段發生的具體時間,只要求了在執行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic這16個用於操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。所以虛擬機實現可以根據需要來判斷到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前纔去解析它。

對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令以外,虛擬機實現可以對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用,並把常量標識爲已解析狀態)從而避免解析動作重複進行。無論是否真正執行了多次解析動作,虛擬機需要保證的是在同一個實體中,如果一個符號引用之前已經被成功解析過,那麼後續的引用解析請求就應當一直成功;同樣的,如果第一次解析失敗了,那麼其他指令對這個符號的解析請求也應該收到相同的異常。

對於invokedynamic指令,上面規則則不成立。當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味着這個解析結果對於其他invokedynamic指令也同樣生效。因爲invokedynamic指令的目的本來就是用於動態語言支持(目前僅使用Java語言不會生成這條字節碼指令),它所對應的引用稱爲“動態調用點限定符”(Dynamic Call Site Specifier),這裏“動態”的含義就是必須等到程序實際運行到這條指令的時候,解析動作才能進行。相對的,其餘可觸發解析的指令都是“靜態”的,可以在剛剛完成加載階段,還沒有開始執行代碼時就進行解析。

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7種常量類型。下面將講解前面4種引用的解析過程,對於後面3種,與JDK 1.7新增的動態語言支持息息相關,由於Java語言是一門靜態類型語言,因此在沒有介紹invokedynamic指令的語義之前,沒有辦法將它們和現在的Java語言對應上,筆者將在第8章介紹動態語言調用時一起分析講解。

1.類或接口的解析

假設當前代碼所處的類爲D,如果要把一個從未解析過的符號引用N解析爲一個類或接口C的直接引用,那虛擬機完成整個解析的過程需要以下3個步驟:
1)如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C。在加載過程中,由於元數據驗證、字節碼驗證的需要,又可能觸發其他相關類的加載動作,例如加載這個類的父類或實現的接口。一旦這個加載過程出現了任何異常,解析過程就宣告失敗。
2)如果C是一個數組類型,並且數組的元素類型爲對象,也就是N的描述符會是類似“[Ljava/lang/Integer”的形式,那將會按照第1點的規則加載數組元素類型。如果N的描述符如前面所假設的形式,需要加載的元素類型就是“java.lang.Integer”,接着由虛擬機生成一個代表此數組維度和元素的數組對象。
3)如果上面的步驟沒有出現任何異常,那麼C在虛擬機中實際上已經成爲一個有效的類或接口了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限。如果發現不具備訪問權限,將拋出java.lang.IllegalAccessError異常。

2.字段解析

要解析一個未被解析過的字段符號引用,首先將會對字段表內class_index[2]項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現了任何異常,都會導致字段符號引用解析的失敗。如果解析成功完成,那將這個字段所屬的類或接口用C表示,虛擬機規範要求按照如下步驟對C進行後續字段的搜索。
1)如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
2)否則,如果在C中實現了接口,將會按照繼承關係從下往上遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
3)否則,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
4)否則,查找失敗,拋出java.lang.NoSuchFieldError異常。

如果查找過程成功返回了引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,將拋出java.lang.IllegalAccessError異常。

在實際應用中,虛擬機的編譯器實現可能會比上述規範要求得更加嚴格一些,如果有一個同名字段同時出現在C的接口和父類中,或者同時在自己或父類的多個接口中出現,那編譯器將可能拒絕編譯。在代碼清單7-4中,如果註釋了Sub類中的“public static int A=4;”,接口與父類同時存在字段A,那編譯器將提示“The field Sub.A is ambiguous”,並且拒絕編譯這段代碼。
這裏寫圖片描述

3.類方法解析

類方法解析的第一個步驟與字段解析一樣,也需要先解析出類方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,我們依然用C表示這個類,接下來虛擬機將會按照如下步驟進行後續的類方法搜索。
1)類方法和接口方法符號引用的常量類型定義是分開的,如果在類方法表中發現class_index中索引的C是個接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。
2)如果通過了第1步,在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
3)否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4)否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,這時查找結束,拋出java.lang.AbstractMethodError異常。
5)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError。
最後,如果查找過程成功返回了直接引用,將會對這個方法進行權限驗證,如果發現不具備對此方法的訪問權限,將拋出java.lang.IllegalAccessError異常。

4.接口方法解析

接口方法也需要先解析出接口方法表的class_index[4]項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用C表示這個接口,接下來虛擬機將會按照如下步驟進行後續的接口方法搜索。
1)與類方法解析不同,如果在接口方法表中發現class_index中的索引C是個類而不是接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。
2)否則,在接口C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
3)否則,在接口C的父接口中遞歸查找,直到java.lang.Object類(查找範圍會包括Object類)爲止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。

由於接口中的所有方法默認都是public的,所以不存在訪問權限的問題,因此接口方法的符號解析應當不會拋出java.lang.IllegalAccessError異常。

7.3.5 初始化

類初始化階段是類加載過程的最後一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。我們在下文會講解<clinit>()方法是怎麼生成的,在這裏,我們先看一下<clinit>()方法執行過程中一些可能會影響程序運行行爲的特點和細節,這部分相對更貼近於普通的程序開發人員。

<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問,如代碼清單7-5中的例子所示。
這裏寫圖片描述
<clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。

由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作,如在代碼清單7-6中,字段B的值將會是2而不是1。
這裏寫圖片描述
<clinit>()方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成<clinit>()方法。

接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞[2],在實際應用中這種阻塞往往是很隱蔽的。代碼清單7-7演示了這種場景。
這裏寫圖片描述
運行結果如下,即一條線程在死循環以模擬長時間操作,另外一條線程在阻塞等待。

Thread[Thread-05,main]start
Thread[Thread-15,main]start
Thread[Thread-05,main]init DeadLoopClass

7.4 類加載器

虛擬機設計團隊把類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需要的類。實現這個動作的代碼模塊稱爲“類加載器”。

類加載器可以說是Java語言的一項創新,也是Java語言流行的重要原因之一,它最初是爲了滿足Java Applet的需求而開發出來的。雖然目前Java Applet技術基本上已經“死掉”,但類加載器卻在類層次劃分、OSGi、熱部署、代碼加密等領域大放異彩,成爲了Java技術體系中一塊重要的基石,可謂是失之桑榆,收之東隅。

7.4.1 類與類加載器

類加載器雖然只用於實現類的加載動作,但它在Java程序中起到的作用卻遠遠不限於類加載階段。對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。這句話可以表達得更通俗一些:比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一個Class文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

這裏所指的“相等”,包括代表類的Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回結果,也包括使用instanceof關鍵字做對象所屬關係判定等情況。如果沒有注意到類加載器的影響,在某些情況下可能會產生具有迷惑性的結果,代碼清單7-8中演示了不同的類加載器對instanceof關鍵字運算的結果的影響。

這裏寫圖片描述
運行結果:

class org.fenixsoft.classloading.ClassLoaderTest
false

代碼清單7-8中構造了一個簡單的類加載器,儘管很簡單,但是對於這個演示來說還是夠用了。它可以加載與自己在同一路徑下的Class文件。我們使用這個類加載器去加載了一個名爲“org.fenixsoft.classloading.ClassLoaderTest”的類,並實例化了這個類的對象。兩行輸出結果中,從第一句可以看出,這個對象確實是類org.fenixsoft.classloading.ClassLoaderTest實例化出來的對象,但從第二句可以發現,這個對象與類org.fenixsoft.classloading.ClassLoaderTest做所屬類型檢查的時候卻返回了false,這是因爲虛擬機中存在了兩個ClassLoaderTest類,一個是由系統應用程序類加載器加載的,另外一個是由我們自定義的類加載器加載的,雖然都來自同一個Class文件,但依然是兩個獨立的類,做對象所屬類型檢查時結果自然爲false。

7.4.2 雙親委派模型

從Java虛擬機的角度來講,只存在兩種不同的類加載器:一種是啓動類加載器
(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;另一種就是所有其他的類加載器,這些類加載器都由Java語言實現,獨立於虛擬機外部,並且全都繼承自抽象類java.lang.ClassLoader。

從Java開發人員的角度來看,類加載器還可以劃分得更細緻一些,絕大部分Java程序都會使用到以下3種系統提供的類加載器。

啓動類加載器(Bootstrap ClassLoader):前面已經介紹過,這個類將器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機識別的(僅按照文件名識別,如rt.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機內存中。啓動類加載器無法被Java程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可,如代碼清單
7-9所示爲java.lang.ClassLoader.getClassLoader()方法的代碼片段。

這裏寫圖片描述

擴展類加載器(Extension ClassLoader):這個加載器由sun.misc.Launcher
$ExtClassLoader實現,它負責加載<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。

應用程序類加載器(Application ClassLoader):這個類加載器由sun.misc.Launcher $App-ClassLoader實現。由於這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱它爲系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

我們的應用程序都是由這3種類加載器互相配合進行加載的,如果有必要,還可以加入自己定義的類加載器。這些類加載器之間的關係一般如圖7-2所示。
這裏寫圖片描述

圖7-2中展示的類加載器之間的這種層次關係,稱爲類加載器的雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的啓動類加載器外,其餘的類加載器都應當有自己的父類加載器。這裏類加載器之間的父子關係一般不會以繼承(Inheritance)的關係來實現,而是都使用組合(Composition)關係來複用父加載器的代碼。

類加載器的雙親委派模型在JDK 1.2期間被引入並被廣泛應用於之後幾乎所有的Java程序中,但它並不是一個強制性的約束模型,而是Java設計者推薦給開發者的一種類加載器實現方式。

雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試自己去加載。

使用雙親委派模型來組織類加載器之間的關係,有一個顯而易見的好處就是Java類隨着它的類加載器一起具備了一種帶有優先級的層次關係。例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類加載器要加載這個類,最終都是委派給處於模型最頂端的啓動類加載器進行加載,因此Object類在程序的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱爲java.lang.Object的類,並放在程序的ClassPath中,那系統中將會出現多個不同的Object類,Java類型體系中最基礎的行爲也就無法保證,應用程序也將會變得一片混亂。如果讀者有興趣的話,可以嘗試去編寫一個與rt.jar類庫中已有類重名的Java類,將會發現可以正常編譯,但永遠無法被加載運行。

雙親委派模型對於保證Java程序的穩定運作很重要,但它的實現卻非常簡單,實現雙親委派的代碼都集中在java.lang.ClassLoader的loadClass()方法之中,如代碼清單7-10所示,邏輯清晰易懂:先檢查是否已經被加載過,若沒有加載則調用父加載器的loadClass()方法,若父加載器爲空則默認使用啓動類加載器作爲父加載器。如果父類加載失敗,拋出ClassNotFoundException異常後,再調用自己的findClass()方法進行加載。
這裏寫圖片描述

7.4.3 破壞雙親委派模型

上文提到過雙親委派模型並不是一個強制性的約束模型,而是Java設計者推薦給開發者的類加載器實現方式。在Java的世界中大部分的類加載器都遵循這個模型,但也有例外,到目前爲止,雙親委派模型主要出現過3較大規模的“被破壞”情況。

雙親委派模型的第一次“被破壞”其實發生在雙親委派模型出現之前——即JDK 1.2發佈之前。由於雙親委派模型在JDK 1.2之後才被引入,而類加載器和抽象類java.lang.ClassLoader則在JDK 1.0時代就已經存在,面對已經存在的用戶自定義類加載器的實現代碼,Java設計者引入雙親委派模型時不得不做出一些妥協。爲了向前兼容,JDK 1.2之後的java.lang.ClassLoader添加了一個新的protected方法findClass(),在此之前,用戶去繼承java.lang.ClassLoader的唯一目的就是爲了重寫loadClass()方法,因爲虛擬機在進行類加載的時候會調用加載器的私有方法loadClassInternal(),而這個方法的唯一邏輯就是去調用自己的loadClass()。

上一節我們已經看過loadClass()方法的代碼,雙親委派的具體邏輯就實現在這個方法之中,JDK 1.2之後已不提倡用戶再去覆蓋loadClass()方法,而應當把自己的類加載邏輯寫到findClass()方法中,在loadClass()方法的邏輯裏如果父類加載失敗,則會調用自己的findClass()方法來完成加載,這樣就可以保證新寫出來的類加載器是符合雙親委派規則的。

雙親委派模型的第二次“被破壞”是由這個模型自身的缺陷所導致的,雙親委派很好地解決了各個類加載器的基礎類的統一問題(越基礎的類由越上層的加載器進行加載),基礎類之所以稱爲“基礎”,是因爲它們總是作爲被用戶代碼調用的API,但世事往往沒有絕對的完美,如果基礎類又要調用回用戶的代碼,那該怎麼辦?

這並非是不可能的事情,一個典型的例子便是JNDI服務,JNDI現在已經是Java的標準服務,它的代碼由啓動類加載器去加載(在JDK 1.3時放進去的rt.jar),但JNDI的目的就是對資源進行集中管理和查找,它需要調用由獨立廠商實現並部署在應用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代碼,但啓動類加載器不可能“認識”這些代碼啊!那該怎麼辦?

爲了解決這個問題,Java設計團隊只好引入了一個不太優雅的設計:線程上下文類加載器(Thread Context ClassLoader)。這個類加載器可以通過java.lang.Thread類的setContextClassLoaser()方法進行設置,如果創建線程時還未設置,它將會從父線程中繼承一個,如果在應用程序的全局範圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。

有了線程上下文類加載器,就可以做一些“舞弊”的事情了,JNDI服務使用這個線程上下文類加載器去加載所需要的SPI代碼,也就是父類加載器請求子類加載器去完成類加載的動作,這種行爲實際上就是打通了雙親委派模型的層次結構來逆向使用類加載器,實際上已經違背了雙親委派模型的一般性原則,但這也是無可奈何的事情。Java中所有涉及SPI的加載動作基本上都採用這種方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

雙親委派模型的第三次“被破壞”是由於用戶對程序動態性的追求而導致的,這裏所說的“動態性”指的是當前一些非常“熱門”的名詞:代碼熱替換(HotSwap)、模塊熱部署(Hot Deployment)等,說白了就是希望應用程序能像我們的計算機外設那樣,接上鼠標、U盤,不用重啓機器就能立即使用,鼠標有問題或要升級就換個鼠標,不用停機也不用重啓。對於個人計算機來說,重啓一次其實沒有什麼大不了的,但對於一些生產系統來說,關機重啓一次可能就要被列爲生產事故,這種情況下熱部署就對軟件開發者,尤其是企業級軟件開發者具有很大的吸引力。

Sun公司所提出的JSR-294[1]、JSR-277[2]規範在與JCP組織的模塊化規範之爭中落敗給JSR-291(即OSGi R4.2),雖然Sun不甘失去Java模塊化的主導權,獨立在發展Jigsaw項目,但目前OSGi已經成爲了業界“事實上”的Java模塊化標準[3],而OSGi實現模塊化熱部署的關鍵則是它自定義的類加載器機制的實現。每一個程序模塊(OSGi中稱爲Bundle)都有一個自己的類加載器,當需要更換一個Bundle時,就把Bundle連同類加載器一起換掉以實現代碼的熱替換。

在OSGi環境下,類加載器不再是雙親委派模型中的樹狀結構,而是進一步發展爲更加複雜的網狀結構,當收到類加載請求時,OSGi將按照下面的順序進行類搜索:
1)將以java.*開頭的類委派給父類加載器加載。
2)否則,將委派列表名單內的類委派給父類加載器加載。
3)否則,將Import列表中的類委派給Export這個類的Bundle的類加載器加載。
4)否則,查找當前Bundle的ClassPath,使用自己的類加載器加載。
5)否則,查找類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類加載器加載。
6)否則,查找Dynamic Import列表的Bundle,委派給對應Bundle的類加載器加載。
7)否則,類查找失敗。
上面的查找順序中只有開頭兩點仍然符合雙親委派規則,其餘的類查找都是在平級的類加載器中進行的。

筆者雖然使用了“被破壞”這個詞來形容上述不符合雙親委派模型原則的行爲,但這裏“被破壞”並不帶有貶義的感情色彩。只要有足夠意義和理由,突破已有的原則就可認爲是一種創新。正如OSGi中的類加載器並不符合傳統的雙親委派的類加載器,並且業界對其爲了實現熱部署而帶來的額外的高複雜度還存在不少爭議,但在Java程序員中基本有一個共識:OSGi中對類加載器的使用是很值得學習的,弄懂了OSGi的實現,就可以算是掌握了類加載器的精髓。

7.5 本章小結

本章介紹了類加載過程的“加載”、“驗證”、“準備”、“解析”和“初始化”5個階段中虛擬機進行了哪些動作,還介紹了類加載器的工作原理及其對虛擬機的意義。

經過第6和第7兩章的講解,相信讀者已經對如何在Class文件中定義類,如何將類加載到虛擬機中這兩個問題有了比較系統的瞭解,第8章我們將一起來看看虛擬機如何執行定義在Class文件裏的字節碼。

發佈了105 篇原創文章 · 獲贊 227 · 訪問量 44萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章