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

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)

分享創造快樂