Java虛擬機器 - 類載入機制

來源:互聯網
上載者:User

標籤:重要   例子   .class   博文   遞迴   classpath   extend   class類   highlight   

【深入Java虛擬機器】之四:類載入機制

 

類載入過程

    類從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,它的整個生命週期包括:載入、驗證、準備、解析、初始化、使用和卸載七個階段。它們開始的順序如所示:

    其中類載入的過程包括了載入、驗證、準備、解析、初始化五個階段。在這五個階段中,載入、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之後開始,這是為了支援Java語言的運行時綁定(也成為動態綁定或晚期綁定)。另外注意這裡的幾個階段是按順序開始,而不是按順序進行完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中調用或啟用另一個階段。

    這裡簡要說明下Java中的綁定:綁定指的是把一個方法的調用與方法所在的類(方法主體)關聯起來,對java來說,綁定分為靜態繫結和動態綁定:

 

  • 靜態繫結:即前期綁定。在程式執行前方法已經被綁定,此時由編譯器或其它串連程式實現。針對java,簡單的可以理解為程式編譯期的綁定。java當中的方法只有final,static,private和構造方法是前期綁定的。
  • 動態綁定:即晚期綁定,也叫運行時綁定。在運行時根據具體對象的類型進行綁定。在java中,幾乎所有的方法都是後期綁定的。
    下面詳細講述類載入過程中每個階段所做的工作。    載入

 

    載入時類載入過程的第一個階段,在載入階段,虛擬機器需要完成以下三件事情:

    1、通過一個類的全限定名來擷取其定義的二進位位元組流。

    2、將這個位元組流所代表的靜態儲存結構轉化為方法區的運行時資料結構。

    3、在Java堆中產生一個代表這個類的java.lang.Class對象,作為對方法區中這些資料的訪問入口。

    注意,這裡第1條中的二進位位元組流並不只是單純地從Class檔案中擷取,比如它還可以從Jar包中擷取、從網路中擷取(最典型的應用便是Applet)、由其他檔案產生(JSP應用)等。

    相對於類載入的其他階段而言,載入階段(準確地說,是載入階段擷取類的二進位位元組流的動作)是可控性最強的階段,因為開發人員既可以使用系統提供的類載入器來完成載入,也可以自訂自己的類載入器來完成載入。

    載入階段完成後,虛擬機器外部的 二進位位元組流就按照虛擬機器所需的格式儲存在方法區之中,而且在Java堆中也建立一個java.lang.Class類的對象,這樣便可以通過該對象存取方法區中的這些資料。

    說到載入,不得不提到類載入器,下面就具體講述下類載入器。

    類載入器雖然只用於實作類別的載入動作,但它在Java程式中起到的作用卻遠遠不限於類的載入階段。對於任意一個類,都需要由它的類載入器和這個類本身一同確定其在就Java虛擬機器中的唯一性,也就是說,即使兩個類來源於同一個Class檔案,只要載入它們的類載入器不同,那這兩個類就必定不相等。這裡的“相等”包括了代表類的Class對象的equals()、isAssignableFrom()、isInstance()等方法的返回結果,也包括了使用instanceof關鍵字對對象所屬關係的判定結果。

    站在Java虛擬機器的角度來講,只存在兩種不同的類載入器:

 

  • 啟動類載入器:它使用C++實現(這裡僅限於Hotspot,也就是JDK1.5之後預設的虛擬機器,有很多其他的虛擬機器是用Java語言實現的),是虛擬機器自身的一部分。
  • 所有其他的類載入器:這些類載入器都由Java語言實現,獨立於虛擬機器之外,並且全部繼承自抽象類別java.lang.ClassLoader,這些類載入器需要由啟動類載入器載入到記憶體中之後才能去載入其他的類。

 

    站在Java開發人員的角度來看,類載入器可以大致劃分為以下三類:

 

  • 啟動類載入器:Bootstrap ClassLoader,跟上面相同。它負責載入存放在JDK\jre\lib(JDK代表JDK的安裝目錄,下同)下,或被-Xbootclasspath參數指定的路徑中的,並且能被虛擬機器識別的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader載入)。啟動類載入器是無法被Java程式直接引用的。
  • 擴充類載入器:Extension ClassLoader,該載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入JDK\jre\lib\ext目錄中,或者由java.ext.dirs系統變數指定的路徑中的所有類庫(如javax.*開頭的類),開發人員可以直接使用擴充類載入器。
  • 應用程式類載入器:Application ClassLoader,該類載入器由sun.misc.Launcher$AppClassLoader來實現,它負責載入使用者類路徑(ClassPath)所指定的類,開發人員可以直接使用該類載入器,如果應用程式中沒有自訂過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

 

     應用程式都是由這三種類載入器互相配合進行載入的,如果有必要,我們還可以加入自訂的類載入器。因為JVM內建的ClassLoader只是懂得從本地檔案系統載入標準的java class檔案,因此如果編寫了自己的ClassLoader,便可以做到如下幾點:

 1)在執行非置信代碼之前,自動驗證數位簽章。

 2)動態地建立符合使用者特定需要的定製化構建類。

 3)從特定的場所取得java class,例如資料庫中和網路中。

事實上當使用Applet的時候,就用到了特定的ClassLoader,因為這時需要從網路上載入java class,並且要檢查相關的安全資訊,應用伺服器也大都使用了自訂的ClassLoader技術。

 

    這幾種類載入器的層次關係如所示:

    這種層次關係稱為類載入器的雙親委派模型。我們把每一層上面的類載入器叫做當前層類載入器的父載入器,當然,它們之間的父子關係並不是通過繼承關係來實現的,而是使用組合關係來複用父載入器中的代碼。該模型在JDK1.2期間被引入並廣泛應用於之後幾乎所有的Java程式中,但它並不是一個強制性的約束模型,而是Java設計者們推薦給開發人員的一種類的載入器實現方式。

    雙親委派模型的工作流程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把請求委託給父載入器去完成,依次向上,因此,所有的類載入請求最終都應該被傳遞到頂層的啟動類載入器中,只有當父載入器在它的搜尋範圍中沒有找到所需的類時,即無法完成該載入,子載入器才會嘗試自己去載入該類。

    使用雙親委派模型來組織類載入器之間的關係,有一個很明顯的好處,就是Java類隨著它的類載入器(說白了,就是它所在的目錄)一起具備了一種帶有優先順序的層次關係,這對於保證Java程式的穩定運作很重要。例如,類java.lang.Object類存放在JDK\jre\lib下的rt.jar之中,因此無論是哪個類載入器要載入此類,最終都會委派給啟動類載入器進行載入,這邊保證了Object類在程式中的各種類載入器中都是同一個類。

 

 

   驗證

    驗證的目的是為了確保Class檔案中的位元組流包含的資訊符合當前虛擬機器的要求,而且不會危害虛擬機器自身的安全。不同的虛擬機器對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:檔案格式的驗證、中繼資料的驗證、位元組碼驗證和符號引用驗證。

 

 

  • 檔案格式的驗證:驗證位元組流是否符合Class檔案格式的規範,並且能被目前的版本的虛擬機器處理,該驗證的主要目的是保證輸入的位元組流能正確地解析並儲存於方法區之內。經過該階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,後面的三個驗證都是基於方法區的儲存結構進行的。
  • 中繼資料驗證:對類的中繼資料資訊進行語義校正(其實就是對類中的各資料類型進行文法校正),保證不存在不符合Java文法規範的中繼資料資訊。
  • 位元組碼驗證:該階段驗證的主要工作是進行資料流和控制流程分析,對類的方法體進行校正分析,以保證被校正的類的方法在運行時不會做出危害虛擬機器安全的行為。
  • 符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機器將符號引用轉化為直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的資訊(常量池中的各種符號引用)進行匹配性的校正。
 

 

 

   準備

    準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中分配。對於該階段有以下幾點需要注意:

 

    1、這時候進行記憶體配置的僅包括類變數(static),而不包括執行個體變數,執行個體變數會在對象執行個體化時隨著對象一塊分配在Java堆中。

    2、這裡所設定的初始值通常情況下是資料類型預設的零值(如0、0L、null、false等),而不是被在Java代碼中被顯式地賦予的值。

   假設一個類變數的定義為:

public static int value = 3;

    那麼變數value在準備階段過後的初始值為0,而不是3,因為這時候尚未開始執行任何Java方法,而把value賦值為3的putstatic指令是在程式編譯後,存放於類構造器<clinit>()方法之中的,所以把value賦值為3的動作將在初始化階段才會執行。

    下表列出了Java中所有基礎資料型別 (Elementary Data Type)以及reference類型的預設零值:

   這裡還需要注意如下幾點:

 

  • 對基礎資料型別 (Elementary Data Type)來說,對於類變數(static)和全域變數,如果不顯式地對其賦值而直接使用,則系統會為其賦予預設的零值,而對於局部變數來說,在使用前必須顯式地為其賦值,否則編譯時間不通過。
  • 對於同時被static和final修飾的常量,必須在聲明的時候就為其顯式地賦值,否則編譯時間不通過;而只被final修飾的常量則既可以在聲明時顯式地為其賦值,也可以在類初始化時顯式地為其賦值,總之,在使用前必須為其顯式地賦值,系統不會為其賦予預設零值。
  • 對於引用資料類型reference來說,如數組引用、對象引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會為其賦予預設的零值,即null。
  • 如果在數組初始化時沒有對數組中的各元素賦值,那麼其中的元素將根據對應的資料類型而被賦予預設的零值。

 

    3、如果類欄位的欄位屬性工作表中存在ConstantValue屬性,即同時被final和static修飾,那麼在準備階段變數value就會被初始化為ConstValue屬性所指定的值。

   假設上面的類變數value被定義為: 

public static final int value = 3;

    編譯時間Javac將會為value產生ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為3。回憶上一篇博文中對象被動引用的第2個例子,便是這種情況。我們可以理解為static final常量在編譯期就將其結果放入了調用它的類的常量池中。

 

 

   解析   解析階段是虛擬機器將常量池中的符號引用轉化為直接引用的過程。在 Class類檔案結構一文中已經比較過了符號引用和直接引用的區別和關聯,這裡不再贅述。前面說解析階段可能開始於初始化之前,也可能在初始化之後開始,虛擬機器會根據需要來判斷,到底是在類被載入器載入時就對常量池中的符號引用進行解析(初始化之前),還是等到一個符號引用將要被使用前才去解析它(初始化之後)。    對同一個符號引用進行多次解析請求時很常見的事情,虛擬機器實現可能會對第一次解析的結果進行緩衝(在運行時常量池中記錄直接引用,並把常量標示為已解析狀態),從而避免解析動作重複進行。    解析動作主要針對類或介面、欄位、類方法、介面方法四類符號引用進行,分別對應於常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四種常量類型。      1、類或介面的解析:判斷所要轉化成的直接引用是對數群組類型,還是普通的物件類型的引用,從而進行不同的解析。     2、欄位解析:對欄位進行解析時,會先在本類中尋找是否包含有簡單名稱和欄位描述符都與目標相匹配的欄位,如果有,則尋找結束;如果沒有,則會按照繼承關係從上往下遞迴搜尋該類所實現的各個介面和它們的父介面,還沒有,則按照繼承關係從上往下遞迴搜尋其父類,直至尋找結束,尋找流程如所示:    從下面一段代碼的執行結果中很容易看出來欄位解析的搜尋順序: [java] view plain copy 
  1. class Super{  
  2.     public static int m = 11;  
  3.     static{  
  4.         System.out.println("執行了super類靜態語句塊");  
  5.     }  
  6. }  
  7.   
  8.   
  9. class Father extends Super{  
  10.     public static int m = 33;  
  11.     static{  
  12.         System.out.println("執行了父類靜態語句塊");  
  13.     }  
  14. }  
  15.   
  16. class Child extends Father{  
  17.     static{  
  18.         System.out.println("執行了子類靜態語句塊");  
  19.     }  
  20. }  
  21.   
  22. public class StaticTest{  
  23.     public static void main(String[] args){  
  24.         System.out.println(Child.m);  
  25.     }  
  26. }  
    執行結果如下:    執行了super類靜態語句塊
    執行了父類靜態語句塊
    33
    如果注釋掉Father類中對m定義的那一行,則輸出結果如下:    執行了super類靜態語句塊
    11   另外,很明顯這就是 上篇博文中的第1個例子的情況,這裡我們便可以分析如下:static變數發生在靜態解析階段,也即是初始化之前,此時已經將欄位的符號引用轉化為了記憶體引用,也便將它與對應的類關聯在了一起,由於在子類中沒有尋找到與m相匹配的欄位,那麼m便不會與子類關聯在一起,因此並不會觸發子類的初始化。    最後需要注意:理論上是按照上述順序進行搜尋解析,但在實際應用中,虛擬機器的編譯器實現可能要比上述規範要求的更嚴格一些。如果有一個同名欄位同時出現在該類的介面和父類中,或同時在自己或父類的介面中出現,編譯器可能會拒絕編譯。如果對上面的代碼做些修改,將Super改為介面,並將Child類繼承Father類且實現Super介面,那麼在編譯時間會報出如下錯誤:StaticTest.java:24: 對 m 的引用不明確,Father 中的 變數 m 和 Super 中的 變數 m
都匹配
                System.out.println(Child.m);
                                        ^
1 錯誤         3、類方法解析:對類方法的解析與對欄位解析的搜尋步驟差不多,只是多了判斷該方法所處的是類還是介面的步驟,而且對類方法的匹配搜尋,是先搜尋父類,再搜尋介面。     4、介面方法解析:與類方法解析步驟類似,知識介面不會有父類,因此,只遞迴向上搜尋父介面就行了。
    初始化    初始化是類載入過程的最後一步,到了此階段,才真正開始執行類中定義的Java程式碼。在準備階段,類變數已經被賦過一次系統要求的初始值,而在初始化階段,則是根據程式員通過程式指定的主觀計划去初始化類變數和其他資源,或者可以從另一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。   這裡簡單說明下<clinit>()方法的執行規則:    1、<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合并產生的,編譯器收集的順序是由語句在源檔案中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句中可以賦值,但是不能訪問。    2、<clinit>()方法與執行個體構造器<init>()方法(類的建構函式)不同,它不需要顯式地調用父類構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此,在虛擬機器中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。    3、<clinit>()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類產生<clinit>()方法。    4、介面中不能使用靜態語句塊,但仍然有類變數(final static)初始化的賦值操作,因此介面與類一樣會產生<clinit>()方法。但是介面魚類不同的是:執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,只有當父介面中定義的變數被使用時,父介面才會被初始化。另外,介面的實作類別在初始化時也一樣不會執行介面的<clinit>()方法。    5、虛擬機器會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個線程阻塞,在實際應用中這種阻塞往往是很隱形。     下面給出一個簡單的例子,以便更清晰地說明如上規則: [java] view plain copy 
  1. class Father{  
  2.     public static int a = 1;  
  3.     static{  
  4.         a = 2;  
  5.     }  
  6. }  
  7.   
  8. class Child extends Father{  
  9.     public static int b = a;  
  10. }  
  11.   
  12. public class ClinitTest{  
  13.     public static void main(String[] args){  
  14.         System.out.println(Child.b);  
  15.     }  
  16. }  
   執行上面的代碼,會列印出2,也就是說b的值被賦為了2。    我們來看得到該結果的步驟。首先在準備階段為類變數分配記憶體並設定類變數初始值,這樣A和B均被賦值為預設值0,而後再在調用<clinit>()方法時給他們賦予程式中指定的值。當我們調用Child.b時,觸發Child的<clinit>()方法,根據規則2,在此之前,要先執行完其父類Father的<clinit>()方法,又根據規則1,在執行<clinit>()方法時,需要按static語句或static變數賦值操作等在代碼中出現的順序來執行相關的static語句,因此當觸發執行Father的<clinit>()方法時,會先將a賦值為1,再執行static語句塊中語句,將a賦值為2,而後再執行Child類的<clinit>()方法,這樣便會將b的賦值為2.    如果我們顛倒一下Father類中“public static int a = 1;”語句和“static語句塊”的順序,程式執行後,則會列印出1。很明顯是根據規則1,執行Father的<clinit>()方法時,根據順序先執行了static語句塊中的內容,後執行了“public static int a = 1;”語句。    另外,在顛倒二者的順序之後,如果在static語句塊中對a進行訪問(比如將a賦給某個變數),在編譯時間將會報錯,因為根據規則1,它只能對a進行賦值,而不能訪問。


總結     整個類載入過程中,除了在載入階段使用者應用程式可以自訂類載入器參與之外,其餘所有的動作完全由虛擬機器主導和控制。到了初始化才開始執行類中定義的Java程式碼(亦及位元組碼),但這裡的執行代碼只是個開端,它僅限於<clinit>()方法。類載入過程中主要是將Class檔案(準確地講,應該是類的二進位位元組流)載入到虛擬機器記憶體中,真正執行位元組碼的操作,在載入完成後才真正開始。

Java虛擬機器 - 類載入機制

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.