Java虛擬機器的類載入機制
前言
(僅供秋招復習,瞭解。)
之前在我的 詳解物件的建立,佈局,定位,存活判斷,介紹了類載入之後的事情。
但是關於類載入機制並沒有過多的介紹,先簡單介紹一下。
Java虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最
終形成可以被虛擬機器直接使用的Java型別,這個過程被稱作虛擬機器的類載入機制。
類載入的時機
之前我們介紹到了在我們使用new關鍵字之後,如果我們類沒有符號引用也就是沒有被載入的時候就會觸發類載入的機制,這算一個類的載入時機,但是載入時機還有很多下面會介紹到。關於我們的類載入過程呢主要是如下圖:
(1)載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的。並且為支援動態繫結,解析階段可以在初始化階段之後再開始。
(2)類載入在什麼時候開始可以由虛擬機器自己決定。但是呢,針對初始化,我們的《Java虛擬機器規範》則是嚴格規定了有且只有六種情況必須立即對類進行“初始化”。(在此之前的其他操作也需要開始)
- 遇到new、getstatic、putstatic、invokestatic四條位元組碼指令時,如果沒有初始化,則需要先觸發初始化階段。具體場景有:
- 使用
new
例項化物件時 - 讀取或設定一個型別的靜態欄位(除被final修飾放到常量池中的欄位)
- 呼叫一個型別的靜態方法時
- 使用
- 使用反射包下的方法對型別進行反射呼叫時,如未初始化,則先初始化。
- 類初始化時,如父類未初始化,則先初始化父類。
- 虛擬機器啟動時,先初始化
main
- 當使用JDK 7新加入的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解
析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法控制代碼,並且這個方法控制代碼對應的類沒有進行過初始化,則需要先觸發其初始化。(這裡瞭解即可) - JDK8種如果介面使用了default關鍵字進行修飾,那麼實現介面的類初始化的時候要先初始化介面。
主動引用與被動引用
上面我們介紹到了六種方法,這六個方法的行為稱為對一個型別進行主動引用。除了這些,所有引用型別的方法都不會觸發初始化,稱為被動引用。
關於被動引用的情況有如下:
- 通過子類引用父類的靜態欄位,不會導致子類初始化。
- 通過陣列定義來引用類,不會觸發此類的初始化。
- 常量在編譯階段存入呼叫類的常量池中,本質上沒有直接引用到定義常量的類,因此也不會觸發定義的類的初始化。這裡在上面也提到了,被static修飾但是沒有被final修飾會觸發主動引用,但是都被修飾只能算是被動引用。
類載入的過程
載入
類載入過程的第一步,主要完成下面3件事情:
- 通過全類名獲取定義此類的二進位制位元組流(並沒有規定從哪裡獲取二進位制流,所以可以從ZIP壓縮檔案中獲取(JAR、WAR的基礎)、網路中獲取(applet的基礎)、執行時計算(動態代理技術)、從其他檔案生成(JSP)))。
- 將位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構。
- 在記憶體中生成一個代表該類的 Class 物件,作為方法區這些資料的訪問入口。
一個非陣列類的載入階段(載入階段獲取類的二進位制位元組流的動作)是可控性最強的階段,這一步我們可以去完成還可以自定義類載入器去控制位元組流的獲取方式(重寫一個類載入器的
loadClass()
方法)。陣列型別不通過類載入器建立,它由 Java 虛擬機器直接建立。
驗證
確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。主要有如下驗證:
-
檔案格式驗證,驗證是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。
-
元資料驗證,對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求。
-
位元組碼驗證,通過資料流和控制流分析,確定程式語義是否合法、符合邏輯。
-
符合引用驗證,是對類自身以外的資訊進行匹配性校驗(常量池中各種符合引用)。
準備
準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中分配。但是也需要注意的是:例項變數不會在這階段分配記憶體,它會在物件例項化時隨著物件一起被分配在堆中。應該注意到,例項化不是類載入的一個過程,類載入發生在所有例項化操作之前,並且類載入只進行一次,例項化可以進行多次。
初始值一般為 0 值,例如下面的類變數 value 被初始化為 0 而不是 123。只有在被例項化的時候才會賦值123。
public static int value = 123;
如果類變數是常量,那麼它將初始化為表示式所定義的值而不是 0。例如下面的常量 value 被初始化為 123 而不是 0。
public static final int value = 123;
基本型別的初始值如下:
解析
該階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫限定符7類符號引用進行。
- 符號引用就是一組符號來描述目標,可以是任何字面量。
- 直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制代碼。
在程式實際執行時,只有符號引用是不夠的,舉個例子:在程式執行方法時,系統需要明確知道這個方法所在的位置。Java 虛擬機器為每個類都準備了一張方法表來存放類中所有的方法。當需要呼叫一個類的方法的時候,只要知道這個方法在方發表中的偏移量就可以直接呼叫該方法了。通過解析操作符號引用就可以直接轉變為目標方法在類中方法表的位置,從而使得方法可以被呼叫。
初始化
初始化階段就是執行類構造器<clinit>()
方法的過程。這個方法不是我們在Java程式碼中去編寫,而且Javac編譯器的自動生成物,但是我們有必要去了解。
(1)<clinit>()
方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問
到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪
問。
public class Test {
static {
i = 0; // 給變數複製可以正常編譯通過
System.out.print(i); // 這句編譯器會提示“非法向前引用”
}
static int i = 1;
}
(2)<clinit>()
與類構造方法init()方法
不同,虛擬機器會保證在子類的<clinit>()
執行前,父類的已經執行完畢。所以,Object類的<clinit>()
方法一定是最先執行的。這也意味著,父類的靜態語句塊要優先與子類的變數賦值操作。
(3)<clinit>()
方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()
方法。
(4)介面中的<clinit>()
執行前不需要先執行父類的<clinit>()
方法,因為只有當父介面中定義的變數被使用時,父接口才會被初始化。此外,介面的實現類在初始化時也 一樣不會執行介面的<clinit>()
方法。
(5)Java虛擬機器必須保證一個類的<clinit>()
方法在多執行緒環境中被正確地加鎖同步,如果多個執行緒同時去初始化一個類,那麼只會有其中一個執行緒去執行這個類的<clinit>()
方法,其他執行緒都需要阻塞等待,直到活動執行緒執行完畢<clinit>()
方法。如果在一個類的<clinit>()
方法中有耗時很長的操作,那就可能造成多個程序阻塞。
解除安裝
(該部分知識點參考Github高星:《JavaGuide》)
解除安裝類即該類的Class物件被GC。
解除安裝類需要滿足3個要求:
- 該類的所有的例項物件都已被GC,也就是說堆不存在該類的例項物件。
- 該類沒有在其他任何地方被引用
- 該類的類載入器的例項已被GC
所以,在JVM生命週期類,由jvm自帶的類載入器載入的類是不會被解除安裝的。但是由我們自定義的類載入器載入的類是可能被解除安裝的。
只要想通一點就好了,jdk自帶的BootstrapClassLoader,PlatformClassLoader,AppClassLoader負責載入jdk提供的類,所以它們(類載入器的例項)肯定不會被回收。而我們自定義的類載入器的例項是可以被回收的,所以使用我們自定義載入器載入的類是可以被解除安裝掉的。
類載入器與雙親委派模型
類載入介紹
(1)BootstrapClassLoader(啟動類載入器)
在jdk的rt.jar包下面,是所有類載入器的父類,c++編寫的。涉及到虛擬機器的具體實現無法引用到,這也就是我們上面為什麼null的原因了。
(2)ExtClassLoader (標準擴充套件類載入器)
負責載入Java的擴充套件類庫,也就是從jre/lib/ext目錄下或者java.ext.dirs系統屬性指定的目錄下載入類。
(3)AppClassLoader(系統類載入器)
負責載入載入應用程式的主函式類。
(4)CustomClassLoader(使用者自定義類載入器)
顧名思義,是使用者自己編寫的類載入器,用來載入指定路徑下面的類。
雙親委派模型
每一個類都有一個對應它的類載入器。系統中的 ClassLoder 在協同工作的時候會預設使用 雙親委派模型 。不考慮自定義的類載入器,我們首先會在載入AppClassLoader
,如果載入過了,則不載入,如果沒有載入,則委派給了ExtClassLoader
,到了這裡,也會同樣進行判斷,如果沒有被載入的話,則會被委派給了BootstrapClassLoader
。在啟動類類載入器這裡,如果被載入過的話,就不載入了,如果沒有的話但是可以載入,就進行載入了,如果不可以載入就反饋給了子載入器,一直到系統類載入器。
具體流程可以參考示意圖:
常見問題分析
(1)為什麼需要雙親委派模型?有什麼好處?
- 防止重複載入同一個
.class
,穩定執行。通過委託去向上面問一問,載入過了,就不用再載入一遍。保證資料安全和Java程式的穩定執行。 - 保證核心
.class
不能被篡改。通過委託方式,不會去篡改核心.class
,即使篡改也不會去載入,即使載入也不會是同一個.class
物件了。不同的載入器載入同一個.class
也不是同一個Class物件。這樣保證了Class執行安全。如果沒有使用雙親委派模型,而是每個類載入器載入自己的話就會出現一些問題,比如我們編寫一個稱為java.lang.Object
類的話,那麼程式執行的時候,系統就會出現多個不同的Object
類。
(2)什麼是雙親委派模型的破壞?怎麼破壞?
雙親委派模型的破壞指的是不按照雙親委派模型來載入類,歷史上就出現了三次比較重大的破壞,詳細的可以去了解一下(點選跳轉)。在自定義類載入器的時候也可以進行破壞。自定義載入器的話,需要繼承 ClassLoader
。如果我們不想打破雙親委派模型,就重寫 ClassLoader
類中的 findClass()
方法即可,無法被父類載入器載入的類最終會通過這個方法被載入。但是,如果想打破雙親委派模型則需要重寫 loadClass()
方法。
參考資料
《深入理解Java虛擬機器3》