7.1 概述
我們知道,在Class檔案中描述的各種資訊,最終都需要加載到虛拟機中之後才能運作和使用。而虛拟機如何加載這些Class檔案?Class檔案中的資訊進入到虛拟機後會發生什麼變化?
虛拟機的類加載機制:
虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型。
與那些在編譯時需要進行連接配接工作的語言(比如C語言)不同,Java語言裡面,類型的加載、連接配接和初始化過程都是在程式運作期間完成的。
這種政策雖然會令類加載時稍微增加一些性能開銷,但是會為Java應用程式提供高度的**靈活性,Java裡天生可以動态擴充的語言特性就是依賴運作期動态加載和動态連接配接這個特點實作的。**
注意這本書這裡有兩個語言上的設定:
- 在實際情況中,每個Class檔案都有可能代表着Java語言中的一個類或接口。
- “Class檔案”應當指的是一串二進制的位元組流,無論以何種形式存在都可以。
7.2 類加載的時間
類從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期包括:
- 加載(Loading)
- 驗證(Verification)
- 準備(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 解除安裝(Unloading)
值得注意的是:加載->驗證->準備->初始化->解除安裝這5個階段的順序是确定的,類的加載過程必須按照這種順序按部就班地開始
而解析階段則不一定:它某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的運作時綁定(也稱為動态綁定或晚期綁定)。
虛拟機規範嚴格規定了有且隻有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):
- 遇到new、getstatic、putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行初始化,則需要先觸發其初始化。
new:使用new關鍵字執行個體化對象的時候
putstatic:設定一個類的靜态字段(被final修飾、已在編譯器把結果放入常量池的靜态字段除外)
getstatic:讀取一個類的靜态字段(被final修飾、已在編譯器把結果放入常量池的靜态字段除外)
invokestatic:調用一個類的靜态方法的時候
- 使用java.lang.reflect包的方法對類進行**反射調用**的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其**父類**還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛拟機啟動時,使用者需要制定一個要執行的**主類**(包含main()方法的那個類),虛拟機會先初始化這個類。
- 當使用JDK1.7的動态語言支援時,如果一個java.lang.invoke.MethodHandle執行個體最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對對應的類沒有進行過初始化,則需要先觸發其初始化。
有且隻有以上這5種場景中的行為稱為對一個類進行主動引用。
除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。
被動引用的例子之一:
package org.fenixsoft.classloading;
/**
* 被動使用類字段示範一:
* 通過子類引用父類的靜态字段,不會導緻子類初始化
*/
class SuperClass{
static{
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass{
static{
System.out.println("SubClass init!");
}
}
/**
* 非主動使用類字段示範
*/
public class NotInitialization {
public static void main(String[] args){
System.out.println(SubClass.value);
}
}
運作結果:
-
SuperClass init!
123
**對于靜态字段,隻有直接定義這個字段的類才會被初始化,是以通過其子類來引用父類中定義的靜态字段,隻會觸發父類的初始化而不會觸發子類的初始化。**至于是否要觸發子類的加載和驗證,在虛拟機規範中并未明确規定,這點取決于虛拟機的具體實作。
被動引用的例子之二:
public class NotInitialization {
public static void main(String[] args){
SuperClass[] sca = new SuperClass[10];
}
}
運作結果:
- 啥都沒有
說明并沒有觸發類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異常。
被動引用的例子之三:
/**
* 被動使用類字段示範三:
* 常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,是以不會觸發定義常量類的初始化
*/
class ConstClass{
static{
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
/**
* 非主動使用類字段示範
*/
public class NotInitialization {
public static void main(String[] args){
System.out.println(ConstClass.HELLOWORLD);
}
}
運作結果:
- hello world
上述代碼運作之後,也沒有輸出“ConstClass init!”,這是因為雖然在Java源碼中引用了ConstClass類中的常量HELLOWORLD,但其實在編譯階段通過常量傳播優化,已經将此常量的值“hello world”存儲到了NotInitialization的引用實際都被轉化為NotInitialization類對自身常量池的引用了。
也就是說,實際上NotInitialization的Class檔案之中并沒有ConstClass類的符号引用入口,這兩個類在編譯成Class之後就不存在任何聯系了。
值得一提:
接口也有初始化過程,這點與類是一緻的,接口中不能使用“static{}”語句塊,但編譯器仍然會為接口生成“()”類構造器,用于初始化接口中所定義的成員變量。
接口與類真正有所差別的是:
當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,隻有在真正使用到父類接口的時候(如引用接口中定義的常量)才會初始化。
7.3 類加載的過程
加載、驗證、準備、解析和初始化這5個階段所執行的具體動作。
7.3.1 加載 ,這是類加載(Class Loading)過程的一個階段
在加載階段,虛拟機需要完成以下3件事情:
- 通過一個類的**全限定名**來擷取定義此類的二進制位元組流
- 将這個位元組流所代表的的靜态存儲結構轉化為方法區的**運作時資料結構**
- 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的**通路入口**。
虛拟機實作與具體應用的靈活度都是相當大的。
例如:通過一個類的全限定名來擷取定義此類的二進制位元組流->它沒有指明二進制位元組流要從一個Class檔案中擷取,準确地說是根本沒有指明要從哪裡擷取、怎樣擷取。
相對于類加載的其他階段:
一個非數組類的加載階段(準确地說,是加載階段中擷取類的二進制位元組流的動作)是開發人員可控性最強的。
因為加載階段既可以使用:
- 系統提供的引導類加載器來完成
- 也可以由使用者自定義的類加載器去完成,開發人員可以通過定義自己的類加載器去控制位元組流的擷取方式(即重寫一個類加載器的loadClass()方法)。
對于數組類而言,數組類本身不通過類加載器建立,它是由Java虛拟機直接建立的。但數組類與類加載器仍然有很密切的關系,因為數組類的元素類型(Element Type,指的是數組去掉所有次元的類型)最終是要靠類加載器去建立。
一個數組類(下面簡稱為C)建立過程就遵循以下規則:
- 如果數組的元件類型(Component Type,指的是數組去掉一個次元的類型)是引用類型,那就遞歸采用本節中定義的加載過程去加載這個元件類型,數組C将在加載該元件類型的類加載器的類名稱空間上被辨別。->确定唯一性
- 如果數組的元件類型不是引用類型(例如int[]數組),Java虛拟機将會把數組C标記為與引導類加載器關聯。
- 數組類的可見性與它的元件類型的可見性一直,如果元件類型不是引用類型,那數組類的可見性将預設為public。
加載階段完成後,虛拟機外部的二進制位元組流就按照虛拟機所需的格式存儲在**方法區**之中,方法區中的資料存儲格式由虛拟機實作自行定義,虛拟機規範未規定此區域的具體資料結構。
然後在記憶體中執行個體化一個java.lang.Class類的對象(并沒有明确規定是在Java堆中,對于HotSpot虛拟機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區裡面),這個對象将作為程式通路方法區中的這些類型資料的外部接口。
加載階段與連接配接階段的部分内容(如一部分位元組碼檔案格式驗證動作)是交叉進行的,加載階段尚未完成,連接配接階段可能已經開始。
但這些夾在加載階段之中進行的動作,仍然屬于連接配接階段的内容,這兩個階段的開始時間仍然保持着固定的先後順序。
7.3.2 驗證
驗證是連接配接階段的第一步,這一階段的目的是為了確定Class檔案的位元組流中包含的資訊符合目前虛拟機的要求,并且不會危害虛拟機自身的安全。
虛拟機如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有害的位元組流(如越界的數組通路)而導緻系統崩潰,是以驗證是虛拟機對自身保護的一項重要工作。
驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛拟機是否能承受惡意代碼的攻擊,從執行性能的角度上講,驗證階段的工作量在虛拟機的類加載子系統中又占了相當大的一部分,如果驗證到輸入的位元組流不符合Class檔案格式的限制,虛拟機就應抛出一個java.lang.VerifyError異常或其子類異常。
從整體上來看,驗證階段大緻上會完成下面4個階段的檢驗動作:檔案格式驗證、中繼資料驗證、位元組碼驗證、符号引用驗證。
1.檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理。
該驗證階段的主要目的是保證輸入的位元組流能正确地解析并存儲于方法區之内,格式上符合描述一個Java類型資訊的要求。
這階段的驗證是基于二進制位元組流進行的,隻有通過了這個階段的驗證後,位元組流才會進入記憶體的方法區中進行存儲,是以後**面的3個驗證階段全部是基于方法區的存儲結構進行的**,不會再直接操作位元組流。
2.中繼資料驗證:是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求。
第二階段的主要目的是對類的中繼資料資訊進行語義校驗,保證不存在不符合Java語言規範的中繼資料資訊。
3.位元組碼驗證:整個驗證過程中最複雜的一個階段,主要目的是通過資料流和控制流分析,确定程式語義是合法的、符合邏輯的。這個階段将對類的方法體進行校驗分析,保證被校驗類的方法在運作時不會做出危害虛拟機安全的事件。
如果一個類方法體的位元組碼沒有通過位元組碼驗證,那肯定是有問題的。但如果一個方法體通過了位元組碼驗證,也不能說明其一定就是安全的。
4.符号引用驗證:最後一個階段的校驗發生在虛拟機将符号引用轉化為直接引用的時候,這個轉化動作将在連接配接的第三階段-解析階段中發生。
符号引用驗證的目的是確定解析動作能正常執行,如果無法通過符号引用驗證,那麼将會抛出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
7.3.3 準備
準備階段是**正式為類變量配置設定記憶體并設定類變量初始值的階段,這些變量所使用的記憶體都将在方法區中進行配置設定。**
注意:
- 這個時候進行記憶體配置設定的僅包括類變量(被static修飾的變量),而不包括執行個體變量,執行個體變量将會在對象執行個體化時随着對象一起配置設定在Java堆中。
- 其次,這裡所說的初始值“通常情況”下是資料類型的零值。
7.3.4 解析
解析階段是虛拟機将常量池内的符号引用替換為直接引用的過程。
符号引用(Symbolic References):符号引用以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時無歧義地定位到目标即可。
直接引用(Direct Reference):直接引用可以是直接指向目标的指針、相對偏移量或是一個能間接定位到目标的句柄。如果有了直接引用。那引用的目标必定已經在記憶體中存在。
7.3.5 初始化
類初始化階段是類加載過程的最後一步,前面的類加載過程中,除了在加載階段使用者應用程式可以通過自定義類加載器參與之外,其餘動作完全由虛拟機主導和控制。
到了初始化階段,才真正開始執行類中定義的**Java程式代碼(或者說位元組碼)**。
在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程式員通過程式制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器()方法的過程。
()方法是由編譯器自動收集類中的所有類變量的複制動作和靜态語句塊(static{}塊)中的語句合并産生的,編譯器**收集的順序是由語句在源檔案中出現的順序**所決定的。
靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句塊可以指派,但是不能通路。
public class Teat{
static{
i = 0; //這個static{}塊無法通路到之後的i,是以無法指派。
System.out.print(i);
}
static int i = 1;
}
()方法與類的構造函數(或者說執行個體構造器()方法)不同,它不需要顯示地調用父類構造器,虛拟機會保證在子類的()方法執行之前,父類的()方法以及執行完畢。
是以在虛拟機中第一個被執行的()方法的類肯定是java.lang.Object。
由于父類的()方法先執行,也就意味着父類中定義的靜态語句塊要優先于子類的變量指派操作。看如下代碼:B的結果為2
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);
}
()方法對于類或接口來說并不是必需的,如果一個類中沒有靜态語句塊,也沒有對變量的指派操作,那麼編譯器可以不為類生成()方法。
接口中不能使用靜态語句塊,但仍然有變量初始化的指派操作。但接口與類不同的是,執行接口的()方法不需要先執行父接口的()方法。隻有當父接口中定義的變量使用時,父接口才會初始化,另外,接口的實作類在初始化時也一樣不會執行接口的()方法。
虛拟機會保證一個類的()方法在多線程環境中被正确地加鎖、同步,如果多個線程同時去初始化一個類,那麼隻會有一個線程去執行這個類的()方法,其他線程都需啊喲阻塞等待,直到活動線程執行()方法完畢。
package chapter7;
public class Test {
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() {
@Override
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
也就是說有一條線程在死循環以模拟長時間操作,另外一條線程在阻塞等待。