天天看點

《深入了解Java虛拟機》學習筆記之類加載機制總結

虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型,這就是虛拟機的類加載機制。

類從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期包括:加載、驗證、準備、解析、初始化、使用和解除安裝7個階段。其中驗證、準備、解析3個部分統稱為連接配接。如下圖:

《深入了解Java虛拟機》學習筆記之類加載機制總結

類加載的7個階段已經知道了,那什麼情況下需要開始類加載過程的第一個階段呢?對此,Java虛拟機規範中并沒有進行強制限制,這點由虛拟機的具體實作來自由把握。但是對于初始化階段,虛拟機規範則是嚴格規定了有且隻有5種情況必須立即對類進行初始化,而加載、驗證、準備自然需要在此之前開始:

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

對于這5種會觸發類進行初始化的場景,虛拟機規範中使用了一個很強烈的限定語:有且隻有,這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用:

  1. 通過子類引用父類的靜态字段,不會導緻子類初始化,對于靜态字段,隻有直接定義這個字段的類才會被初始化,是以通過其子類來引用父類中定義的靜态字段,隻會觸發父類的初始化而不會觸發子類的初始化。至于是否要觸發子類的加載和驗證,在虛拟機規範中并未明确規定,這點取決于虛拟機的具體實作。
  2. 通過數組定義來引用類,不會觸發此類的初始化,但會觸發另外一個名為“[L+此類的完整類名”的類的初始化,對于使用者代碼來說,這并不是一個合法的類名稱,它是一個由虛拟機自動生成的、直接繼承于java.lang.Object的子類,建立動作由位元組碼指令newarray觸發。這個類代表了一個元素類型為此類的一維數組,數組中應有的屬性和方法都實作再這個類中。
  3. 通過引用某個類的靜态常量字段,不會導緻該定義常量類的初始化。常量在編譯階段會存入調用類的常量池,本質上并沒有直接引用到定義常量的類,是以不會觸發定義常量的類的初始化。

接口的加載過程與類加載過程稍微有一些不同,針對接口需要做一些特殊說明:接口也有初始化過程,這點與類是一緻的,有所差別的的是上面講述的5種有且隻有需要開始初始化場景中的第3種:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,隻有真正使用到父接口的時候才會初始化。

下面我們從開發者的角度來一一總結以上7個步驟做了哪些事。

首先來看加載。對于加載這步驟,虛拟機需要完成以下3件事情:

  1. 通過一個類的全限定名來擷取定義此類的二進制位元組流。
  2. 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構。
  3. 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口。

相對于類加載過程的其他階段,一個非數組類的加載階段是開發人員可控性最強的,因為加載階段既可以使用系統提供的引導類加載器來完成,也可以由使用者自定義的類加載器去完成。對于數組類而言,數組類本身不通過類加載器建立,它由Java虛拟機直接建立的。但是數組類與類加載器仍然有很密切的關系,因為數組類的元素類型,指的是數組去掉所有次元的類型,最終是要靠類加載器去建立的,一個數組類建立過程要遵循以下規則:

  1. 如果數組的元件類型是引用類型,那就遞歸采用上述所說3件事情的那些加載過程去加載這個元件類型,數組将在加載該元件類型的類加載器的類名稱空間上被辨別。
  2. 如果數組的元件類型不是引用類型,Java虛拟機将會把數組标記為與引導類加載器關聯。
  3. 數組類的可見性與它的元件類型的可見性一緻,如果元件類型不是引用類型,那數組類的可見性将預設為public。

以上就是加載過程,接下來是連接配接過程(驗證、準備、解析)所需要完成的事情,先說說驗證吧。 驗證階段是連接配接階段的第一步,目的是為了確定class檔案的位元組流中包含的資訊符合目前虛拟機的要求,并且不會危害虛拟機自身的安全。驗證階段大緻完成檔案格式驗證、中繼資料驗證、位元組碼驗證、符号引用驗證這4個階段的檢驗,有興趣的童鞋可以去《深入了解Java虛拟機》一書中詳細解讀。 準備階段是正式為類變量配置設定記憶體并設定類變量初始值的階段,這些變量所使用的記憶體都将在方法區中進行配置設定。在這個階段中有兩個容易産生混淆的概念。首先,這個時候進行記憶體配置設定的僅包括類變量,而不包括執行個體變量,執行個體變量将會在對象執行個體化時随着對象一起配置設定在Java堆中。其次,這裡所說的初始值通常情況下是資料類型的零值。假設一個類變量的定義為: public static int value = 123; 那變量value在準備階段過後的初始值為0而不是123,因為這時候尚未開始執行溫和Java方法,而把value指派為123的putstatic指令時程式被編譯後,存放于類構造器<clinit>()方法之中,是以把value指派為123的動作将在初始化階段才會執行。既然上面提到這是通常情況,那麼肯定也會有特殊情況:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化為ConstantValue屬性所指定的值,假設上面變量value的定義變為: public static final int value = 123; 編譯時Javac将會為value生成ConstantValue屬性,在準備階段虛拟機就會根據ConstantValue的設定将value指派為123。 解析階段是虛拟機将常量池内的符号引用替換為直接引用的過程,符号引用在上面也一筆帶過了,那麼下面就一起來解釋下符号引用和直接引用:

符号引用:符号引用以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義地定位到目标即可。符号引用與虛拟機實作的記憶體布局無關,引用的目标并不一定已經加載到記憶體中。各種虛拟機實作的記憶體布局可以各不相同,但是它們能接受的符号引用必須都是一緻的,因為符号引用的字面量形式明确定義在Java虛拟機規範的Class檔案格式中。
直接引用:直接引用可以是直接指向目标的指針、相對偏移量或是一個能間接定位到目标的句柄。直接引用是和虛拟機實作的記憶體布局相關的,同一個符号引用在不同虛拟機執行個體上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目标必定已經在記憶體中存在。

虛拟機規範之中并未規定解析階段發生的具體時間,隻要求了在執行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic這16個用于操作符合引用的位元組碼指令之前,先對它們所使用的符号引用進行解析。解析分為類或接口的解析和字段解析,這裡就不詳細說明了,具體的可以去《深入了解Java虛拟機》一書中進行解讀。

說完連接配接階段,類加載過程就剩下最後一步初始化了,現在來看看初始化階段吧。初始化階段是真正開始執行類中定義的Java程式代碼的階段。在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程式員通過程式制定的主觀計劃去初始化類變量和其他資源,即初始化階段是執行類構造器<clinit>()方法的過程:

<clinit>()方法是由編譯器自動收集類中的所有變量的指派動作和靜态語句塊中的語句合并産生的,編譯器收集的順序是由語句在源檔案中出現的順序所決定的,靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句塊可以指派,但是不能通路,如下執行個體:

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

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

由于父類的<clinit>()方法先執行,也就意味着父類中定義的靜态語句塊要優先于子類的變量指派操作,如下執行個體:

public class Test{
	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);
	}
}
           

字段B的值将會是2而不是1。

<clinit>()方法對于類或接口來說并不是必需的,如果一個類中沒有靜态語句塊,也就沒有對變量的指派操作,那麼編譯器可以不為這個類生成該方法。

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

虛拟機會保證一個類的<clinit>()方法在多線程環境中被正确地加鎖、同步,如果多個線程同時去初始化一個類,那麼隻會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,知道活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個程序阻塞。需要注意的是,其他線程雖然會被阻塞,但如果執行<clinit>()方法的那條線程退出該方法後,其他線程喚醒之後不會再次進入該方法。同一個類加載器下,一個類型隻會初始化一次。

類加載的過程已經介紹完了,最後,再簡單說一下類加載器吧。

虛拟機設計團隊把類加載階段中的通過一個類的全限定名來擷取描述此類的二進制位元組流這個動作放到Java虛拟機外部去實作,以便讓應用程式自己決定如何擷取所需要的類。實作這個動作的代碼子產品稱為類加載器。

對于任意一個類,都需要由加載它的類加載器和這個類本身一同确立其在Java虛拟機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。這句話可以表達的更通俗一些:比較兩個類是否相等,隻有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個class檔案,被同一個虛拟機加載,隻要加載它們的類加載器不同,那這兩個類就必定不相等。

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

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

啟動類加載器:這個類加載器将存放在<JAVA_HOME>\lib目錄中的或者被-Xbootclasspath參數所指定的路徑中的,并且被虛拟機識别的類庫加載到虛拟機記憶體中。啟動類加載器無法被Java程式直接引用,使用者在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可。

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

應用程式類加載器:這個類加載器由sun.misc.Launcher$AppClassLoader實作,由于這個類加載器是ClassLoader中的getSystemClassLoader()方法的傳回值,是以一般也稱它為系統類加載器。它負責加載使用者類路徑上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程式中沒有自定義過自己的類加載器,一般情況下這個就是程式中預設的類加載器。

我們的應用程式都是由這3種類加載器互相配合進行加載的,如果有必要,還可以加入自己定義的類加載器。這些類加載器之間的關系一般如下圖所示:

《深入了解Java虛拟機》學習筆記之類加載機制總結

上圖展示的類加載器之間的這種層次關系,稱為類加載器的雙親委派模型。雙親委派模型要求除了頂層的啟動類加載器外,其餘的類加載器都應當有自己的父類加載器。這裡類加載器之間的父子關系一般不會以繼承的關系來實作,而都是使用組合關系來複用父加載器的代碼。雙親委派模型不是一個強制性的限制模型,而是Java設計者推薦給開發者的一種類加載器實作方式。雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求時,子加載器才會嘗試自己去加載。使用雙親委派模型來組織類加載器之間的關系,有一個顯而易見的好處就是Java類随着它的類加載器一起具備了一種帶有優先級的層次關系。例如java.lang.Object類,它存放在rt.jar之中,無論哪一個類加載器要加載這個類,最終都是委派給處于模型最頂端的啟動類加載器進行加載,是以Object類在程式的個種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果使用者自己編寫了一個稱為java.lang.Object的類,并放在程式的ClassPath中,那系統中将會出現多個不同的Object類,Java類型體系中最基礎的行為也就無法保證了,應用程式也将會變得一片混亂。

雙親委派模型對于保證Java程式的穩定運作很重要,但它的實作卻非常簡單,實作雙親委派的代碼都集中在java.lang.ClassLoader的loadClass()方法之中,具體邏輯清晰易懂:先檢查是否已經被加載過,若沒有加載則調用父加載器的loadClass()方法,若父加載器為空則預設使用啟動類加載器作為父加載器。如果父類加載失敗,則抛出ClassNotFoundException異常後,再調用自己的findClass()方法進行加載。有興趣的可以去閱讀源碼。

以上就是對虛拟機類加載機制的一個總結。