1. 程式人生 > 程式設計 >詳解Java 類的載入機制

詳解Java 類的載入機制

一、類的載入機制

  虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

  類的載入指的是將類的.class檔案中的二進位制資料讀入到記憶體中,將其放在執行時資料區的方法區內,然後在堆區建立一個java.lang.Class物件,用來封裝類在方法區內的資料結構。類的載入的最終產品是位於堆區中的Class物件,Class物件封裝了類在方法區內的資料結構,並且向Java程式設計師提供了訪問方法區內的資料結構的介面。

詳解Java 類的載入機制

  類載入器並不需要等到某個類被“首次主動使用”時再載入它,JVM規範允許類載入器在預料某個類將要被使用時就預先載入它,如果在預先載入的過程中遇到了.class檔案缺失或存在錯誤,類載入器必須在程式首次主動使用該類時才報告錯誤(LinkageError錯誤)如果這個類一直沒有被程式主動使用,那麼類載入器就不會報告錯誤

載入.class檔案的方式

– 從本地系統中直接載入
– 通過網路下載.class檔案,這種場景最典型的應用就是Applet
– 從zip,jar等歸檔檔案中載入.class檔案
– 從專有資料庫中提取.class檔案
– 將Java原始檔動態編譯為.class檔案

二、類的載入時機

  類從被載入到虛擬機器記憶體中開始,直到卸載出記憶體為止,它的整個生命週期包括了:載入、驗證、準備、解析、初始化、使用和解除安裝這7個階段。其中,驗證、準備和解析這三個部分統稱為連線(linking)。

詳解Java 類的載入機制

  其中,載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,類的載入過程必須按照這種順序按部就班的“開始”(僅僅指的是開始,而非執行或者結束,因為這些階段通常都是互相交叉的混合進行,通常會在一個階段執行的過程中呼叫或者啟用另一個階段),而解析階段則不一定(它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結(也稱為動態繫結或晚期繫結)。

三、類的載入過程

  接下來詳細講解一下Java虛擬機器中類載入的全過程,也就是載入、驗證、準備、解析和初始化這五個階段所執行的具體動作。

  3.1 載入

  “載入”(Loading)階段是“類載入”(Class Loading)過程的第一個階段,在此階段,虛擬機器需要完成以下三件事情:

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

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

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

  上面的第一步獲取二進位制位元組流,並沒有限定只能從編譯好的.class檔案中獲取,也可以是zip包,jar,war,網路流(Applet),執行時計算生成(如動態代理,通過反射在執行時動態生成代理類),其他檔案(如jsp,因jsp最終會編譯成class),資料庫(用的場景較少)。

  對於陣列類的載入,和普通類的載入有所不同。陣列類本身不通過類載入器載入,而是由虛擬機器直接完成。但是陣列類的元素型別(指陣列類去除維度之後的型別,如String[] 陣列的元素型別就是 String)是靠類載入器載入的。

  載入階段完成之後,虛擬機器就會把外部的二進位制位元組流(不論從何處獲取的)按照一定的資料格式儲存在執行時資料區中的方法區。然後在記憶體中例項化一個java.lang.Class物件(Class這個物件比較特殊,它存放在方法區中而不是堆中),這個物件將作為程式訪問方法區中的這些資料的外部介面。

載入階段即可以使用系統提供的類載入器在完成,也可以由使用者自定義的類載入器來完成。載入階段與連線階段的部分內容(如一部分位元組碼檔案格式驗證動作)是交叉進行的,載入階段尚未完成,連線階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於連線階段的內容,這兩個階段的開始時間仍然保持著固定的先後順序。

  3.2 驗證

驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

Java語言本身是相對安全的語言,使用Java編碼是無法做到如訪問陣列邊界以外的資料、將一個物件轉型為它並未實現的型別等,如果這樣做了,編譯器將拒絕編譯。但是,Class檔案並不一定是由Java原始碼編譯而來,可以使用任何途徑,包括用十六進位制編輯器(如UltraEdit)直接編寫。如果直接編寫了有害的“程式碼”(位元組流),而虛擬機器在載入該Class時不進行檢查的話,就有可能危害到虛擬機器或程式的安全。

不同的虛擬機器,對類驗證的實現可能有所不同,但大致都會完成下面四個階段的驗證:檔案格式驗證、元資料驗證、位元組碼驗證和符號引用驗證。

1、檔案格式驗證,是要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。如驗證魔數是否0xCAFEBABE;主、次版本號是否正在當前虛擬機器處理範圍之內;常量池的常量中是否有不被支援的常量型別……該驗證階段的主要目的是保證輸入的位元組流能正確地解析並存儲於方法區中,經過這個階段的驗證後,位元組流才會進入記憶體的方法區中儲存,所以後面的三個驗證階段都是基於方法區的儲存結構進行的。

2、元資料驗證,是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求。可能包括的驗證如:這個類是否有父類;這個類的父類是否繼承了不允許被繼承的類;如果這個類不是抽象類,是否實現了其父類或介面中要求實現的所有方法……

3、位元組碼驗證,主要工作是進行資料流和控制流分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的行為。如果一個類方法體的位元組碼沒有通過位元組碼驗證,那肯定是有問題的;但如果一個方法體通過了位元組碼驗證,也不能說明其一定就是安全的。

4、符號引用驗證,發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在“解析階段”中發生。驗證符號引用中通過字串描述的許可權定名是否能找到對應的類;在指定類中是否存在符合方法欄位的描述符及簡單名稱所描述的方法和欄位;符號引用中的類、欄位和方法的訪問性(private、protected、public、default)是否可被當前類訪問

驗證階段對於虛擬機器的類載入機制來說,不一定是必要的階段。如果所執行的全部程式碼確認是安全的,可以使用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入時間。

  3.3 準備

  準備階段是類變數分配記憶體並設定初始值的階段。這裡的類變數指的是被static修飾的變數,而不包括例項變數。類變數被分配到方法區中,而例項變數存放在堆中。

  這裡的初始值指的是資料型別的預設值,而不是程式碼中所賦的值。例如

  publicstaticintvalue = 1 ;

  在準備階段之後,value值為0,而不是1。賦值為1的動作發生在初始化階段。

  但是,也要特殊情況,如果變數被static 和 final同時修飾,則準備階段直接賦值為指定值。如

  public finallystaticintvalue = 1 ;

  在準備階段之後,value的值即為1.

  各資料型別的初始預設值如下:

詳解Java 類的載入機制

  3.4 解析

  解析階段是將常量池中的符號引用轉換為直接引用的過程。那什麼是符號引用和直接引用呢?

  符號引用是用一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可(前面JVM的模型中,也提到了符號引用,它存在於常量池中,包括類和介面的全限定名、欄位的名稱和描述符、方法的名稱和描述符)。看概念可能比較抽象,可以理解為它就是一個代號,就像你有一個大名,同時也有一個小名,但是不管怎麼叫指代的都是你本人。

  直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。

  解析動作主要針對類或介面、欄位、類方法、介面方法、方法屬性、方法控制代碼、呼叫點限定符7類符號引用。此處分別介紹一下前四種的解析過程。

  1、類或介面的解析

  如果類C不是陣列型別,那麼虛擬機器會把類C直接傳給類載入器。如果類C是陣列型別並且元素型別是物件(如String[]),那麼先用類載入器載入元素型別(String型別),再由虛擬機器建立代表此陣列維度和元素的陣列物件。判斷呼叫類是否有許可權訪問被載入類,如果不允許的話,就丟擲IllegalAccessError異常。

  2、欄位的解析

  首先解析欄位所屬的類或介面的符號引用。如果類中有欄位的符號引用(欄位的名稱和描述符)和目標欄位相匹配,則返回這個欄位的直接引用。如果沒有,則自下而上查詢其實現的介面和父介面,若匹配到,則返回這個欄位的直接引用。如果還沒有,就自下而上查詢其繼承的父類,若匹配到,則返回這個欄位的直接引用。否則,查詢失敗,丟擲NoSuchFieldError異常。最後如果查詢成功的話,會判斷欄位訪問許可權,如果該欄位不允許訪問,則丟擲 IllegalAccessError異常。

  3、類方法解析

  類方法解析第一步同欄位解析一樣,也需要先解析方法所屬的類或介面的符號引用。類方法和介面方法符號引用的常量型別是分開的。如果,在類方法中解析出來的是一個介面,則會丟擲 IncompatibleClassChangeError 異常。如果在類中有方法的符號引用(方法的名稱和描述符)和目標方法相匹配,則返回這個方法的直接引用,查詢結束。否則,在類的父類中遞迴查詢,若找到則返回,查詢結束。否則,查詢它實現的介面和父介面,如果找到,說明此類是一個抽象類,丟擲 AbstractMethodError異常。若都找不到,就丟擲NoSuchMethodError 異常。最後,如果查詢成功,會判斷此方法是否有訪問許可權,若沒有,則丟擲 IllegalAccessError異常。

  4、介面方法的解析

  首先解析方法所屬的類或介面的符號引用,和類方法解析同理,如果發現解析出來是一個類方法,則會丟擲 IncompatibleClassChangeError 異常。如果所屬介面中匹配到目標方法,則返回此方法的直接引用。否則,在父介面中查詢,若找到,則返回。否則,查詢失敗,丟擲 NoSuchMethodError 異常。由於介面的方法都是public的,所以不存在訪問許可權的問題。

  3.5 初始化

  這是類載入的最後一步,到這才真正開始執行Java程式碼。在準備階段,已經為類變數分配記憶體,並賦值了預設值。在初始階段,則可以根據需要來賦值了。可以說,初始化階段是執行類構造器 < clinit > 方法的過程。

  首先說下類構造器 < clinit > 方法和例項構造器 < init > 方法有什麼區別。< clinit > 方法是在類載入的初始化階段執行,是對靜態變數、靜態程式碼塊進行的初始化。而< init > 方法是new一個物件,即呼叫類的 constructor方法時才會執行,是對非靜態變數進行的初始化。

  類構造器方法有如下特點:

  保證父類的 < clinit > 方法執行完畢,再執行子類的 < clinit > 方法。由於父類的 < clinit > 方法先執行,所以父類的靜態程式碼塊也優於子類執行。如果類中沒有靜態程式碼塊,也沒有為變數賦值,則可以不生成 < clinit > 方法。執行介面的 < clinit > 方法時,不需要先執行父介面的 < clinit > 方法。只有父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也不執行介面的 < clinit > 方法。虛擬機器會保證在多執行緒環境下 < clinit > 方法能被正確的加鎖、同步。如果有多個執行緒同時請求載入一個類,那麼只會有一個執行緒去執行這個類的 < clinit > 方法,其他執行緒都會阻塞,直到方法執行完畢。同時,其他執行緒也不會再去執行 < clinit > 方法了。這就保證了同一個類載入器下,一個類只會初始化一次。(這也是為什麼說餓漢式單例模式是執行緒安全的,因為類只會載入一次。)類的初始化時機:只有對類主動使用的時候才會觸發初始化,主動使用的場景如下:

  使用new關鍵詞建立物件時,訪問某個類的靜態變數或給靜態變數賦值時,呼叫類的靜態方法時。反射呼叫時,會觸發類的初始化(如Class.forName())初始化一個類的時候,如其父類未初始化,則會先觸發父類的初始化。虛擬機器啟動時,會先初始化主類(即包含main方法的類)。另外,也有些場景並不會觸發類的初始化:

  通過子類呼叫父類的靜態變數,只會觸發父類的初始化,而不會觸發子類的初始化(因為,對於靜態變數,只有直接定義這個變數的類才會初始化)。通過陣列來建立物件不會觸發此類的初始化。(如定義一個自定義的Person[] 陣列,不會觸發Person類的初始化)通過呼叫靜態常量(即static final修飾的變數),並不會觸發此類的初始化。因為,在編譯階段,就已經把final修飾的變數放到常量池中了,本質上並沒有直接引用到定義常量的類,因此不會觸發類的初始化。

四、題目分析

  上面很詳細的介紹了類的載入時機和類的載入過程,通過上面的理論來分析本文開門見上的題目

class SingleTon {
  private static SingleTon singleTon = new SingleTon();
  public static int count1;
  public static int count2 = 0;
 
  private SingleTon() {
    count1++;
    count2++;
  }
 
  public static SingleTon getInstance() {
    return singleTon;
  }
}
 
public class Test {
  public static void main(String[] args) {
    SingleTon singleTon = SingleTon.getInstance();
    System.out.println("count1=" + singleTon.count1);
    System.out.println("count2=" + singleTon.count2);
  }
}

分析:

  1、SingleTon singleTon = SingleTon.getInstance();呼叫了類的SingleTon呼叫了類的靜態方法,觸發類的初始化
  2、類載入的時候在準備過程中為類的靜態變數分配記憶體並初始化預設值 singleton=null count1=0,count2=0
  3、類初始化化,為類的靜態變數賦值和執行靜態程式碼快。singleton賦值為new SingleTon()呼叫類的構造方法
  4、呼叫類的構造方法後count=1;count2=1
  5、繼續為count1與count2賦值,此時count1沒有賦值操作,所有count1為1,但是count2執行賦值操作就變為0

參考:

《深入理解Java虛擬機器:JVM高階特性與最佳實踐》

以上就是詳解Java 類的載入機制的詳細內容,更多關於Java 類的載入的資料請關注我們其它相關文章!