1. 程式人生 > >JVM 之 (12) 類載入機制

JVM 之 (12) 類載入機制


1.概述

       Class檔案由類裝載器裝載後,在JVM中將形成一份描述Class結構的元資訊物件,通過該元資訊物件可以獲知Class的結構資訊:如建構函式,屬性和方法等,Java允許使用者藉由這個Class相關的元資訊物件間接呼叫Class物件的功能。

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


2.工作機制

      類裝載器就是尋找類的位元組碼檔案,並構造出類在JVM內部表示的物件元件。在Java中,類裝載器把一個類裝入JVM中,要經過以下步驟:

     (1) 裝載:查詢和匯入Class檔案;

     (2) 連結:把類的二進位制資料合併到JRE中;

        (a)校驗:檢查載入Class檔案資料的正確性;

        (b)準備:給類的靜態變數分配儲存空間;

        (c)解析:將符號引用轉成直接引用;

     (3) 初始化:對類的靜態變數,靜態程式碼塊執行初始化操作



    Java程式可以動態擴充套件是由執行期動態載入和動態連結實現的;比如:如果編寫一個使用介面的應用程式,可以等到執行時再指定其實際的實現(多型),解析過程有時候還可以在初始化之後執行;比如:動態繫結(多型);

3.裝載

 在裝載階段,虛擬機器需要完成以下3件事情
          (1) 通過一個類的全限定名來獲取定義此類的二進位制位元組流

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

        (3) 在Java堆中生成一個代表這個類的java.lang.Class物件,作為方法區這些資料的訪問入口。(不同虛擬機器機制不同,hotsport把Class物件放在方法區中)

    虛擬機器規範中並沒有準確說明二進位制位元組流應該從哪裡獲取以及怎樣獲取,這裡可以通過定義自己的類載入器去控制位元組流的獲取方式,譬如:網路、動態生成、資料庫等


4.驗證

    驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
驗證階段大致會完成4個階段的檢驗動作:
                (1) 檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範;例如:是否以魔術0xCAFEBABE開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別。
                 (2) 元資料驗證:對位元組碼描述的資訊進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的資訊符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object之外。
                 (3) 位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。
                 (4) 符號引用驗證:確保解析動作能正確執行。
驗證階段是非常重要的,但不是必須的,它對程式執行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverifynone引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

5.準備

    準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在堆中。其次,這裡所說的初始值“通常情況”下是資料型別的零值,假設一個類變數的定義為:
public static int value=123;
        那變數value在準備階段過後的初始值為0而不是123.因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。
至於“特殊情況”是指:public static final int value=123,即當類欄位的欄位屬性是ConstantValue時,會在準備階段初始化為指定的值,所以標註為final之後,value的值在準備階段初始化為123而非0.

6.解析

        解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符7類符號引用進行。

CONSTANT_Class_info
CONSTANT_Field_info
CONSTANT_Method_info

        符號引用與虛擬機器實現的佈局無關,引用的目標並不一定要已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。
        直接引用可以是指向目標的指標,相對偏移量或是一個能間接定位到目標的控制代碼。如果有了直接引用,那引用的目標必定已經在記憶體中存在。    


7.初始化

        初始化階段是類載入最後一個階段,前面的類載入階段之後,除了在載入階段可以自定義類載入器以外,其它操作都由JVM主導。到了初始階段,才開始真正執行類中定義的Java程式程式碼。
        初始化階段是執行類構造器<client>方法的過程。<client>方法是由編譯器自動收集類中的類變數的賦值操作和靜態語句塊中的語句合併而成的。虛擬機器會保證<client>方法執行之前,父類的<client>方法已經執行完畢。p.s: 如果一個類中沒有對靜態變數賦值也沒有靜態語句塊,那麼編譯器可以不為這個類生成<client>()方法。

    <clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊static{}中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。如下:
public class Test
{
    static
    {
        i=0;
        System.out.println(i);//這句編譯器會報錯:Cannot reference a field before it is defined(非法向前應用)
    }
    static int i=1;
}
<clinit>()方法與例項構造器<init>()方法不同,它不需要顯示地呼叫父類構造器,虛擬機器會保證在子類<init>()方法執行之前,父類的<clinit>()方法方法已經執行完畢, 由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。如下:
public 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);     //2
    }
}
        <clinit>()方法對於類或者介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生產<clinit>()方法。
        介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
        虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確的加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有好事很長的操作,就可能造成多個執行緒阻塞,在實際應用中這種阻塞往往是隱藏的。
public class DealLoopTest
{
    static class DeadLoopClass
    {
        static
        {
            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
需要注意的是,其他執行緒雖然會被阻塞,但如果執行<clinit>()方法的那條執行緒退出<clinit>()方法後,其他執行緒喚醒之後不會再次進入<clinit>()方法。同一個類載入器下,一個型別只會初始化一次。
將上面程式碼中的靜態塊替換如下:
static
        {
            System.out.println(Thread.currentThread() + "init DeadLoopClass");
            try
            {
                TimeUnit.SECONDS.sleep(10);
            }
            catch (InterruptedException e)
            {
                e.printStackTrace();
            }
        }
Thread[Thread-0,5,main] start
Thread[Thread-1,5,main] start
Thread[Thread-1,5,main]init DeadLoopClass (之後sleep 10s)
Thread[Thread-1,5,main] run over
Thread[Thread-0,5,main] run over
虛擬機器規範嚴格規定了有且只有5中情況(jdk1.7)必須對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):

      (1) 遇到new、getstatic、putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java程式碼場景是:使用new關鍵字例項化物件的時候,讀取或設定一個類的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候,以及呼叫一個類的靜態方法的時候。

      (2) 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。

      (3) 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

      (4) 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。

      (5) 當使用jdk1.7動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getstatic,REF_putstatic,REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行初始化,則需要先出觸發其初始化。

只有上述這五種情況會觸發初始化,也稱為對一個類進行主動引用,除此以外,所有其他方式都不會觸發初始化,稱為被動引用.

注意以下幾種情況不會執行類初始化

  • 通過子類引用父類的靜態欄位,只會觸發父類的初始化,而不會觸發子類的初始化。
  • 定義物件陣列,不會觸發該類的初始化。
  • 常量在編譯期間會存入呼叫類的常量池中,本質上並沒有直接引用定義常量的類,不會觸發定義常量所在的類。
  • 通過類名獲取Class物件,不會觸發類的初始化。
  • 通過Class.forName載入指定類時,如果指定引數initialize為false時,也不會觸發類初始化,其實這個引數是告訴虛擬機器,是否要對類進行初始化。
  • 通過ClassLoader預設的loadClass方法,也不會觸發初始化動作。
  • final變數不會觸發此類的初始化,因為在編譯階段就儲存在常量池中.