歡迎光臨
每天分享高質量文章

Java 虛擬機 9:Java 類加載機制

(點擊上方公眾號,可快速關註)


來源:五月的倉頡,

www.cnblogs.com/xrq730/p/4844915.html

前言

我們知道我們寫的程式經過編譯後成為了.class檔案,.class檔案中描述了類的各種信息,最終都需要加載到虛擬機之後才能運行和使用。而虛擬機如何加載這些.class檔案?.class檔案的信息進入到虛擬機後會發生什麼變化?這些都是本文要講的內容,文章將會講解加載類加載的每個階段Java虛擬機需要做什麼事(加粗標紅)。

類使用的7個階段

類從被加載到虛擬機記憶體中開始,到卸載出記憶體,它的整個生命周期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initiallization)、使用(Using)和卸載(Unloading)這7個階段。其中驗證、準備、解析3個部分統稱為連接(Linking),這七個階段的發生順序如下圖:

圖中,加載、驗證、準備、初始化、卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段不一定:它在某些情況下可以初始化階段之後在開始,這是為了支持Java語言的運行時系結(也稱為動態系結)。接下來講解加載、驗證、準備、解析、初始化五個步驟,這五個步驟組成了一個完整的類加載過程。使用沒什麼好說的,卸載屬於GC的工作,在之前GC的文章中已經有所提及了。

加載Loading

加載是類加載的第一個階段。有兩種時機會觸發類加載:

1、預加載。虛擬機啟動時加載,加載的是JAVA_HOME/lib/下的rt.jar下的.class檔案,這個jar包裡面的內容是程式運行時非常常常用到的,像java.lang.*、java.util.*、java.io.*等等,因此隨著虛擬機一起加載。要證明這一點很簡單,寫一個空的main函式,設置虛擬機引數為”-XX:+TraceClassLoading”來獲取類加載信息,運行一下:

[Opened E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.Object from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.io.Serializable from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.Comparable from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.CharSequence from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.String from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.reflect.GenericDeclaration from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.reflect.Type from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.reflect.AnnotatedElement from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.Class from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

[Loaded java.lang.Cloneable from E:\MyEclipse10\Common\binary\com.sun.java.jdk.win32.x86_64_1.6.0.013\jre\lib\rt.jar]

2、運行時加載。虛擬機在用到一個.class檔案的時候,會先去記憶體中查看一下這個.class檔案有沒有被加載,如果沒有就會按照類的全限定名來加載這個類。

那麼,加載階段做了什麼,其實加載階段做了有三件事情:

1、獲取.class檔案的二進制流

2、將類信息、靜態變數、位元組碼、常量這些.class檔案中的內容放入方法區中

3、在記憶體中生成一個代表這個.class檔案的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。一般這個Class是在堆里的,不過HotSpot虛擬機比較特殊,這個Class物件是放在方法區中的

虛擬機規範對這三點的要求並不具體,因此虛擬機實現與具體應用的靈活度都是相當大的。例如第一條,根本沒有指明二進制位元組流要從哪裡來、怎麼來,因此單單就這一條,就能變出許多花樣來:

  • 從zip包中獲取,這就是以後jar、ear、war格式的基礎

  • 從網絡中獲取,典型應用就是Applet

  • 運行時計算生成,典型應用就是動態代理技術

  • 由其他檔案生成,典型應用就是JSP,即由JSP生成對應的.class檔案

  • 從資料庫中讀取,這種場景比較少見

總而言之,在類加載整個過程中,這部分是對於開發者來說可控性最強的一個階段。

驗證

連接階段的第一步,這一階段的目的是為了確保.class檔案的位元組流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

Java語言本身是相對安全的語言(相對C/C++來說),但是前面說過,.class檔案未必要從Java原始碼編譯而來,可以使用任何途徑產生,甚至包括用十六進制編輯器直接編寫來產生.class檔案。在位元組碼語言層面上,Java代碼至少從語意上是可以表達出來的。虛擬機如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有害的位元組流而導致系統崩潰,所以驗證是虛擬機對自身保護的一項重要工作。

驗證階段將做一下幾個工作,具體就不細講了,這是虛擬機實現層面的問題:

1、檔案格式驗證

這個地方要說一點和開發者相關的。.class檔案的第5~第8個位元組表示的是該.class檔案的主次版本號,驗證的時候會對這4個位元組做一個驗證,高版本的JDK能向下兼容以前版本的.class檔案,但不能運行以後的class檔案,即使檔案格式未發生任何變化,虛擬機也必須拒絕執行超過其版本號的.class檔案。舉個具體的例子,如果一段.java代碼是在JDK1.6下編譯的,那麼JDK1.6、JDK1.7的環境能運行這個.java代碼生成的.class檔案,但是JDK1.5、JDK1.4乃更低的JDK版本是無法運行這個.java代碼生成的.class檔案的。如果運行,會丟擲java.lang.UnsupportedClassVersionError,這個小細節,務必註意。

2、元資料驗證

3、位元組碼驗證

4、符號取用驗證

準備

準備階段是正式為類變數分配記憶體並設置其初始值的階段,這些變數所使用的記憶體都將在方法區中分配。關於這點,有兩個地方註意一下:

1、這時候進行記憶體分配的僅僅是類變數(被static修飾的變數),而不是實體變數,實體變數將會在物件實體化的時候隨著物件一起分配在Java堆中

2、這個階段賦初始值的變數指的是那些不被final修飾的static變數,比如”public static int value = 123;”,value在準備階段過後是0而不是123,給value賦值為123的動作將在初始化階段才進行;比如”public static final int value = 123;”就不一樣了,在準備階段,虛擬機就會給value賦值為123。

各個資料型別的零值如下圖:

解析

解析階段是虛擬機將常量池內的符號取用替換為直接取用的過程。來瞭解一下符號取用和直接取用有什麼區別:

1、符號取用。

這個其實是屬於編譯原理方面的概念,符號取用包括了下麵三類常量:

  • 類和接口的全限定名

  • 欄位的名稱和描述符

  • 方法的名稱和描述符

這麼說可能不太好理解,結合實際看一下,寫一段很簡單的代碼:

package com.xrq.test6;

 

public class TestMain

{

    private static int i;

    private double d;

 

    public static void print()

    {

 

    }

 

    private boolean trueOrFalse()

    {

        return false;

    }

}

用javap把這段代碼的.class反編譯一下:

Constant pool:

   #1 = Class              #2             //  com/xrq/test6/TestMain

   #2 = Utf8               com/xrq/test6/TestMain

   #3 = Class              #4             //  java/lang/Object

   #4 = Utf8               java/lang/Object

   #5 = Utf8               i

   #6 = Utf8               I

   #7 = Utf8               d

   #8 = Utf8               D

   #9 = Utf8               

  #10 = Utf8               ()V

  #11 = Utf8               Code

  #12 = Methodref          #3.#13         //  java/lang/Object.”“:()V

  #13 = NameAndType        #9:#10         //  ““:()V

  #14 = Utf8               LineNumberTable

  #15 = Utf8               LocalVariableTable

  #16 = Utf8               this

  #17 = Utf8               Lcom/xrq/test6/TestMain;

  #18 = Utf8               print

  #19 = Utf8               trueOrFalse

  #20 = Utf8               ()Z

  #21 = Utf8               SourceFile

  #22 = Utf8               TestMain.java

看到Constant Pool也就是常量池中有22項內容,其中帶”Utf8″的就是符號取用。比如#2,它的值是”com/xrq/test6/TestMain”,表示的是這個類的全限定名;又比如#5為i,#6為I,它們是一對的,表示變數時Integer(int)型別的,名字叫做i;#6為D、#7為d也是一樣,表示一個Double(double)型別的變數,名字為d;#18、#19表示的都是方法的名字。

那其實總而言之,符號取用和我們上面講的是一樣的,是對於類、變數、方法的描述。符號取用和虛擬機的記憶體佈局是沒有關係的,取用的標的未必已經加載到記憶體中了。

2、直接取用

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

初始化

初始化階段是類加載過程的最後一步,初始化階段是真正執行類中定義的Java程式代碼(或者說是位元組碼)的過程。初始化過程是一個執行類建構式()方法的過程,根據程式員通過程式制定的主觀計划去初始化類變數和其它資源。把這句話說白一點,其實初始化階段做的事就是給static變數賦予用戶指定的值以及執行靜態代碼塊。

註意一下,虛擬機會保證類的初始化在多執行緒環境中被正確地加鎖、同步,即如果多個執行緒同時去初始化一個類,那麼只會有一個類去執行這個類的()方法,其他執行緒都要阻塞等待,直至活動執行緒執行()方法完畢。因此如果在一個類的()方法中有耗時很長的操作,就可能造成多個行程阻塞。不過其他執行緒雖然會阻塞,但是執行()方法的那條執行緒退出()方法後,其他執行緒不會再次進入()方法了,因為同一個類加載器下,一個類只會初始化一次。實際應用中這種阻塞往往是比較隱蔽的,要小心。

Java虛擬機規範嚴格規定了有且只有5種場景必須立即對類進行初始化,這4種場景也稱為對一個類進行主動取用(其實還有一種場景,不過暫時我還沒弄明白這種場景的意思,就先不寫了):

1、使用new關鍵字實體化物件、讀取或者設置一個類的靜態欄位(被final修飾的靜態欄位除外)、呼叫一個類的靜態方法的時候

2、使用java.lang.reflect包中的方法對類進行反射呼叫的時候

3、初始化一個類,發現其父類還沒有初始化過的時候

4、虛擬機啟動的時候,虛擬機會先初始化用戶指定的包含main()方法的那個類

除了上面4種場景外,所有取用類的方式都不會觸發類的初始化,稱為被動取用,接下來看下被動取用的幾個例子:

1、子類取用父類靜態欄位,不會導致子類初始化。至於子類是否被加載、驗證了,前者可以通過”-XX:+TraceClassLoading”來查看

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 class TestMain

{

    public static void main(String[] args)

    {

        System.out.println(SubClass.value);

    }

}

運行結果為:

SuperClass init

123

2、通過陣列定義取用類,不會觸發此類的初始化

public class SuperClass

{

    public static int value = 123;

 

    static

    {

        System.out.println(“SuperClass init”);

    }

}

 

public class TestMain

{

    public static void main(String[] args)

    {

        SuperClass[] scs = new SuperClass[10];

    }

}

運行結果為:

1

 

3、取用靜態常量時,常量在編譯階段會存入類的常量池中,本質上並沒有直接取用到定義常量的類

public class ConstClass

{

    public static final String HELLOWORLD =  “Hello World”;

 

    static

    {

        System.out.println(“ConstCLass init”);

    }

}

 

public class TestMain

{

    public static void main(String[] args)

    {

        System.out.println(ConstClass.HELLOWORLD);

    }

}

運行結果為:

Hello World

在編譯階段通過常量傳播優化,常量HELLOWORLD的值”Hello World”實際上已經儲存到了NotInitialization類的常量池中,以後NotInitialization對常量ConstClass.HELLOWORLD的取用實際上都被轉化為NotInitialization類對自身常量池的取用了。也就是說,實際上的NotInitialization的Class檔案中並沒有ConstClass類的符號取用入口,這兩個類在編譯成Class之後就不存在任何聯繫了。

看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

赞(0)

分享創造快樂