目錄
如有錯誤望指出
Java虛擬機器器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可被java虛擬機器器直接使用的java型別,這個過程被稱為虛擬機器器的類載入機制
在java語言裡面,型別的載入、連線和初始化都是在程式執行期間完成的
「Class檔案」也並非特指某個存在於具體磁碟的檔案,而應當是一串二進位制位元組流
一個型別被載入到java虛擬機器器記憶體中開始到記憶體結束時,會經歷七個階段,載入、驗證、準備、解析、初始化、使用和解除安裝。
其中驗證、準備、解析三個部分統稱為連線
載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,型別的載入過程必須按照這種順序按部就班的開始,而解析階段則不一樣,它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結特性(也稱為動態繫結或晚期繫結)。
這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中呼叫、啟用另一個階段。
在什麼情況下需要開始類載入的過程中的第一個階段「載入」:
遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時,如果型別沒有進行過初始化,則需要先觸發其初始化階段,場景:
使用new範例化物件
讀取或設定一個型別的靜態欄位
呼叫一個型別的靜態方法
使用java.lang.reflect包的方法對型別進行反射呼叫的時候,如果型別沒有進行過初始化,則需要先觸發其初始化
當初始化類的時候,還沒有觸發其父類別的初始化則先觸發父類別的初始化
虛擬機器器啟動時,初始化主類(包含main()方法的)
當使用JDK 7新加入的動態語言支援時,如果一個java.lang.invoke.MethodHandle範例最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種型別的方法控制程式碼,並且這個方法控制程式碼對應的類沒有進行過初始化,則需要先觸發其初始化。
當一個介面中定義了JDK 8新加入的預設方法(被default關鍵字修飾的介面方法)時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化。
這六種場景中的行為稱為對一個型別進行主動參照。除此之外,所有參照型別的方式都不會觸發初始化,稱為被動參照。
package org.fenixsoft.classloading;
/**
\* 被動使用類欄位演示一:
\* 通過子類參照父類別的靜態欄位,不會導致子類初始化
**/
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public 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!」,而不會輸出「SubClass init!」。對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過其子類來參照父類別中定義的靜態欄位,只會觸發父類別的初始化而不會觸發子類的初始化
package org.fenixsoft.classloading;
/**
\* 被動使用類欄位演示二:
\* 通過陣列定義來參照類,不會觸發此類的初始化
**/
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}
/**
\* 被動使用類欄位演示三:
\* 常數在編譯階段會存入呼叫類的常數池中,本質上沒有直接參照到定義常數的類,因此不會觸發定義常數的
類的初始化
**/
public 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);
}
}
在載入階段,Java虛擬機器器需要完成以下三件事情:
通過一個類的全限定名來獲取定義此類的二進位制位元組流
將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構
在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的存取入口
驗證是連線階段的第一步
確保Class檔案中包含的位元組流符合《java虛擬機器器規範》,保證這些資訊不會危害虛擬機器器的安全
驗證的工作量在虛擬機器器的類載入過程中佔了很大的比重
蘊含四個階段:
檔案格式驗證
這一階段是驗證位元組流是否符合Class檔案格式的規範,並且能被當前虛擬機器器的版本所處理
是否以魔數0xCAFEBABE開頭
主、次版本號是否在當前Java虛擬機器器接受範圍之內。
常數池的常數中是否有不被支援的常數型別(檢查常數tag標誌)
指向常數的各種索引值中是否有指向不存在的常數或不符合型別的常數
CONSTANT_Utf8_info型的常數中是否有不符合UTF-8編碼的資料
Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊
............等等
後設資料驗證
第二階段是對位元組碼描述的資訊進行語意分析,保證其描述的資訊符合《java語言規範》的要求,驗證點如下:
·這個類是否有父類別(除了java.lang.Object之外,所有的類都應當有父類別)
·這個類的父類別是否繼承了不允許被繼承的類(被final修飾的類)。
·如果這個類不是抽象類,是否實現了其父類別或介面之中要求實現的所有方法
類中的欄位、方法是否與父類別產生矛盾(例如覆蓋了父類別的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等)。
........等等
第二階段的主要目的是對類的後設資料資訊進行語意校驗,保證不存在與《java語言規範》不相符的後設資料資訊
位元組碼驗證
第三階段是整個驗證過程中最複雜的一個階段
要目的是通過資料流分析和控制流分析,確定程式語意是合法的、符合邏輯的
保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似於「在操作棧放置了一個int型別的資料,使用時卻按long型別來載入入本地變數表中」這樣的情況
·保證任何跳轉指令都不會跳轉到方法體以外的位元組碼指令上。
·保證方法體中的型別轉換總是有效的,例如可以把一個子類物件賦值給父類別資料型別,這是安全的,但是把父類別物件賦值給子類資料型別,甚至把物件賦值給與它毫無繼承關係、完全不相干的一個資料型別,則是危險和不合法的。
等等.........
如果一個型別中有方法體的位元組碼沒有通過位元組碼驗證,那它肯定是有問題的;
但如果一個方法體通過了位元組碼驗證,也仍然不能保證它一定就是安全的。即使位元組碼驗證階段中進行了再大量、再嚴密的檢查,也依然不能保證這一點
符號參照驗證
最後一個階段的校驗行為發生在虛擬機器器將符號參照轉化為直接參照[3]的時候,這個轉化動作將在連線的第三階段——解析階段中發生
。符號參照驗證可以看作是對類自身以外(常數池中的各種符號
參照)的各類資訊進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止存取它依賴的某些外部
類、方法、欄位等資源。本階段通常需要校驗下列內容:
·符號參照中通過字串描述的全限定名是否能找到對應的類。
·在指定類中是否存在符合方法的欄位描述符及簡單名稱所描述的方法和欄位
·符號參照中的類、欄位、方法的可存取性(private、protected、public、<package>)是否可被當前類存取。
驗證階段對於虛擬機器器的類載入機制來說,是一個非常重要的、但卻不是必須要執行的階段,因為驗證階段只有通過或者不通過的差別,只要通過了驗證,其後就對程式執行期沒有任何影響了
準備階段是正式為類中定義的變數(即靜態變數,被static修飾的變數)分配記憶體並設定類變數初始值的階段
假設一個類變數的定義為:
public static int value = 123;
那變數value在準備階段過後的初始值為0而不是123,因為這時尚未開始執行任何Java方法,
而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器<clinit>()方法之中,
所以把value賦值為123的動作要到類的初始化階段才會被執行
如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數值就會被初始化為ConstantValue屬性所指定的初始值
假設上面類變數value的定義修改為:
public static final int value = 123;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器器就會根據Con-stantValue的設定將value賦值為123。
上面講述了設定類變數初始值,但沒講述分配記憶體,分配在哪裡呢?
分配在哪個空間是比較模糊的,概念上是被分配在方法區中,但必須注意方法區是一個邏輯上的區域,JDK7以前可以說被分配在方法區,7以後就是一種,這時候「類變數在 方法區」就完全是一種對邏輯概念的表述了
解析階段是Java虛擬機器器將常數池內的符號參照替換為直接參照的過程
符號參照(Symbolic References):符號參照以一組符號來描述所參照的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可
·直接參照(Direct References):直接參照是可以直接指向目標的指標、相對偏移量或者是一個能間接定位到目標的控制程式碼
符號參照與虛擬機器器實現的記憶體佈局無關,直接參照是和虛擬機器器實現的記憶體佈局直接相關的,同一個符號參照在不同虛擬機器器範例上翻譯出來的直接參照一般不會相同。
如果有了直接參照,那參照的目標必定已經在虛擬機器器的記憶體中存在。
解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符這7類符號參照進行
類的初始化階段是類載入過程的最後一個步驟,之前介紹的幾個類載入的動作裡,
除了在載入階段使用者應用程式可以通過自定義類載入器的方式區域性參與外,其餘動作都完全由Java虛擬機器器來主導控制。
直到初始化階段,Java虛擬機器器才真正開始執行類中編寫的Java程式程式碼,將主導權移交給應用程式。
進行準備階段時,變數已經賦過一次系統要求的初始零值,而在初始化階段,
則會根據程式設計師通過程式編碼制定的主觀計劃去初始化類變數和其他資源。
我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器<clinit>()方法的過程
·<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的 語句合併產生的,它是Javac編譯器的自動生成物
編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能存取到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能存取
public class Test {
static {
i = 0; // 給變數賦值可以正常編譯通過
System.out.print(i); // 這句編譯器會提示「非法向前參照」
}
static int i = 1;
}
<clinit>()方法與類別建構函式(即在虛擬機器器視角中的範例構造器<init>()方法)不同,
它不需要顯式地呼叫父類別構造器,Java虛擬機器器會保證在子類的<clinit>()方法執行前,父類別的<clinit>()方法已經執行完畢。
因此在Java虛擬機器器中第一個被執行的<clinit>()方法的型別肯定是java.lang.Object。
·由於父類別的<clinit>()方法先執行,也就意味著父類別中定義的靜態語句塊要優先於子類的變數賦值操作
<Clinit>方法對類或介面來說不是必須的,因為類中不一定需要靜態語句塊,而且如果沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。
介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法,但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,
因為只有當父介面中定義的變數被使用時,父介面才會被初始化。此外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法
Java虛擬機器器必須保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖同步,
如果多個執行緒同時去初始化一個類,那麼只會有其中一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,
直到活動執行緒執行完畢<clinit>()方法。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個程序阻塞
在實際應用中這種阻塞往往是很隱蔽的
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() {
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
同一個類載入器下,一個型別只會被初始化一次
比較兩個類是否「相等」,只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個
Class檔案,被同一個Java虛擬機器器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等
名稱 | 載入哪的類 | 說明 |
---|---|---|
Bootstrap ClassLoader | JAVE_HOME/jre/lib | 無法直接存取 null |
Extension ClassLoader | JAVE_HOME/jre/lib/ext | 上級為Bootstrap |
Application ClassLoader | classpath | 上級為Extension |
自定義類載入器 | 自定義 | 上級為Application |
由下至上詢問是否載入
圖中展示的各種類載入器之間的層次關係被稱為類載入器的「雙親委派模型(Parents Delegation Model)」。雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類別載入
器。不過這裡類載入器之間的父子關係一般不是以繼承(
Inheritance)的關係來實現的,而是通常使用
組合(Composition)關係來複用父載入器的程式碼。
雙親委派模型的工作過程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,
而是把這個請求委派給父類別載入器去完成,每一個層次的類載入器都是如此,因此所有的 載入請求最終都應該傳送到最頂層的啟動類載入器中,
只有當父載入器反饋自己無法完成這個載入請 求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自己去完成載入。
雙親委派第一次被破壞
即為了相容雙親委派模型出現之前(JDK1.2)的程式碼不得不做出的妥協
只能在JDK1.2,只能在JDK 1.2之後的java.lang.ClassLoader中新增一個新的
protected方法findClass(),並引導使用者編寫的類載入邏輯時儘可能去重寫這個方法,而不是在 loadClass()中編寫程式碼
雙親委派第二次被破壞
是由於自身這個雙親委派模型的缺陷導致的
雙親委派很好地解決了各個類載入器共同作業時基礎型別的一致性問題(越基礎的類由越上層的載入器進行載入)
基礎型別之所以被稱為「基礎」,是因為它們總是作為被使用者程式碼繼承、呼叫的API存在,但程式設計往往沒有絕對不變
的完美規則,如果有基礎型別又要呼叫回使用者的程式碼,那該怎麼辦呢?
這時候就需要執行緒上下文載入器了
這個類載入器可以通過java.lang.Thread類的setContext-ClassLoader()方
法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器
有了執行緒上下文類載入器,程式就可以做一些「舞弊」的事情了
這是一種父類別載入器去請求子類載入器完成類載入的行為,這種行
為實際上是打通了雙親委派模型的層次結構來逆向使用類載入器,已經違背了雙親委派模型的一般性原則,但也是無可奈何的事情
雙親委派模型的第三次「被破壞」
是由於使用者對程式動態性的追求而導致的
動態性:程式碼熱替換(Hot Swap)、模組熱部署(Hot Deployment)等。說白了就是希望Java應用程式能像我們的電腦外設那樣,接上滑鼠、U盤,不用重新啟動機器就能立即使用
由IBM公司提出的OSGi提案動態化
OSGi通過類載入器實現熱部署:
1)將以java.*開頭的類,委派給父類別載入器載入。
2)否則,將委派列表名單內的類,委派給父類別載入器載入。
3)否則,將Import列表中的類,委派給Export這個類的Bundle的類載入器載入。
4)否則,查詢當前Bundle的ClassPath,使用自己的類載入器載入。
5)否則,查詢類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類載入器
載入。
6)否則,查詢Dynamic Import列表的Bundle,委派給對應Bundle的類載入器載入。
7)否則,類查詢失敗。
上面的查詢順序中只有開頭兩點仍然符合雙親委派模型的原則,其餘的類查詢都是在平級的類載入器中進行的
筆者雖然使用了「被破壞」這個詞來形容上述不符合雙親委派模型原則的行為,但這裡「被破壞」並不一定是帶有貶義的。只要有明確的目的和充分的理由,突破舊有原則無疑是一種創新。
雙親委派第四次被破壞
是JDK9之後出現的
JDK 9中雖然仍然維持著三層類載入器和雙親委派的架構,但類載入的委派關係也發生了變動。當平臺及應用程式類載入器收到類載入請求,在委派給父載入器載入前
要先判斷該類是否能夠歸屬到某一個系統模組中,如果可以找到這樣的歸屬關係,就要優先委派給負責那個模組的載入器完成載入,也許這可以算是對雙親委派的第四次破壞