虛擬機類加載機制

什么是類加載?

虛擬機把類的數據從Class文件(二進制字節流)加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java數據類型。

類加載的生命周期

加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱為連接(Linking)。

類加載的時機

加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,就是為了支持Java語言的運行時綁定(也成為動態綁定或晚期綁定)。注意這里寫的是“開始”,而不是“進行”或“完成”,強調這點是因為這些階段通常都是互相交叉地混合式進行的,通常會在一個階段執行的過程中調用、激活另一個階段。

加載這個階段Java虛擬機規范中并沒有進行強制約束,這是由虛擬機的具體實現決定的。但是對于初始化階段,虛擬機規范則是嚴格規定了==有且只有5中情況必須立即對類進行“初始化”==(而加載、驗證、準備自然需要在此之前開始):

  • 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先出發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段中)的時候,以及調用一個類的靜態方法的時候。
  • 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main方法的那個類),虛擬機會先初始化這個主類。
  • 當使用jdk1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

對于這5種會觸發類進行初始化的場景,虛擬機規范中使用了一個很強烈的限定語:“有且只有”,這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。

下面是被動引用的例子:

public class SuperClass {
    public static int value = 123;
    static {
        System.out.println("SuperClass init!");
    }
}
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
    public static final String HELLOWORLD = "helloworld";
}
public class NotInitialization1 {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}
public class NotInitialization2 {
    public static void main(String[] args) {
        SubClass[] arr = new SubClass[10];
    }
}
public class NotInitialization3 {
    public static void main(String[] args) {
        System.out.println(SubClass.HELLOWORLD);
    }
}
  • NotInitialization1啟動后輸出SuperClass init!和123,說明沒有觸發SubClass這個類的初始化,對于靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。至于是否要觸發子類的加載和驗證,在虛擬機規范中并未明確規定,這點取決于虛擬機的具體實現。對于HotSpot虛擬機來說,可以通過-XX:TraceClassLoading或-verbose觀察到此操作會導致子類的加載。
  • NotInitialization2啟動后什么也沒輸出,說明沒有觸發SubClass的初始化,但是這段代碼里面會觸發了另外一個名為“[Ltystudy.javabasic.classloader.SubClass”的類的初始化階段,對于用戶代碼來說,這并不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承于Object的子類,創建動作由字節碼指令newarray觸發。
  • NotInitialization3啟動后輸出helloworld,說明沒有觸發SubClass這個類的初始化,因為引用的是SubClass的常量,但其實在編譯階段通過常量傳播優化,已經將此常量的值“helloworld”存儲到了NotInitialization3的常量池中,以后NotInitialization3對常量SubClass.HELLOWORLD引用實際上都被轉化為NotInitialization3類對自身常量池的引用了。也就是說,實際上NotInitialization3的Class文件之中并沒有SubClass類的符號引用了,這兩個類在編譯成Class之后就不存在任何聯系了。

類與接口的Class初始化有什么區別?

接口的加載過程與類加載過程稍有一些不同,針對接口需要做一些特殊說明:接口也有初始化過程,這點與類是一致的,上面的代碼都是用靜態語句塊來輸出初始化信息的,而接口中不能使用靜態塊,但編譯器仍然會為接口生成<clinit>類構造器,用于初始化接口中所定義的靜態常量。接口與類真正有所區別的是前面講述的5種“有且只有”需要開始初始化場景的第3種:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始化。

類加載過程中的加載階段做了什么事?

普通類:

  • 通過一個類的全限定名來獲取定義此類的二進制字節流。獲取的方式和位置可以任意,比如壓縮文件、網絡、動態生成、jsp、數據庫等等。
  • 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
  • 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

數組類:

對于數組類而言,情況有所不同,數組類本身不通過類加載器創建,它是有Java虛擬機直接創建的。但是數組類與類加載器仍然有很密切的關系,因為數組類的元素類型最終是要靠加載器去創建,一個數組類創建過程就遵循以下規則:

  • 如果元素類型是引用類型,那就遞歸采用普通類加載的方式去加載這個元素類型,數組將在加載該組件類型的類加載器的類名稱空間上被標識。
  • 如果數組的元素類型不是引用類型(如果int型數組),Java虛擬機將會把數組標記為與引導類加載器關聯。
  • 數組類的可見性與它的元素類型的可見性一致,如果元素類型不是引用類型,那數組類的可見性將默認為public。

加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬于連接階段的內容,這兩個階段的開始時間仍然保持著固定的先后順序。

類加載過程中的驗證階段做了什么事?

驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。

從整體上看,驗證大致上會完成下面4個階段的檢查動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

  1. 文件格式驗證

驗證字節碼是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理:

  • 是否以魔數0xCAFEBABE開頭
  • 主、次版本號是否在當前虛擬機處理范圍之內
  • 常量池的常量中是否有不被支持的常量類型(檢查常量tag標志)
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據。
  • Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
  • 。。。。

這階段的驗證是基于二進制字節流進行的,只有通過了這個階段的驗證后,字節流才會進入內存的方法去中進行存儲,所以后面的3個驗證階段全部是基于方法去的存儲結構進行的,不會再直接操作字節流。

  1. 元數據驗證

對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規范的要求:

  • 這個類是否有父類(除了java.lang.Object之外,所有類都應當有父類)
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一直,但返回類型卻不同等)。
  • 。。。。
  1. 字節碼驗證

主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗后,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件,如:

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這樣的情況:在操作數棧防止了一個int類型的數據,使用時卻按long類型來加載到本地變量表中。
  • 保證跳轉指令不會跳轉到方法體意外的字節碼指令上。
  • 保證方法體中的類型轉換是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關系、完全不相干的一個數據類型,則是危險和不合法的。
  • 。。。。
  1. 符號引用驗證

最后一個階段的校驗發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三個階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗,通常需要校驗下列內容:

  • 符號引用中通過字符串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
  • 符號引用中的類、字段、方法的訪問性(private、protected、public、default)是否可被當前類訪問。
  • 。。。。

類加載過程中的準備階段做了什么事?

準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在方法去中進行分配。首先,這里的內存分配僅包括類變量(static修飾的變量),其次,這里所說的初始值對于非final修飾的變量是數據類型的零值,如果是static final的則直接初始化為指定的常量。

// 準備階段被賦值為0,而把value賦值為123的putstatic指令是程序別編譯后,
// 存放于類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。
public static int value = 123;
// 準備階段被賦值為123
public static final int value = 123;

類加載過程中的解析階段做了什么事?

解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,符號引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現。

虛擬機規范之中并未規定解析階段發生的具體時間,只要求了在執行anewarray、checkcase、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic這16個用于操作符號引用的字節碼指令之前,先對他們所使用的符號引用進行解析。所以虛擬機實現可以根據需要判斷到底是再類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。

此處省略。。。 參考深入理解java虛擬機第220頁

什么是符號引用和直接引用?

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標并不一定已經加載到內存中。
  • 直接引用(Direct References):直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用適合虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。

類加載過程中的初始化階段做了什么事?

類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的助管計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。關于<clinit>()方法的運行行為的特點和細節:

  • <clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中語句合并產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值,但是不能訪問。

    public class MyTest3 {
        static {
            i = 1; // 可以訪問賦值,但不能訪問定義在靜態塊之后的變量
            System.out.println(i); // 這里編譯報錯"Illegal forward reference"非法向前引用
        }
        static int i =0;
    }
    
  • <clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。

  • 由于父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先于子類的變量賦值操作。

  • <clinit>()方法對于類或接口來說并不是必須的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。

  • 接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

  • 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,知道活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的。

    public class MultiThreadInitClass {
        static class ForeverSleepClass {
            static {
                try {
                    TimeUnit.DAYS.sleep(Integer.MAX_VALUE);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public static void main(String[] args) {
            Runnable task = () -> {
                System.out.println("start");
                new ForeverSleepClass();
                System.out.println("end");
            };
            new Thread(task, "test-thread-1").start();
            new Thread(task, "test-thread-2").start();
        }
    }
    

什么是類加載器?

虛擬機設計團隊把類加載階段中的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓程序自己決定如何去獲取所需要的類。實現這個動作的代碼模塊稱為“類加載器”。

類加載器的作用

類加載器雖然只用于實現類的加載動作,但它在Java程序中起到的作用卻遠遠不限于類加載階段。對于任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類命名空間。也就是說,比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class文件,被同一個虛擬機加載,只要加載他們的類加載器不同,那這兩個類就必定不相等。

這里所指的相等,包括類的Class對象的equals方法、isAssignableFrom方法、isInstance方法的返回結果,也包括instanceof關鍵字做對象所屬關系判定等情況。

public class ClassLoaderTest {

    public static void main(String[] args) throws Exception {

        MyClazz myClazz = new MyClazz();
        // sun.misc.Launcher$AppClassLoader@58644d46
        System.out.println(myClazz.getClass().getClassLoader());

        ClassLoader myClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream in = this.getClass().getResourceAsStream(fileName);
                    if(in == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[in.available()];
                    in.read(b);
                    return this.defineClass(b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException();
                }
            }
        };

        Object obj = myClassLoader.loadClass("tystudy.javabasic.classloader.MyClazz").newInstance();
        // class tystudy.javabasic.classloader.MyClazz
        System.out.println(obj.getClass());
        // tystudy.javabasic.classloader.ClassLoaderTest2$1@610455d6
        System.out.println(obj.getClass().getClassLoader());
        // false
        System.out.println(obj instanceof MyClazz);

    }
}

上面的代碼中,因為虛擬機中存在了兩個MyClazz類,一個由AppClassLoader加載,另一個由自定義的類加載器加載,雖然都來自同一個Class文件,但依然是兩個獨立的類,做對象所屬類型檢查時結果自然為false。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,622評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,716評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,746評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,991評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,706評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,036評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,029評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,203評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,725評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,451評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,677評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,161評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,857評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,266評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,606評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,407評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,643評論 2 380

推薦閱讀更多精彩內容