目錄
類加載
類加載的時機
類加載的程序
加載階段
驗證階段
準備階段
決議階段
初始化階段
類加載器
雙親委派
破壞雙親委派
本文摘自(深入理解JVM)
如有錯誤望指出
類加載
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中雖然仍然維持著三層類加載器和雙親委派的架構,但類加載的委派關系也發生了變動,當平臺及應用程式類加載器收到類加載請求,在委派給父加載器加載前
要先判斷該類是否能夠歸屬到某一個系統模塊中,如果可以找到這樣的歸屬關系,就要優先委派給負責那個模塊的加載器完成加載,也許這可以算是對雙親委派的第四次破壞
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/400433.html
標籤:其他
