面試官,不要再問我“Java虛擬機器類載入機制”了

secbro2發表於2019-10-27

關於Java虛擬機器類載入機制往往有兩方面的面試題:根據程式判斷輸出結果和講講虛擬機器類載入機制的流程。其實這兩類題本質上都是考察面試者對Java虛擬機器類載入機制的瞭解。

面試題試水

現在有這樣一道判斷程式輸出結果的面試題,先看看列印的結果是什麼?

public class SuperClass {

	static {
		System.out.println("SuperClass static init");
	}

	public static String ABC = "abc";
}

public class SubClass extends SuperClass{

	static {
		System.out.println("SubClass static init");
	}
}

public class Main {

	public static void main(String[] args) {
		System.out.println(SubClass.ABC);
	}

}

複製程式碼

上面定義了三個類,其中SubClass繼承SuperClass,然後Mian類中列印SubClass.ABC的值。那麼,控制檯列印結果是什麼?

SuperClass static init
abc
複製程式碼

你做對了麼?這是為什麼呢?對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。

再對上面的程式碼進行調整,對靜態變數ABC新增final修飾。

public class SuperClass {

	static {
		System.out.println("SuperClass static init");
	}

	public static final String ABC = "abc";
}

public class SubClass extends SuperClass{

	static {
		System.out.println("SubClass static init");
	}
}

public class Main {

	public static void main(String[] args) {
		System.out.println(SubClass.ABC);
	}

}
複製程式碼

列印結果為:

abc
複製程式碼

這又是為什麼呢?因為,常量在編譯階段會存入呼叫類的常量池中,也就是說Main類對SubClass.ABC的引用已經與SuperClass無關了,實際上已經轉行為Main類對ABC的引用了。

做好的鋪墊,可以開始對類載入機制的瞭解了。

類載入過程

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

整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段。其中準備、驗證、解析3個部分統稱為連線(Linking)。

Java虛擬機器類載入機制

其中載入、驗證、準備、初始化和解除安裝的執行順序是確定的,解析階段則在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結(也稱為動態繫結或晚期繫結)。

載入階段

在載入階段虛擬機器會完成三件事:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流;
  • 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;
  • 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口;

其中獲取二進位制位元組流可以通過Class檔案、ZIP包、網路、執行時(動態代理)、JSP生成、資料庫等途徑獲取。

需要注意的是陣列類的載入,陣列類並不通過類載入器載入,而是由Java虛擬機器直接建立,但陣列類的元素還是要依靠類載入器進行載入。

這些二進位制位元組流載入完成之後,按照指定的格式存放于于方法區內(Java7及以前方法區位於永久代,Java8位於Metaspace)。然後在方法區生成一個比較特殊的java.lang.Class物件,用來作為程式訪問方法區中這些型別資料的外部介面。

驗證階段

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

Java虛擬機器類載入機制

檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範;比如,是否以魔術0xCAFEBABE開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別。只有驗證通過才會進入方法區進行儲存。

後設資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求;比如,是否有父類(除Object類)、父類是否為final修飾、是否實現抽象方法或介面、過載是否正確等。

位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。比如,保證資料型別與指令正常配合工作、指令不會跳轉到方法體外的位元組碼上,方法體中的型別轉換是有效的等。

符號引用驗證:在虛擬機器將符號引用轉化為直接引用的時候進行驗證,可以看做是對類自身以外的資訊(常量池中的各種符號引用)進行匹配性的校驗。常見的異常比如:java.lang.NoSuchMethdError、java.lang.NoSuchFiledError等。

準備階段

準備階段主要是正式為類變數分配記憶體並設定類變數初始值,變數所使用的記憶體都將在方法區中進行分配。

此處的類變數指的是被static修飾的變數,不包含例項變數,例項變數在物件例項化階段分配在堆中。

public static String ABC = "abc";
複製程式碼

並且,變數的初始化值並不是類中定義的值,而是該變數所屬型別的預設值。

Java虛擬機器類載入機制

當然,也有特殊情況,比如當變數被final修飾時:

public static final String ABC = "abc";
複製程式碼

此時,該欄位屬性是ConstantValue時,會在準備階段初始化為指定的值。

解析階段

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行。

這裡我們看一下欄位解析,也就是最開始第一道面試題。當獲取SubClass的屬性ABC時,首先會查詢SubClass本身是否包含該欄位,如果包含則直接返回引用,查詢結束。

否則,如果SubClass類實現了介面或繼承了父類,那麼則遞迴搜尋各個介面和父類,找到匹配的屬性則返回,查詢結束。

否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常。如果返回成功了,但是是許可權校驗失敗,也就是無該欄位的訪問許可權,則丟擲java.lang.IllegalAccessError異常。

其他形式的解析,就不再這裡一一說明了。

初始化階段

初始化階段才是真正執行類中定義的Java程式程式碼(位元組碼)。在此階段會根據程式碼進行類變數和其他資源的初始化,或者可以從另一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。

<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static語句塊)中的語句合併生成的,編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊中可以賦值,但是不能訪問。

編譯器提示錯誤。

Java虛擬機器類載入機制

將其放在後面,則正常編譯執行,輸出結果為“edf”:

Java虛擬機器類載入機制

如果將static中的列印語句去掉,那麼下面這段程式碼的列印結果會是什麼呢?

public class Main {
	static {
		//可以賦值
		abc = "edf";
		//編譯器會提示“非法向前引用”
//		System.out.println(abc);
	}

	static String abc = "abc";

	public static void main(String[] args) {
		System.out.println(abc);
	}
}
複製程式碼

列印結果為“abc”。在準備階段屬性abc的值為null,然後類初始化按照順序執行,首先執行static塊中的abc=“edf”賦值操作,接著執行abc="abc"的賦值操作,此時值為“abc”。當main方法呼叫列印時則為“abc”。

<clinit>()方法與例項構造器<init>()方法不同,它不需要顯示地呼叫父類構造器,虛擬機器會保證在子類<cinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。最開始的面試題中列印出父類靜態塊的方法就是這個原因。

由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。

<clinit>()方法對於類或者介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生產<clinit>()方法。

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

虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確的加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個執行緒阻塞,在實際應用中這種阻塞往往是隱藏的。

虛擬機器規範初始化

虛擬機器規範嚴格規定了有且只有5中情況(jdk1.7)必須對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):

  • 遇到new,getstatic,putstatic,invokestatic這失調位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java程式碼場景是:使用new關鍵字例項化物件的時候、讀取或設定一個類的靜態欄位(被final修飾、已在編譯器把結果放入常量池的靜態欄位除外)的時候,以及呼叫一個類的靜態方法的時候。
  • 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
  • 當使用jdk1.7動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getstatic,REF_putstatic,REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行初始化,則需要先出觸發其初始化。

該段內容引自周志明《深入理解java虛擬機器》。

小結

經過以上步驟,便完成了虛擬機器類的載入過程,後續會繼續講解虛擬機器的類載入器和雙親委派機制。歡迎大家關注公眾號“程式新視界”繼續深入學習。

原文連結:《面試官,不要再問我“Java虛擬機器類載入機制”了

《面試官》系列文章:


程式新視界:精彩和成長都不容錯過

程式新視界-微信公眾號

相關文章