1. 程式人生 > >JVM學習筆記(七)類載入機制-類載入的時機、過程

JVM學習筆記(七)類載入機制-類載入的時機、過程

前言

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

  與那些在編譯時需要進行連線工作的語言不同,在java語言裡,型別的載入,連線和初始化過程都是在程式執行期間完成的,這種策略雖然會令類載入時稍微增加一些效能開銷,但是會為java應用程式提供高度的靈活性,在java裡天生可以動態擴充套件的語言特性就是依賴執行期動態載入和動態連線這個特點實現的。

  例如,如果編寫一個面向介面的應用程式,可以等到執行時再指定其實際的實現類;使用者可以通過java預定義的和自定義的類載入器,讓一個本地的應用程式可以在執行時從網路或其他地方載入一個二進位制流作為程式程式碼的一部分,這種組裝應用程式的方式目前已廣泛應用於java程式之中。

一、類載入的時機

  類從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,它的整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段。其中驗證、準備、解析三個部分統稱為連線(Linking),這7個階段的發生順序如下圖所示:
這裡寫圖片描述

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

  java虛擬機器規範中對類載入過程中的第一個階段並沒有進行強制約束,這點可以交給虛擬機器的具體實現來自由把握。但是對於初始化階段,虛擬機器規範則是嚴格了有且只有5種情況必須立即對類進行“初始化“(而載入、驗證、準備自然需要在此之前開始):

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的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則需要先觸發其初始化。

  java虛擬機器規範中規定有且只有這5種會觸發類進行初始化的場景,這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。

下面是三個被動引用的例子:
被動引用例子一:通過子類引用父類的靜態欄位,不會導致子類初始化

package com.blog.test.jvm;
/**
 * 被動使用類欄位演示一:
 * 通過子類引用父類的靜態欄位,不會導致子類初始化
 */
public class SuperClass {

    static {
        System.out.println("SuperClass init!");
    }

    public static int value=123;

}

public class SubClass extends SuperClass{
    static {
        System.out.println("SubClass init!");
    }
}

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

執行結果:

SuperClass init!
123
Process finished with exit code 0

  從執行結果我們可知通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。對於Sun HotSpot 虛擬機器來說,可通過-XX:+TraceClassLoading引數觀察到此操作會導致子類的載入。

被動引用例子二:通過陣列定義來引用類,不會觸發此類的初始化

public class NotInitialization {
public static void main(String[] args) {
    SuperClass[] superClasses = new SuperClass[10];
}
}

  這段程式碼複用了例子一中的SuperClass,執行之後發現沒有輸出“SuperClass Init!“,說明並沒有觸發類com.blog.test.jvm.SuperClass的初始化階段。但是這段程式碼裡面觸發了另外一個名為“[Lcom.blog.test.jvm.SuperClass]“的類的初始化階段,對於使用者來說,這並不是一個合法的類名稱,它是一個由虛擬機器自動生成的、直接繼承於java.lang.Object的子類,建立動作由位元組碼指令newarray觸發。

  這個類代表了一個元素型別為com.blog.test.jvm.SuperClass的唯一陣列,陣列中應有的屬性和方法(使用者可直接使用的只有被修飾為public的lenth屬性和clone()方法)都實現在這個類裡。java 語言中對陣列的訪問比C/C++相對安全是因為這個類封裝了陣列元素的訪問方法,而C/C++直接翻譯為對陣列指標的移動。在java語言中,當檢查到發生陣列越界時會丟擲java.lang.ArrayIndexOutOfBoundsException異常。

被動引用例子三:常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化

package com.blog.test.jvm;

public class ConstClass {

    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLOWWORLD="hello world";  
}

public class NotInitialization {
    //常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWWORLD);
    }
}

執行結果:

hello world

Process finished with exit code 0

  從執行結果沒輸出“ConstClass init!“,這是因為雖然在java 原始碼中引用了ConstClass淚中的常量HELLOWORLD,但其實在編譯階段通過常量傳播優化,已經將此常量的值“hello world“儲存到了NotInitialization類的常量池中,以後NotInitialization對常量ConstClass.HELLOWWORLD的引用時機都被轉化為NotInitialization類對自身常量池的引用了。也就是說,實際上NotInitialization的Class檔案之中並沒有ConstClass類的符號引用入口,這兩個類在編譯成class之後就不存在任何聯絡了。

介面的載入過程和類載入過程稍有一些不同,針對介面需要做一些特殊說明:介面也有初始化過程,這點與類是一致的,上面的程式碼都是用靜態程式碼塊“static{}“來輸出初始化資訊的,而介面中不能使用“static{}“語句塊,但編譯器仍然會為介面生成“clinit()“類構造器,用於初始化介面中所定義的成員變數。介面與類真正有所區別的是:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候(如引用介面中定義的常量)才會初始化。

注:類構造器< clinit >和方法構造器< init >的生成過程和作用?

二 類載入的過程

java虛擬機器中類載入的全過程:載入、驗證、準備、解析和初始化這5個階段,注意區別於類的生命週期。

1、載入

在載入階段,虛擬機器需要完成以下3件事情:

1)通過一個類的全限定名來獲取定義此類的二進位制位元組流。
2)將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3)在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

通過一個類的全限定名來獲取定義此類的二進位制位元組流這條,它沒有指明二進位制位元組流要從一個Class檔案中獲取,準確地說是根本沒有明確要從哪裡獲取、怎樣獲取。許多舉足輕重的java技術都建立在這一基礎上,例如:

1)從ZIP包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎。
2)從網路中獲取,這種場景最典型的應用就是Applet。
3)執行時計算生成,這種場景使用的最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generatePorxyClass來為特定介面生成形式為”$Proxy”的代理類的二進位制位元組流。
4)由其他檔案生成,典型場景是JSP應用,即由JSP檔案生成對應的Class類。
5)從資料庫中讀取,這種場景相對少見些,例如有些中介軟體伺服器(如SAP Netweaver)可以選擇把程式安裝到資料庫中來完成程式程式碼在叢集間的分發。
……

  非陣列類的載入階段(準確地說,是載入階段中獲取類的二進位制位元組流的動作)是開發人員可控性最強的,因為載入階段既可以使用系統提供的引導類載入器來完成,也可以由使用者自定義的類載入器去完成,開發人員可以通過定義自己的類載入器去控制位元組流的獲取方式(即重寫一個類載入器的loadClass()方法)。

  陣列類本身不通過類載入器建立,它是由java虛擬機器直接建立的。但陣列類與類載入器仍然有密切的關係,因為陣列類的元素型別(Element Type,指的是陣列去掉所有維度的型別)最終是要靠類載入器去建立,一個數組類(下面簡稱C)建立過程遵循以下規則:

1)如果陣列的元件型別(Component Type,指的是陣列去掉一個維度的型別)是引用型別,那就遞迴採用本節定義的載入過程去載入這個組建型別,陣列C將在載入該組建型別的類載入器的類名稱空間上被標識(一個類必須與類載入器一起確定唯一性)。
2)如果陣列的元件型別不是引用型別(例如int[]陣列),java虛擬機器將會把陣列C標記為與引導類載入器關聯。
3)陣列類的可見性與它的組建型別的可見性一致,如果元件型別不是引用型別,那陣列類的可見性將預設為public。

  載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區中的資料儲存格式由虛擬機器實現自行定義,虛擬機器規範未規定此區域的具體資料結構。然後在記憶體中例項化一個java.lang.Class類的物件(並沒有明確規定是在java堆中,對於HotSpot虛擬機器而言,Class物件比較特殊,它雖然是物件,但是存放在方法區裡面),這個物件將作為程式訪問方法區中的這些型別資料的外部介面。

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

2、驗證

  驗證是連線階段(連線階段包括驗證、準備、解析)的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。如果驗證失敗,會丟擲java.lang.VerifyError異常。

  驗證階段大致上會完成下面4個階段的檢驗動作:檔案格式驗證、元資料驗證、位元組碼驗證、符號引用驗證。

1)檔案格式驗證:驗證Class檔案魔數、主次版本、常量池、類檔案本身等等。

2)元資料驗證:主要是對位元組碼描述的資訊進行語義分析,包括是否有父類、是否是抽象類、是否是介面、是否繼承了不允許被繼承的類(final類)、是否實現了父類或者介面的方法等等。

3)位元組碼驗證:是整個驗證過程中最複雜的,主要進行資料流和控制流分析,如保證跳轉指令不會跳轉到方法體之外的位元組碼指令、資料型別轉換安全有效等。

4)符號引用驗證:發生在虛擬機器將符號引用轉化為直接引用的時候(連線第三階段-解析階段進行符號引用轉換為直接引用),符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,則會丟擲java.lang.IncompatibleClassChangeError異常的子類異常,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

  對於虛擬機器的類載入機制來說,驗證階段是一個非常重要的、但不是一定必要(因為對程式執行期沒有影響)的階段。如果所執行的全部程式碼(包括自己編寫的及第三方包中的程式碼)都已經被反覆使用和驗證過,那麼在實施階段就可以考慮使用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。

3、準備

  準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下,首先,這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在java堆中。其次,這裡所說的初始值“通常情況下“是資料型別的零值,假設一個類變數的定義為:

public static int value=123;

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

  上面提到,在“通常情況“下初始值是零值,那相對的會有一些“特殊情況“:如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數value就會被初始化為ConstantValue屬性所指定的值,假設上面類變數value的定義變為:

public static final int value=123;

編譯時javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定講value賦值為123。

4、解析

解析階段時虛擬機器將常量池內的符號引用替換為直接引用的過程。

符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在java虛擬機器規範的Class檔案格式中。

直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那直接引用的目標必定已經在記憶體中存在。

解析的動作主要針對類或介面、欄位、類方法、介面方法四類符號引用進行解析。

5、初始化

  在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式設計師制定的主觀計劃去初始化類變數和其他資源,或者從另一個角度來表達:初始化階段是執行類構造器< clinit >()方法的過程。

  < clinit >方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊能賦值,但是不能訪問,如下所示:

public class test{
    static{
        i=0;//給變數賦值可以正常編譯通過
        System.out.print(i);//這句話編譯器會提示“非法向前引用“
    }
    static int i=0;
}

  < clinit >()方法與類的建構函式(或者說例項構造器< init >) 不同,它不需要顯式地呼叫父類構造器,虛擬機器會保證子類的< clinit >()方法執行之前,父類的< clinit >()方法已經執行完畢。因此在虛擬機器中第一個被執行的< clinit >()方法的類肯定是java.lang.Object。

  由於父類的< clinit >()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類地變數賦值操作,如下程式碼所示,欄位B的值將會是2而不是1。

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.printIn(Sub.B)
}

  < clinit >()方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成< clinit >()方法。

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

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

初始化總結如下:

1).類構造器< clinit >方法是由編譯器自動收集類中所有類變數(靜態非final變數)賦值動作和靜態初始化塊(static{……})中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序決定。靜態初始化塊中只能訪問到定義在它之前的類變數,定義在它之後的類變數,在前面的靜態初始化中可以賦值,但是不能訪問。

3).由於父類構造器< clinit >方法先於子類構造器執行,因此父類中定義的靜態初始化塊要先於子類的類變數賦值操作。

4). 類構造器< clinit >方法對於類和介面並不是必須的,如果一個類中沒有靜態初始化塊,也沒有類變數賦值操作,則編譯器可以不為該類生成類構造器< clinit >方法。

5).介面中不能使用靜態初始化塊,但可以有類變數賦值操作,因此介面與類一樣都可以生成類構造器< clinit >方法。
介面與類不同的是:
首先,執行介面的類構造器< clinit >方法時不需要先執行父介面的類構造器< clinit >方法,只有當父介面中定義的靜態變數被使用時,父接口才會被初始化。
其次,介面的實現類在初始化時同樣不會執行介面的類構造器< clinit >方法。

6).java虛擬機器會保證一個類的< clinit >方法在多執行緒環境中被正確地加鎖和同步,如果多個執行緒同時去初始化一個類,只會有一個執行緒去執行這個類的< clinit >方法,其他執行緒都需要阻塞等待,直到活動執行緒執行< clinit >方法完畢。
初始化階段,當執行完類構造器< clinit >方法之後,才會執行例項構造器的< init >方法,例項構造方法同樣是按照先父類,後子類,先成員變數,後例項構造方法的順序執行。

注:需要注意的是,其他執行緒雖然會被阻塞,但如果執行< clinit >()方法的那條執行緒退出< clinit >()方法後,其他執行緒喚醒之後不會再次進入< clinit >()方法。同一個類載入器下,一個型別只會初始化一次。