深入理解虛擬機器之虛擬機器類載入機制

Guide哥發表於2018-05-05

Java面試通關手冊(Java學習指南),會一直完善下去,歡迎大家star以及和我一起完善。 github.com/Snailclimb/…

《深入理解Java虛擬機器:JVM高階特性與最佳實踐(第二版》讀書筆記與常見相關面試題總結

本節常見面試題(推薦帶著問題閱讀,問題答案在文中都有提到):

簡單說說類載入過程,裡面執行了哪些操作?

對類載入器有了解嗎?

什麼是雙親委派模型?

雙親委派模型的工作過程以及使用它的好處。

前言:

程式碼編譯的結果從本地轉換為位元組碼,是儲存格式發展的一小步,卻是程式語言發展的一大步。

1 概述

上一節我們已經知道了類檔案結構,在class檔案中描述的各種資訊最終都需要載入到虛擬機器中之後才能執行和使用。

那麼虛擬機器是如載入這些class檔案呢?class檔案中的資訊進入到虛擬機器後會發生什麼變化呢?

1.1 虛擬機器類載入機制的概念

虛擬機器把描述類的資料從class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化。最終形成可以被虛擬機器最直接使用的java型別的過程就是虛擬機器的類載入機制。

1.2 Java語言的動態載入和動態連線

另外需要注意的很重要的一點是:java語言中型別的載入連線以及初始化過程都是在程式執行期間完成的,這種策略雖然會使類載入時稍微增加一些效能開銷,但是會為java應用程式提供高度的靈活性。java裡天生就可以動態擴充套件語言特性就是依賴執行期間動態載入和動態連線這個特點實現的。比如,如果編寫一個面向介面的程式,可以等到執行時再指定其具體實現類。

2 類載入時機

類從被載入到虛擬機器記憶體到卸出記憶體為止,它的整個生命週期包括:

類的生命週期
我們思考一下那麼什麼時候需要開始類載入的第一個階段:載入?

虛擬機器規範嚴格規定了有且只有五種情況必須立即對類進行“初始化”:

  • 使用new關鍵字例項化物件的時候、讀取或設定一個類的靜態欄位的時候,已經呼叫一個類的靜態方法的時候。
  • 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類沒有被初始化就會先初始化它的父類。
  • 當虛擬機器啟動的時候,使用者需要指定一個要執行的主類(就是包含main()方法的那個類),虛擬機器會先初始化這個類;
  • 使用Jdk1.7動態語言支援的時候的一些情況。

而對於介面,當一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面時(如引用父介面中定義的常量)才會初始化。

所有引用類的方式都不會觸發初始化稱為被動引用,下面是3個被動引用例子:

①通過子類引用父類靜態欄位,不會導致子類初始化;②通過陣列定義引用類,不會觸發此類的初始化

public class SuperClass {
	static {
		System.out.println("SuperClass(父類)被初始化了。。。");
	}
	public static int value = 66;
}

複製程式碼
public class Subclass extends SuperClass {
	static {
		System.out.println("Subclass(子類)被初始化了。。。");

	}
    
}

複製程式碼
public class Test1 {

	public static void main(String[] args) {

		// 1:通過子類呼叫父類的靜態欄位不會導致子類初始化
		// System.out.println(Subclass.value);//SuperClass(父類)被初始化了。。。66
		// 2:通過陣列定義引用類,不會觸發此類的初始化
		SuperClass[] superClasses = new SuperClass[3];
		// 3:通過new 建立物件,可以實現類初始化,必須把1下面的程式碼註釋掉才有效果不然經過1的時候類已經初始化了,下面這條語句也就沒用了。
		//SuperClass superClass = new SuperClass();
	}

}
複製程式碼

③常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用定義常量的類,因此不會觸發定義常量的類的初始化

public class ConstClass {
	static {
		System.out.println("ConstClass被初始化了。。。");
	}
	public static final String HELLO = "hello world";
}
複製程式碼
public class Test2 {

	public static void main(String[] args) {
		System.out.println(ConstClass.HELLO);//輸出結果:hello world
	}

}
複製程式碼

3 類載入過程

下面我們詳細的說一下java虛擬機器中類載入的全過程:載入驗證準備解析初始化這5個階段鎖執行的具體工作。

3.1 載入

“載入”“類載入” 過程的一個階段,切不可將二者混淆。

載入階段由三個基本動作組成:

  1. 通過型別的完全限定名,產生一個代表該型別的二進位制資料流(根本沒有指明從哪裡獲取、怎樣獲取,可以說一個非常開放的平臺了)

  2. 解析這個二進位制資料流為方法區內的執行時資料結構

  3. 建立一個表示該型別的java.lang.Class類的例項,作為方法區這個類的各種資料的訪問入口。

通過型別的完全限定名,產生一個代表該型別的二進位制資料流的幾種常見形式:

  • 從zip包中讀取,成為日後JAR、EAR、WAR格式的基礎;
  • 從網路中獲取,這種場景最典型的應用就是Applet;
  • 執行時計算生成,這種場景最常用的就是動態代理技術了;
  • 由其他檔案生成,比如我們的JSP;

注意: 非陣列類載入階段既可以使用系統提供的類載入器來完成,也可以由使用者自定義的類載入器去完成。(即重寫一個類載入器的loadClass()方法)

3.2 驗證

驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全

虛擬機器如果不檢查輸入的位元組流,並對其完全信任的話,很可能會因為載入了有害的位元組流而導致系統崩潰,所以驗證是虛擬機器對自身保護的一項重要工作。這個階段是否嚴謹,直接決定了java虛擬機器是否能承受惡意程式碼的攻擊。

從整體上看,驗證階段大致上會完成4個階段的校驗工作:檔案格式、後設資料、位元組碼、符號引用

3.2.1 檔案格式驗證

驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。該驗證階段的主要目的是保證輸入的位元組流能正確地解析並儲存於方法區之內。這個階段驗證是基於二進位制位元組流進行的,只有通過這個階段的驗證後,位元組流才會進入記憶體的方法區進行儲存,所以後面的3個階段的全部是基於方法區的儲存結構進行的,不會再直接操作位元組流。

3.2.2 後設資料驗證

該階段對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求,目的是保證不存在不符合Java語言規範的後設資料資訊

3.2.3 位元組碼驗證

該階段主要工作時進行資料流和控制流分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的行為。 例如,保證跳轉指令不會跳轉到方法體以外的位元組碼指令上、保證方法體中的型別轉換是有效的等等。

由於資料流校驗的高複雜性,耗時較大,所以JDK1.6之後,在Javac中引入一項優化方法(可以通過引數關閉):在方法體的Code屬性的屬性表中增加一項“StackMapTable”屬性,該屬性描述了方法體中所有基本塊開始時本地變數表和操作棧應有的狀態,從而將位元組碼驗證的型別推導轉變為型別檢查從而節省一些時間。

注意: 如果一個方法體通過了位元組碼驗證,也不能說明其一定是安全的,因為校驗程式邏輯無法做到絕對精確。

3.2.4 符號引用驗證

最後一個階段的校驗發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三個階段——解析階段中發生。符號引用驗證的目的是確保解析動作能正常執行。

驗證的內容主要有:

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

3.3 準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。(備註:這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中)。

初始值通常是資料型別的零值:

對於:public static int value = 123;,那麼變數value在準備階段過後的初始值為0而不是123,這時候尚未開始執行任何java方法,把value賦值為123的動作將在初始化階段才會被執行。

一些特殊情況:

對於:public static final int value = 123;編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為123。

基本資料型別的零值:

基本資料型別的零值

3.4 解析

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。

那麼符號引用與直接引用有什麼關聯呢?

3.4.1 看兩者的概念。

符號引用(Symbolic References): 符號引用以一組符號來描述所引用的目標,符號可以是符合約定的任何形式的字面量,符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。

直接引用(Direct References): 直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用與虛擬機器實現的記憶體佈局相關,引用的目標必定已經在記憶體中存在。

虛擬機器規範沒有規定解析階段發生的具體時間,虛擬機器實現可以根據需要來判斷到底是在類被載入時解析還是等到一個符號引用將要被使用前才去解析。

3.4.2 對解析結果進行快取

同一符號引用進行多次解析請求是很常見的,除invokedynamic指令以外,虛擬機器實現可以對第一次解析結果進行快取,來避免解析動作重複進行。無論是否真正執行了多次解析動作,虛擬機器需要保證的是在同一個實體中,如果一個引用符號之前已經被成功解析過,那麼後續的引用解析請求就應當一直成功;同樣的,如果 第一次解析失敗,那麼其他指令對這個符號的解析請求也應該收到相同的異常。

3.4.3 解析動作的目標

解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行。前面四種引用的解析過程,對於後面三種,與JDK1.7新增的動態語言支援息息相關,由於java語言是一門靜態型別語言,因此沒有介紹invokedynamic指令的語義之前,沒有辦法將他們和現在的java語言對應上。

3.5 初始化

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

4 類載入器

4.1、類與類載入器

對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器中的唯一性。如果兩個類來源於同一個Class檔案,只要載入它們的類載入器不同,那麼這兩個類就必定不相等。

4.2 類載入器介紹

從Java虛擬機器的角度分為兩種不同的類載入器:啟動類載入器(Bootstrap ClassLoader)其他類載入器。其中啟動類載入器,使用C++語言實現,是虛擬機器自身的一部分;其餘的類載入器都由Java語言實現,獨立於虛擬機器之外,並且全都繼承自java.lang.ClassLoader類。(這裡只限於HotSpot虛擬機器)。

從Java開發人員的角度來看,絕大部分Java程式都會使用到以下3種系統提供的類載入器。

啟動類載入器(Bootstrap ClassLoader):

這個類載入器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath引數所指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別,如rt.jar,名字不符合的類庫即使放在lib目錄中也不會被載入)類庫載入到虛擬機器記憶體中。

擴充套件類載入器(Extension ClassLoader):

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

應用程式類載入器(Application ClassLoader):

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

我們的應用程式都是由這3種類載入器互相配合進行載入的,如果有必要,還可以加入自己定義的類載入器。

4.3 雙親委派模型

雙親委派模型(Pattern Delegation Model),要求除了頂層的啟動類載入器外,其餘的類載入器都應該有自己的父類載入器。這裡父子關係通常是子類通過組合關係而不是繼承關係來複用父載入器的程式碼。

雙親委派模型(Pattern Delegation Model)
雙親委派模型的工作過程: 如果一個類載入器收到了類載入的請求,先把這個請求委派給父類載入器去完成(所以所有的載入請求最終都應該傳送到頂層的啟動類載入器中),只有當父載入器反饋自己無法完成載入請求時,子載入器才會嘗試自己去載入。

使用雙親委派模型來組織類載入器之間的關係,有一個顯而易見的好處就是java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係

注意:雙親委派模型是Java設計者們推薦給開發者們的一種類載入器實現方式,並不是一個強制性 的約束模型。在java的世界中大部分的類載入器都遵循這個模型,但也有例外。

4.4 破壞雙親委派模型

雙親委派模型主要出現過3次較大規模“被破壞”的情況。

第一次破壞是因為類載入器和抽象類java.lang.ClassLoader在JDK1.0就存在的,而雙親委派模型在JDK1.2之後才被引入,為了相容已經存在的使用者自定義類載入器,引入雙親委派模型時做了一定的妥協:在java.lang.ClassLoader中引入了一個findClass()方法,在此之前,使用者去繼承java.lang.Classloader的唯一目的就是重寫loadClass()方法。JDK1.2之後不提倡使用者去覆蓋loadClass()方法,而是把自己的類載入邏輯寫到findClass()方法中,如果loadClass()方法中如果父類載入失敗,則會呼叫自己的findClass()方法來完成載入,這樣就可以保證新寫出來的類載入器是符合雙親委派模型規則的。

第二次破壞是因為模型自身的缺陷,現實中存在這樣的場景:基礎的類載入器需要求呼叫使用者的程式碼,而基礎的類載入器可能不認識使用者的程式碼。為此,Java設計團隊引入的設計時“執行緒上下文類載入器(Thread Context ClassLoader)”。這樣可以通過父類載入器請求子類載入器去完成類載入動作。已經違背了雙親委派模型的一般性原則。

第三次破壞 是由於使用者對程式動態性的追求導致的。這裡所說的動態性是指:“程式碼熱替換”、“模組熱部署”等等比較熱門的詞。說白了就是希望應用程式能夠像我們的計算機外設一樣,接上滑鼠、U盤不用重啟機器就能立即使用。OSGi是當前業界“事實上”的Java模組化標準,OSGi實現模組化熱部署的關鍵是它自定義的類載入器機制的實現。每一個程式模組(OSGi中稱為Bundle)都有一個自己的類載入器,當需要更換一個Bundle時,就把Bundle連同類載入器一起換掉以實現程式碼的熱替換。在OSGi環境下,類載入器不再是雙親委派模型中的樹狀結構,而是進一步發展為更加複雜的網狀結構。

總結:

本節主要介紹了類載入過程中:“載入”、“驗證”、“準備”、“解析”、“初始化”這5個階段中虛擬機器進行了了那些動作,還介紹了類載入器的工作原理及對虛擬機器的意義。

歡迎關注我的微信公眾號:"Java面試通關手冊"(一個有溫度的微信公眾號,期待與你共同進步~~~堅持原創,分享美文,分享各種Java學習資源):

微信公眾號

相關文章