天天看點

深入了解JVM - 虛拟機類加載機制1、類加載的時機2、類的加載過程

在Class檔案中描述的各種資訊,最終都需要加載到虛拟機中之後才能運作和使用。

虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型,這就是虛拟機的類加載機制。與那些在編譯時需要進行連接配接工作的語言不同,在Java語言裡面,類型的加載、連接配接和初始化都是在運作期間完成的,運作期間的動态加載和動态連接配接使Java天生具備可以動态擴充的語言特性。

1、類加載的時機

類從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期包括:加載、驗證、準備、解析、初始化、使用、解除安裝。其中驗證、準備、解析三個部分統稱為連接配接。

加載、驗證、準備、初始化和解除安裝這五個階段的順序是确定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化之後再開始,這是為了支援Java語言的運作時綁定(也成為動态綁定或者晚期綁定)。

什麼情況下需要開始類加載過程的第一個階段:加載?虛拟機規範中用了一個很強的限定語:有且隻有。即有且隻有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然要在此之前開始):

1)遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。

      生成這四條指令的最常見的Java代碼場景是:使用new關鍵字執行個體化對象的時候、讀取或設定一個類的靜态字段的時候、以及調用一個類的靜态方法的時候。

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

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

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

5)當使用JDK1.7的動态語言支援時,如果一個java.lang.invoke.MethodHandle執行個體最後的解析結果是REF_getStatic、REF_putstatic、REF_invokestatic的方法句柄,并且這個方法句柄對應的類進行過沒有初始化,則需要先觸發其初始化。

這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方法都不會觸發其初始化,稱為被動引用。

/**
 *被動使用類字段示範一:
 *通過子類引用父類的靜态字段,不會導緻子類初始化
 */
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 NoInitialization{
	public static void main(String[] args){
		System.out.println(SubClass.value);
	}
}
           

上述代碼運作之後,隻會輸出“SuperClass Init!”。對于靜态字段,隻有直接定義這個字段的類才會被初始化,是以通過其子類來引用父類中定義的靜态字段,隻會觸發父類的初始化而不會觸發子類的初始化。

注意:至于是否要觸發子類的加載和驗證,在虛拟機規範中未明确規定,這點取決于虛拟機的具體實作。對于Sun HotSpot虛拟機來說,此操作會導緻子類的加載。

/**
 * 被動使用類字段示範二:
 * 通過數組定義引用類,不會觸發此類的初始化
 */
public class NoInitialization{
	public static void main(String[] args){
		SuperClass[] sca = new SuperClass[10];
	}
}
           

運作之後,這段代碼沒有輸出“SuperClass init!”,這說明沒有觸發SuperClass的初始化階段。但是這段代碼觸發了另外一個名為L包名.SuperClass的類的初始化階段,這是由虛拟機自動生成的、直接繼承與java.lang.Object的子類,建立動作由位元組碼指令newArray觸發。這個類代表了一個一維數組,數組中應有的屬性和方法都實作在這個類裡。Java語言中對數組的通路要比c/c++相對安全是因為這個類封裝了數組元素的通路方法。

/**
 * 被動使用類字段示範三:
 * 常量在編譯階段會存入調用類的常量池,本質上并沒有直接引用到定義常量的類,是以不會觸發定義常量的類的初始化
 */
public class ConstClass {
	static {
		System.out.println("ConstClass  init!");
	}
	public static final String HELLOWORLD = "hello world";
}

public class NoInitialization {
	public static void main(String[] args) {
		System.out.println(ConstClass.HELLOWORLD);
	}
}
           

上述并沒有輸出“ConstClass  init!”,這是因為在編譯階段通過常量傳播優化,已經将此常量的值“hello world”存儲到了NoInitialization類的常量池中,以後NoInitialization對常量ConstClass.HELLOWORLD的引用實際都被轉化為NoInitialization類對自身常量池的引用。也就是說,這兩個類在被編譯成Class之後,就不存在任何聯系了。

接口的加載與類的加載基本相同,真正的差別是前面講述的五種“有且隻有”需要初始化場景中的第三種:當一個類初始化時,要求其父類全部都已經初始化過,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,隻有真正使用到父接口的時候(如引用接口中定義的常量)才會初始化。

2、類的加載過程

按照“加載、驗證、準備、解析、初始化”五個階段講解。

2.1、加載

“加載”是“類加載”過程的一個階段,虛拟機需要完成以下的三件事情:

1)通過一個類的全限定名來擷取定義此類的二進制位元組流;

2)将這個位元組流所代表的靜态存儲結構轉換為方法區的運作時資料結構;

3)在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口;

虛拟機規範的這三點要求其實并不算具體,是以虛拟機實作與具體應用的靈活度都是相當大的。例如第一條就根本沒有指明要從哪裡擷取、怎樣擷取。虛拟機設計團隊在加載階段搭建了一個相當開放、廣闊的“舞台”,Java發展曆程中充滿活力的開發人員則在這個舞台上玩出了新花樣。許多舉足輕重的Java技術都是建立在這一基礎之上,例如:

從Zip包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎;

從網絡中擷取,這種場景最典型的應用就是Applet;

運作時計算生成,這種場景使用得最多的就是動态代理技術,在java.lang.reflect.proxy中,就是用了ProxyGenerator.generateProxyClass來為特定接口生成形式為“*$Proxy”的代理類的二進制位元組流;

由其他檔案生成,典型場景是JSP應用,即由JSP檔案生成對應的Class檔案;

.......

相對于類加載過程的其他階段,一個非數組類的加載階段(準确的說是加載階段中擷取類的二進制位元組流的動作)是開發人員可控性最強的,因為加載階段既可以使用系統提供的引導類加載器來完成,也可以由使用者自定義的類加載器去控制位元組流的擷取方式(即重寫類加載器的loadClass()方法)。

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

1)如果數組的元件類型(指的是數組去掉一個次元的類型)是引用類型,那就遞歸采用本節中定義的加載過程去加載這個元件類型,數組C将在加載該元件類型的類加載器的類名稱空間上被辨別(一個類必須與類加載器一起确定唯一性);

2)如果數組的元件類型不是引用類型(例如int[]數組),Java虛拟機将會把數組C标記為與引導類加載器關聯;

3)數組類的可見性與它的元件類型的可見性一緻,如果元件類型不是引用類型,那數組類的可見性将預設為public;

加載階段完成後,虛拟機外部的二進制位元組流将按照虛拟機所需的格式存儲在方法區之中,方法區中的資料存儲格式由虛拟機實作自行定義,虛拟機規範未規定此區域的具體資料結構。然後在記憶體中執行個體化一個java.lang.Class類的對象(并沒有明确規定是在Java堆中,對于HotSpot虛拟機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區裡面),這個對象将作為程式通路方法區中的這些類型資料的外部接口。

2.2、驗證

驗證是連接配接階段的第一步,這一階段的目的是為了確定Class檔案的位元組流中包含的資訊符合目前虛拟機的要求,并且不會危害虛拟機自身的安全。前面說過,Class檔案并不一定是要求用Java源碼編譯而來,可以使用任何途徑産生,甚至可以使用十六機制編輯器直接編寫來産生Class檔案。在位元組碼語言層面上,上述Java代碼無法做到的事情都可以實作的,至少語義上是可以表達出來的。虛拟機如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有害的位元組流而導緻系統崩潰,是以驗證是虛拟機對自身保護的一項重要工作。

如果驗證到輸入的位元組流不符合Class檔案格式的限制,虛拟機将抛出一個java.lang.VertifyError異常或其子類異常。從整體上看,驗證階段大緻會分為下面四個階段的校驗動作:檔案格式驗證、中繼資料驗證、位元組碼驗證、符号引用驗證。

注意:隻有通過第一個階段的驗證後,位元組流才會進入記憶體的方法區中進行存儲,是以後面的三個驗證階段全部基于方法區的存儲結構進行的,不會直接操作位元組流。

符号引用驗證的主要目的是確定解析動作能正常的執行,如果無法通過符号引用驗證,那麼将抛出哪一個java.lang.InCompatibleClassChangeError異常的子類。

2.3、準備

準備階段是正式為類變量(被static修飾的變量)配置設定記憶體并設定類變量初始值的階段,這些變量所使用的記憶體都将在方法區中進行配置設定,這個階段中容易産生混淆的概念需要強調一下:首先,這時候進行記憶體配置設定的僅包括類變量,而不是執行個體變量,執行個體變量将會在對象執行個體化之後随着對象一起配置設定在Java堆中,其次,這裡所說的初始值“通常情況”下是資料類型的零值。假設類變量的定義為:

public static int value =  123;

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

特殊情況:如果類字段的字段屬性表中存在ConstantValue屬性,那麼在準備階段變量value就會被初始化為指定的值,例如:

public static final int value = 123;

編譯時javac将會為value生成ConstantValue屬性,并在準備階段虛拟機就會根據ConstantValue的設定将value指派為123.

2.4、解析

解析階段是虛拟機将常量池内的符号引用替換為直接引用的過程,那麼在解析階段中所說的直接引用與符号引用又有什麼關聯呢?

a)符号引用:符号引用以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義的定位到目标即可。符号引用與虛拟機實作的記憶體布局無關,引用的目标并不一定已經加載到記憶體中。各種虛拟機實作的記憶體布局可以各不相同,但是它們能接受的符号引用必須都是一緻的,因為符号引用的字面量形式明确定義在Java虛拟機規範的Class檔案格式中。

b)直接引用:直接引用可以是直接指向目标的指針、相對偏移量或者是一個能間接定位到目标的句柄。直接引用和虛拟機實作的記憶體布局相關的,同一個符号引用在不同虛拟機執行個體上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目标必定已經在記憶體中存在。

虛拟機可以根據需要來判斷到底是在類被加載器加載時就對常量池中的符号引用進行解析,還是等到一個符号引用将要被使用前才去解析它。

2.5、初始化

初始化階段是類加載過程的最後一步,前面的類加載過程中,除了在加載階段使用者應用程式可以通過自定義的類加載器參與之外,其餘動作完全由虛拟機主導和控制。到了初始化階段,才真正開始執行類中定義的Java代碼。

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

a)<clinit>方法是由編譯器自動收集類中的所有類變量的指派動作和靜态語句塊(static{}塊)中的語句合并産生的。編譯器收集的順序是由語句在源檔案中出現的順序決定的,靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句塊中可以指派,但是不能通路。

public class Test {
    static {
        i = 0;
        System.out.println(i);//Ilegal forward reference非法向前引用
    }
    static int i = 0;
}
           

b)<clinit>()方法與類的構造函數(或者說執行個體構造器<init>()方法)不同,它不需要顯示地調用父類構造器,虛拟機會保證在子類的<clinit>方法執行之前,父類的<clinit>方法已經執行完畢。是以,在虛拟機中第一個被執行<clinit>()方法的類肯定是java.lang.object。

c)由于父類的<clinit>()方法先執行,也就意味着父類中定義的靜态語句塊要優先于子類的變量指派操作。

d)<clinit>()方法對于類或接口來說并不是必需的,如果一個類中沒有靜态語句塊,也就沒有對變量指派操作,那麼編譯器可以不為這個類生成<clinit>()方法。

e)接口中不能使用靜态語句塊,但仍然有變量初始化的指派操作,是以接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要執行父接口的<clinit>()方法。隻有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實作類在初始化時也一樣不會執行接口的<clinit>()方法。

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

2.6、類加載器

虛拟機設計團隊把類加載階段中的“通過一個類的全限定名來擷取描述此類的二進制位元組流”這個動作放到了Java虛拟機外部去實作。類加載器在類層次劃分、OSGI、熱部署、代碼加密等領域大放異彩,成為Java技術體系中一塊重要的基石。

2.6.1、類與類加載器

類加載器雖然隻用于實作類的加載動作,但它在Java程式中起到的作用卻遠遠不限于類加載階段。對于任意一個類,都需要由加載它的類加載器和這個類本身一同确立其在Java虛拟機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間,即:比較兩個類是否“相等”,隻有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class檔案,被同一個虛拟機加載,隻要加載它們的類加載器不同,那這兩個類就必定不相等。這裡的相等,包括代表類的Class對象的equals方法、isAssignableFrom方法、isInstance方法的傳回結果,也包括使用instanceOf關鍵字做對象所屬關系判定等情況。

public class Test {
    public static void main(String[] args) {
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf("." + 1)) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object obj = myLoader.loadClass("org.fenixsoft.classloading.Test").newInstance();
        System.out.print(obj.getClass());
        System.out.println(obj instanceof org.fenixsoft.classloading.Test);
       
    }
}
           

結果:

//org.fenixsoft.classloading.Test
 //false
           

為什麼是false呢?這是因為虛拟機中存在了兩個ClassLoader,一個是由系統應用程式類加載器加載的,一個是我們自定義的類加載器,雖然都是來自同一個Class檔案,但依然是兩個獨立的類,做對象所屬類型檢查時結果自然是false。

2.6.2、雙親委派模型

從Java虛拟機的角度,隻存在兩種不同的類加載器:一種是啟動類加載器,這個類加載器使用C++語言實作,是虛拟機自身的一部分;另一種就是所有其他的類加載器,這些類加載器是由Java語言實作,獨立于虛拟機外部,并且全部繼承自抽象類java.lang.ClassLoader。 從Java開發人員的角度來看,類加載器還可以劃分為更細緻些,絕大部分Java程式都會使用以下3種系統提供的類加載器。 a)啟動類加載器。這個類加載器負責将存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,并且能被虛拟機識别的(僅按照檔案名識别,例如rt.jar,名字不符合的類庫即使被放到lib目錄下也不會被加載)類庫加載到虛拟機記憶體中。 啟動類加載器無法被Java程式直接引用,使用者在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用null代替即可。 b)擴充類加載器。ExtClassLoader實作,負責加載<JAVA_HOME>\lib\ext目錄中,或者被java.ext.dirs系統變量指定的路徑中的所有類庫。開發者可以直接使用擴充類加載器。 c)應用程式類加載器。AppClassLoader實作,這個類加載器是ClassLoader中的getSystemClassLoader()方法的傳回值,是以一般也成為系統類加載器。負責加載使用者路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程式中沒有自定義過自己的類加載器,一般情況下這個就是程式中預設的類加載器。

我們的應用程式都是這三種類加載器互相配合進行加載的,如果有必要,還可以加入自己定義的類加載器。

深入了解JVM - 虛拟機類加載機制1、類加載的時機2、類的加載過程

上圖展示的是類加載器之間的這種層次關系。雙親委派模型要求除了頂層的啟動類加載器外,其餘的類加載器都應有自己的父類加載器。這裡的父子關系不是以繼承的關系來實作的,而是使用組合關系來複用父加載器的代碼。

工作過程:如果一個類加載器收到類加載的請求,它首先不會自己去嘗試加載這個類,而是把請求委派給父類加載器去完成,每個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求的時候(它的搜尋範圍中沒有找到所需的類),子加載器才會嘗試自己去加載。

雙親委派模型的好處就是Java類随着它的類加載器一起具備了一種帶有優先級的層次關系。例如java.lang.Object,它存放在rt.jar中,無論哪一個類加載器要加載這個類,最終都會委派給處于模型最頂端的啟動類加載器進行加載,是以Object類在程式的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委托模型,由各個類加載器自行去加載的話,如果使用者自己編寫了一個稱為java.lang.Object的類,并存放在了使用者的ClassPath中,那系統中将出現多個不同的Object,這樣會一片混亂。你可以嘗試去編寫一個與rt.jar類庫中已有類重名的Java類,将發現可以正常編譯,但永遠無法被加載運作[即使自定義了自己的類加載器,強行用defineClass去加載一個以“java.lang”開頭的類,也不會成功。如果嘗試這樣做的話,将會收到一個由虛拟機自己抛出的"java.lang.SecurityException:prohibited package name:java.lang"]。

繼續閱讀