Java 類加載機制

前言

我們知道我們寫的程序經過編譯後成爲了.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               <init>

  #10 = Utf8               ()V

  #11 = Utf8               Code

  #12 = Methodref          #3.#13         //  java/lang/Object."<init>":()V

  #13 = NameAndType        #9:#10         //  "<init>":()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程序代碼(或者說是字節碼)的過程。初始化過程是一個執行類構造器<clinit>()方法的過程,根據程序員通過程序制定的主觀計劃去初始化類變量和其它資源。把這句話說白一點,其實初始化階段做的事就是給static變量賦予用戶指定的值以及執行靜態代碼塊。


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


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之後就不存在任何聯繫了。

轉載自:https://mp.weixin.qq.com/s/u5F2yBuyAvnUitA292aUYg

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章