類加載各階段詳解


搬運自《Java 虛擬機(第二版)》

深入了解虛擬機-類加載

類從被加載到虛擬機內存中開始,到卸載出內存位置,他的整個生命周期包括:加載(Loading)、驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Uploading)7個階段。其中驗證、准備、解析3個部分統稱為連接。

類加載中初始化的時機

虛擬機規范中嚴格規定了有且只有5中情況必須立即對類進行"初始化"(而加載、驗證、准備自然需要在此之前)
(1)遇到 new、getstatic、putstatic 或 invokestatic 這4條字節碼指令時,如果類沒有進行過初始化,則需要觸發其初始化。生成這4條指令的最常見的 Java 代碼場景是:使用 new 關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被 final 修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候;
(2)使用 java.lang.relflect 包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化;
(3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化;
(4)當虛擬機啟動時,用戶需要制定一個要執行的主類(包含 main() 方法的那個類),虛擬機會先初始化這個主類;
(5)當使用 JDK1.7 的動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最后的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

上面5種場景中的行為稱為對類進行主動應用。除此之外,其他所有引用類的方式都不會觸發初始化,稱為被動引用。比如下面三種情況:
(1)被動引用例一:通過子類引用父類的靜態字段,不會導致子類的初始化

/**
* 通過子類引用父類的靜態字段,不會導致子類的初始化
**/
public class SuperClass{
	public static int value = 123;
	static{
		System.out.println("SuperClass init!");
	}
}
public class SubClass{
	static{
		System.out.println("SubClass init!");
	}
}
/**
* 非主動使用類字段演示
*/
public class NotInitialization{
	public static void main(String[] args){
		System.out.println(SubClass.value);    // SuperClass init!
	}
}

對於靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。至於是否要觸發子類的加載和驗證,在虛擬機規范中並未明確規定,這點取決於虛擬機的具體實現。對於 Sun HotSpot 虛擬機來說,可通過 -XX:+TraceClassLoading 參數觀察到此操作會導致子類的加載(這里的加載指的是將二進制字節流讀取到內存中的過程,而不是類加載。前者是后者的一個階段)。

(2)被動引用例二:通過數組定義來引用類,不會觸發此類的初始化

/**
* 通過數組定義來引用類,不會觸發此類的初始化
**/
public class NotInitialization{
	public static void main(String[] args){
			SuperClass[] sca = new SuperClass[10];
	}
}

這邊沒有輸出 "SuperClass init!",說明沒有觸發 SuperClass 的初始化階段。但是這段代碼里面觸發了另外一個名為 “[Lxxx.xxx.SuperClass”的類的初始化階段,對於用戶代碼來說,這並不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承於 java.lang.Object 的子類,創建動作由字節碼指令 newarray 觸發。
這個類代表可一個元素類型為 xxx.xxx.SuperClass 的一維數組,數組中應有的屬性和方法(用戶可直接使用的只有被修飾為 public 的 length 屬性和 clone() 方法)都實現在這個類里。 Java 語言中對數組的訪問比 C/C++ 更加安全,是因為這個類封裝了數組元素的訪問方法,而 C/C++ 直接是數組指針的移動。在 Java 語言中,當檢查到發生數組越界時,會拋出 ArrayIndexOutOfBoundsException 異常。

(3)被動引用例三:常量在編一階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化

/**
* 常量在編一階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化
**/
public class ConstClass {
	public static final String HELLO_WORLD = "hello world";
	static {
		System.out.println("ConstClass init!");
	}
}
/**
* 非主動使用類字段演示
**/
public class Test{
	public static void main(String[] args) {
		System.out.println(ConstClass.HELLO_WORLD);
	}
}

上述代碼運行之后,也沒有輸出 "ConstClass init!",這是因為雖然在 Java 源碼中引用了 ConstClass 類中的常量 HELLO_WORLD,但其實在編譯階段通過常量傳播優化,已經將此常量的值 "hello world 存儲到了 Test 類的常量池中,以后 Test 對常量 ConstClass.HELLO_WORLD 的引用實際都被轉化為 Test 對自身常量池的引用了。也就是說,實際上 Test 的 class 文件中並沒有 ConstClass 類的符號引用入口,這兩個類在編譯成 Class 之后就不存在聯系了。

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


類加載的過程

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

加載

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

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

虛擬機規范的這三點要求其實並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的。例如“通過一個類的全限定名來獲取此類的二進制字節流”這條,它沒有指明二進制字節流要從一個一個 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 將在加載該組件類型的類加載器的類名稱空間上被標識(這點很重要,一個類必須與類加載器一起確定唯一性);
  • 如果數組的組件類型不是引用類型(例如int[]數組),Java 虛擬機會把數組 C 標記為與引導類加載器關聯;
    數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為 public。
    加載階段完成后,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規范未規定此區域的具體數據結構。然后在內存中實例化一個java.lang.Class類的對象(並沒有明確規定是在 Java 堆中,對於 HotSpot 虛擬機而言,Class 對象比較特殊,它雖然是對象,但是存放在方法區里面),這個對象將作為程序訪問方法區中的這些類型數據的外部接口。
    加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先后順序。

驗證

驗證是連接階段的第一步,這一階段的目的是為了確保 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 修飾的類);
  • 如果這個類不是抽象類,是否實現了其父類或接口中要求實現的所有方法;
  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的 fianl 字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等);

第二階段的主要目的是對類的元數據信息進行語義檢驗,保證不存在不符合 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、default、protected、public)是否可被當前類訪問;

符號應用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那么將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError等。
對於虛擬機的類加載機制來說,驗證階段是一個非常重要的,但不是一定必要的(因為對程序運行期沒有影響)的階段。如果所運行的代碼(包括自己編寫的及第三方包中的代碼)都已經被反復使用和驗證過,那么在實施階段就可以考慮使用 -Xverify:none 參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

准備

准備階段是正式為類變量分配內存並設置類變量初始值的極端,這些變量所使用的的內存都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這時候進行內存分配的僅包括類變量(被 static 修飾的變量)而不包括實例變量,實例變量將在對象實例化時隨着對象一起分配在 Java 堆中。其次,這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:public static int value = 123;,那變量 value 在准備階段過后的初始值為 0 而不是 123,因為這時候尚未開始執行任何 Java 方法,而把 value 賦值為 123 的 putstatic 指令是程序被編譯后,存放於類構造器<clinit>()方法之中,所以把 value 賦值為 123 的動作將在初始化階段才會執行。下表列出了 Java 中所有基本數據類型的零值:
|---|---|

數據類型 零值
int 0
long 0L
short (short)0
char '\u0000'
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

上面提到,在“通常情況下”初始值是零值,那相對的會有一些“特殊情況”:如果類字段的字段屬性表中存在 ConstantVlaue 屬性,那么在准備階段變量 value 就會被初始化為 ConstantValue 屬性所指定的值,假設上面類變量 value 的定義變為public static final int value = 123;,編譯時 Javac 將會為 value 生成 ConstantValue 屬性,在准備階段虛擬機就會根據 ConstantValue 的設置將 value 賦值為 123。

解析

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

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以使任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標並不一定已經加載到內存中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在 Java 虛擬機規范的 Class 文件格式中。
  • 直接引用(Direct References):直接引用可以使直接指向目標的指針、相對偏移量或是一個能直接定位到目標的句柄。直接引用適合虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實力上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。

虛擬機規范之中並未規定解析發生的具體時間,只要求了在執行anewarraycheckcastgetfieldgetstaticinstanceofinvokedynamicinvokeinterfaceinvokespecialinvokestaticinvokevirtualldcldc_wmultianewarraynewputfieldputstatic 這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_MethodHabdle_info 和 CONSTANT_InvokeDynamic_info 7種常量類型。下面將講解前面4種引用的解析過程,對於后面3種,與 JDK 1.7 新增的動態語言支持息息相關,由於 Java 語言是一門靜態類型語言,因此在沒有介紹 invokedynamic指令的語義之前,沒有辦法將他們和現在的 Java 語言對應上,筆者將在介紹動態語言調用時一起分析講解。
1、類或接口的解析
假設當前代碼所處的類為 D ,如果要把一個從未解析過的符號引用 N 解析為一個類或接口 C 的直接引用,那虛擬機完成整個解析的過程需要以下3個步驟:

  • 如果 C 不是一個數組類型,那虛擬機將會把代表 N 的全限定名傳遞給 D 的類加載器去加載這個類 C 。在加載過程中,由於元數據驗證、字節碼驗證的需要,又可能觸發其他相關類的加載動作,例如加載這個類的父類或實現的接口。一旦這個加載過程出現了任何異常,解析動作就宣告失敗;
  • 如果 C 是一個數組類型,並且數組的元素類型是對象,也就是 N 的描述符會是類似“Ljava/lang/Integer”的形式,那將會按照第1點的規則加載數組元素類型。如果 N 的描述符如前面所假設的形式,需要加載的元素類型就是 “java.lang.Integer”,接着由虛擬機生成一個代表此數組維度和元素的數組對象;
  • 如果上面的步驟沒有出現任何異常,那么 C 在虛擬機中實際上已經成為了一個有效的類或者接口了,但在解析完成之前還要進行符號引用驗證,確認 D 是否具備對 C 的訪問權限。如果發現不具備訪問權限,將拋出 java.lang.IllegalAccessError異常。
    2、字段解析
    要解析一個未被解析過的字段符號引用,首先將對字段表內 class_index 項中索引的 CONSTANT_Class_info 符號引用進行解析,也就是字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現了任何異常,都會導致字段符號引用解析的失敗。如果解析成功完成,那將這個字段所屬的類或接口用 C 表示,虛擬機規范要求按照如下步驟對 C 進行后續字段的搜索:
  • 如果 C 本身就包含了簡單名稱和字符描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束;
  • 否則,如果在 C 中實現了接口,將會按照繼承關系從下往上遞歸所搜各個接口和它的父接口,如果接口中包含了簡單名稱和字符描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束;
  • 否則,如果 C 不是java.lang.Object的話,將會按照繼承關系從下往上搜索遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束;
  • 否則,查找失敗,拋出java.lang.NoSuchFieldError異常。

如果在查找過程中成功返回了引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,將拋出java.lang.IllegalAccessError異常。
在實際應用中,虛擬機的編譯器實現可能會比上述規范要求的更加嚴格一些,如果有一個同名字段同時出現在 C 的接口和父類中,或者同時在自己或父類的多個接口中出現,那編譯器將可能拒絕編譯。在下面代碼中,如果注釋了 Sub 類中的public static int A=4;,接口與父類同時存在字段 A ,那編譯器將提示“ The field Sub.A is ambiguous”,並且拒絕編譯這段代碼。

pakage org.fenixsoft.classloading;
public class FieldResolution{
	interface Interface0{
		int A = 0;
	}
	interface Interface1 extends Interface0{
		int A = 1;
	}
	interface Interface2{
		int A = 2;
	}
	static class Parent implements Interface1{
		public static int A = 3;
	}
	static class Sub extend Parent implements Interface2{
		public static int A = 4;
	}
	public static void main(String[] args){
		System.out.println(Sub.A);
	}
}

3、類方法解析
類方法解析的第一個步驟與字段解析一樣,也需要先解析出類方法表的 class_index 項中索引的方法所屬的類或接口的符號引用,如果解析成功,我們依然用 C 表示這個類,接下來虛擬機將會按照如下步驟進行后續的類方法搜索:

  • 類方法和接口方法符號引用時常量類型定義是分開的,如果在類方法表中發現 class_index 中索引的 C 是個接口,那就直接拋出java.lang.IncompatibleClassChangeError異常;
  • 如果通過了第一步,在類 C 中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束;
  • 否則,在類 C 的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束;
  • 否則,在類 C 實現的接口列表及他們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類 C 是一個抽象類,這時查找結束,拋出java.lang.AbstractMethodError異常;
  • 否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError
    最后,如果查找過程成功返回了直接引用,將會對這個方法進行權限驗證,如果發現不具備對此方法的訪問權限,將拋出java.lang.IllegalAccessError異常。

4、接口方法解析
接口方法也需要先解析出接口方法表的 class_index 項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用 C 表示這個接口,接下來虛擬機將會按照如下步驟進行后續的接口方法搜索:

  • 與類方法解析不同,如果在接口方發表中發現 class_index 中的索引 C 是個類而是不是接口,那就直接拋出 java.lang.IncompatibleClassChangeError異常;
  • 否則,在接口 C 中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束;
  • 否則,在接口 C 的父接口中遞歸查找,直到 java.lang.Object類(查找范圍會包括 Object 類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束;
  • 否則,宣告方法查找失敗,拋出 java.lang.NoSuchMethodError異常。
    由於接口中的所有方法默認都是 public 的,所以不存在訪問權限的問題,因此接口方法的符號解析應當不會拋出java.lang.IllegalAccessError異常。

初始化

類初始化階段是類加載過程的最后一步,前面的類家在過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的 Java 程序代碼(或者說字節碼)。
在准備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計划去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。我們在下文會講解<clinit>()方法是怎么生成的,在這里,我們先看一下<clinit>()方法執行過程中一些可能會影響程序運行行為的特點和細節,這部分相對更貼近普通的程序開發人員。
<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合並而成的,編譯器收集的順序是語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值,但不能訪問,如下面代碼所示:

public class Test{
	static{
		i = 0;    //給變量賦值可以正常編譯
		System.out.println(i);    //這句編譯器會提示“非法向前引用”
	}
	static int i = 1;
}

<clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機第一個被執行的<clinit>()方法的類肯定是java.lang.Object

由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作,如下面代碼中,字段 B 的值將會是2而不是1。

static class Parent{
	public static int A = 1;
	static{
		A = 2;
	}
}

static class Sub extends Parent{
	public static int B = A;
}
public static void main(String[] args){
	System.out.println(Sub.B);
}

<clinit>()方法糴類或接口來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。
接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。
虛擬機會保證一個類的<clinit>()在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,知道活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個線程(原書是進程???)阻塞,在實際應用中這種阻塞往往是很隱蔽的。下面代碼演示了這種場景:

static class DeadLoopClass{
	static{
		/*如果不加上這個if語句,編譯器將提示“Initializer does not complete normally”並拒絕編譯*/
		if(true){
			System.out.println(Thread.currentThread() + "init DeadLoopClass");
			while(true){
			}
		}
	}
}
public static void main(String[] args){
	Runnable script = new Runnable(){
		public void run(){
			System.out.println(Thread.currentThread() + "start");
			DeadLoopClass dlc = new DeadLoopClass();
			System.out.println(Thread.currentThread() + "run over");
		}
	};
	Thread thread1 = new Thread(script);
	Thread thread2 = new Thread(script);
	thread1.start();
	thread2.start();
}

運行結果如下,即一條線程在死循環以模擬長時間操作,另外一條線程在阻塞等待:

Thread[Thread-0,5,main] start
Thread[Thread-1,5,main] start
Thread[Thread-0,5,main] init DeadLoopClass


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM