java類載入過程詳解
今天去涉獵了一下類的載入的過程,現在也總結一下:
一個java檔案從被載入到被解除安裝這個生命過程,總共要經歷5個階段:
載入->連結(驗證+準備+解析)->初始化(使用前的準備)->使用->解除安裝
其中載入(除了自定義載入)+連結的過程是完全由jvm負責的,什麼時候要對類進行初始化工作(載入+連結在此之前已經完成了),jvm有嚴格的規定(五種情況):
1.遇到new,getstatic,putstatic,invokestatic這4條位元組碼指令時,假如類還沒進行初始化,則馬上對其進行初始化工作。其實就是3種情況:用new例項化一個類時、讀取或者設定類的靜態欄位時(不包括被final修飾的靜態欄位,因為他們已經被塞進常量池了)、以及執行靜態方法的時候。
2.使用java.lang.reflect.*的方法對類進行反射呼叫的時候,如果類還沒有進行過初始化,馬上對其進行。
3.初始化一個類的時候,如果他的父親還沒有被初始化,則先去初始化其父親。
4.當jvm啟動時,使用者需要指定一個要執行的主類(包含static void main(String[] args)的那個類),則jvm會先去初始化這個類。
5.用Class.forName(String className);來載入類的時候,也會執行初始化動作。注意:ClassLoader的loadClass(String className);方法只會載入並編譯某類,並不會對其執行初始化。
以上5種預處理稱為對一個類進行主動的引用,其餘的其他情況,稱為被動引用,都不會觸發類的初始化。下面也舉了些被動引用的例子:
/**
* 被動引用情景1
* 通過子類引用父類的靜態欄位,不會導致子類的初始化
* @author volador
*
*/
class SuperClass{
static{
System.out.println("super class init.");
}
public static int value=123;
}
class SubClass extends SuperClass{
static{
System.out.println("sub class init." );
}
}
public class test{
public static void main(String[]args){
System.out.println(SubClass.value);
}
}
輸出結果是:super class init 123。
/**
* 被動引用情景2
* 通過陣列引用來引用類,不會觸發此類的初始化
* @author volador
*
*/
public class test{
public static void main(String[] args){
SuperClass s_list=new SuperClass[10];
}
}
輸出結果:沒輸出
/**
* 被動引用情景3
* 常量在編譯階段會被存入呼叫類的常量池中,本質上並沒有引用到定義常量類類,所以自然不會觸發定義常量的類的初始化
* @author root
*
*/
class ConstClass{
static{
System.out.println("ConstClass init.");
}
public final static String value="hello";
}
public class test{
public static void main(String[] args){
System.out.println(ConstClass.value);
}
}
輸出結果:hello(tip:在編譯的時候,ConstClass.value已經被轉變成hello常量放進test類的常量池裡面了)
以上是針對類的初始化,介面也要初始化,介面的初始化跟類的初始化有點不同:
上面的程式碼都是用static{}來輸出初始化資訊的,介面沒法做到,但介面初始化的時候編譯器仍然會給介面生成一個<clinit>()的類構造器,用來初始化介面中的成員變數,這點在類的初始化上也有做到。真正不同的地方在於第三點,類的初始化執行之前要求父類全部都初始化完成了,但介面的初始化貌似對父介面的初始化不怎麼感冒,也就是說,子介面初始化的時候並不要求其父介面也完成初始化,只有在真正使用到父介面的時候它才會被初始化(比如引用介面上的常量的時候啦)。
下面分解一下一個類的載入全過程:載入->驗證->準備->解析->初始化
首先是載入:
這一塊虛擬機器要完成3件事:
1.通過一個類的全限定名來獲取定義此類的二進位制位元組流。
2.將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3.在java堆中生成一個代表這個類的java.lang.Class物件,作為方法區這些資料的訪問入口。
關於第一點,很靈活,很多技術都是在這裡切入,因為它並沒有限定二進位制流從哪裡來:
從class檔案來->一般的檔案載入
從zip包中來->載入jar中的類
從網路中來->Applet
..........
相比與載入過程的其他幾個階段,載入階段可控性最強,因為類的載入器可以用系統的,也可以用自己寫的,程式猿可以用自己的方式寫載入器來控制位元組流的獲取。
獲取二進位制流獲取完成後會按照jvm所需的方式儲存在方法區中,同時會在java堆中例項化一個java.lang.Class物件與堆中的資料關聯起來。
載入完成後就要開始對那些位元組流進行檢驗了(其實很多步驟是跟上面交叉進行的,比如檔案格式驗證):
檢驗的目的:確保class檔案的位元組流資訊符合jvm的口味,不會讓jvm感到不舒服。假如class檔案是由純粹的java程式碼編譯過來的,自然不會出現類似於陣列越界、跳轉到不存在的程式碼塊等不健康的問題,因為一旦出現這種現象,編譯器就會拒絕編譯了。但是,跟之前說的一樣,Class檔案流不一定是從java原始碼編譯過來的,也可能是從網路或者其他地方過來的,甚至你可以自己用16進位制寫,假如jvm不對這些資料進行校驗的話,可能一些有害的位元組流會讓jvm完全崩潰。
檢驗主要經歷幾個步驟:檔案格式驗證->元資料驗證->位元組碼驗證->符號引用驗證
檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範並 驗證其版本是否能被當前的jvm版本所處理。ok沒問題後,位元組流就可以進入記憶體的方法區進行儲存了。後面的3個校驗都是在方法區進行的。
元資料驗證:對位元組碼描述的資訊進行語義化分析,保證其描述的內容符合java語言的語法規範。
位元組碼檢驗:最複雜,對方法體的內容進行檢驗,保證其在執行時不會作出什麼出格的事來。
符號引用驗證:來驗證一些引用的真實性與可行性,比如程式碼裡面引了其他類,這裡就要去檢測一下那些來究竟是否存在;或者說程式碼中訪問了其他類的一些屬性,這裡就對那些屬性的可以訪問行進行了檢驗。(這一步將為後面的解析工作打下基礎)
驗證階段很重要,但也不是必要的,假如說一些程式碼被反覆使用並驗證過可靠性了,實施階段就可以嘗試用-Xverify:none引數來關閉大部分的類驗證措施,以簡短類載入時間。
接著就上面步驟完成後,就會進入準備階段了:
這階段會為類變數(指那些靜態變數)分配記憶體並設定類比那輛初始值的階段,這些記憶體在方法區中進行分配。這裡要說明一下,這一步只會給那些靜態變數設定一個初始的值,而那些例項變數是在例項化物件時進行分配的。這裡的給類變數設初始值跟類變數的賦值有點不同,比如下面:
public static int value=123;
在這一階段,value的值將會是0,而不是123,因為這個時候還沒開始執行任何java程式碼,123還是不可見的,而我們所看到的把123賦值給value的putstatic指令是程式被編譯後存在於<clinit>(),所以,給value賦值為123是在初始化的時候才會執行的。
這裡也有個例外:
public static final int value=123;
這裡在準備階段value的值就會初始化為123了。這個是說,在編譯期,javac會為這個特殊的value生成一個ConstantValue屬性,並在準備階段jm就會根據這個ConstantValue的值來為value賦值了。
完成上步後,就要進行解析了。解析好像是對類的欄位,方法等東西進行轉換,具體涉及到Class檔案的格式內容,並沒深入去了解。
初始化過程是類載入過程的最後一步:
在前面的類載入過程中,除了在載入階段使用者可以通過自定義類載入器參與之外,其他的動作完全有jvm主導,到了初始化這塊,才開始真正執行java裡面的程式碼。
這一步將會執行一些預操作,注意區分在準備階段,已經為類變數執行過一次系統賦值了。
其實說白了,這一步就是執行程式的<clinit>();方法的過程。下面我們來研究一下<clinit>()方法:
<clinit>()方法叫做類構造器方法,有編譯器自動手機類中的所有類變數的賦值動作和靜態語句塊中的語句合併而成的,置於他們的順序與在原始檔中排列的一樣。
<clinit>();方法與類構造方法不一樣,他不需要顯示得呼叫父類的<clinit>();方法,虛擬機器會保證子類的<clinit>();方法在執行前父類的這個方法已經執行完畢了,也就是說,虛擬機器中第一個被執行的<clinit>();方法肯定是java.lang.Object類的。
下面來個例子說明一下:
static class Parent{
public static int A=1;
static{
A=2;
}
}
static class Sub extends Parent{
public static int B=A;
}
public static void main(String[] args){
System.out.println(Sub.B);
}
首先Sub.B中對靜態資料進行了引用,Sub類要進行初始化了。同時,其父類Parent要先進行初始化動作。Parent初始化後,A=2,所以B=2;上個過程相當於:
static class Parent{
<clinit>(){
public static int A=1;
static{
A=2;
}
}
}
static class Sub extends Parent{
<clinit>(){ //jvm會先讓父類的該方法執行完在執行這裡
public static int B=A;
}
}
public static void main(String[] args){
System.out.println(Sub.B);
}
<clinit>();方法對類跟介面來說不是必須的,假如類或者介面中沒有對類變數進行賦值且沒有靜態程式碼塊,<clinit>()方法就不會被編譯器生成。
由於接口裡面不能存在static{}這種靜態程式碼塊,但仍然可能存在變數初始化時的變數賦值操作,所以接口裡面也會生成<clinit>()構造器。但跟類的不同的是,執行子介面的<clinit>();方法前並不需要執行父介面的<clinit>();方法,當父介面中定義的變數被使用時,父接口才會被初始化。
另外,介面的實現類在初始化的時候也一樣不會執行介面的<clinit>();方法。
另外,jvm會保證一個類的<clinit>();方法在多執行緒環境下能被正確地加鎖同步。<因為初始化只會被執行一次>。
下面用個例子說明一下:
public class DeadLoopClass {
static{
if(true){
System.out.println("要被 ["+Thread.currentThread()+"] 初始化了,下面來一個無限迴圈");
while(true){}
}
}
/**
* @param args
*/
public static void main(String[] args) {
// TODO Auto-generated method stub
System.out.println("toplaile");
Runnable run=new Runnable(){
@Override
public void run() {
// TODO Auto-generated method stub
System.out.println("["+Thread.currentThread()+"] 要去例項化那個類了");
DeadLoopClass d=new DeadLoopClass();
System.out.println("["+Thread.currentThread()+"] 完成了那個類的初始化工作");
}};
new Thread(run).start();
new Thread(run).start();
}
}
這裡面,執行的時候將會看到阻塞現象。
呼呼~先到這裡`