【JVM系列2】Java虛擬機類加載機制及雙親委派模式分析 前言 編譯 Class文件 類加載機制 總結

前言

上一篇我們粗略的介紹了一下Java虛擬機的運行時數據區,並對運行時數據區內的劃分進行了解釋,今天我們就會從類加載開始分析並會深入去看看數據是具體以什麼格式存儲到運行時數據區的。

編譯

一個.java文件經過編譯之後,變成了了.class文件,主要經過留下步驟:
.java -> 詞法分析器 -> tokens流 -> 語法分析器 -> 語法樹/抽象語法樹 -> 語義分析器 -> 註解抽象語法樹 -> 字節碼生成器 -> .class文件 。
具體的過程不做分析,涉及到編譯原理比較複雜,我們需要分析的是.class文件到底是一個什麼樣的文件?

Class文件

在Java中,每個類文件包含單個類或接口,每個類文件由一個8位字節流組成。所有16位、32位和64位的量都是通過分別讀取2個、4個和8個連續的8位字節來構建的。

Java虛擬機規範中規定,Class文件格式使用一種類似於C語言的僞結構來存儲數據,class文件中只有兩種數據類型,無符號數。注意,class文件中沒有任何對齊和填充的說法,所有數據都按照特定的順序緊湊的排列在Class文件中

  • 無符號數
    屬於數據的基本類型,以u1,u2,u4,u8來表示1個字節,2個兒字節,4個字節,8個字節(在Java SE平臺中,這些類型可以通過readUnsignedByte、readUnsignedShort和接口java.io.DataInput中的的readInt方法進行讀取)。

  • 由0個或多個大小可變的項組成,用於多個類文件結構中,也就是說一個類其實就相當於是一個表。

Class文件結構

一個Class文件大致由如下結構組成:

ClassFile {
    u4             magic;//魔數
    u2             minor_version;//次版本號
    u2             major_version;//主版本號
    u2             constant_pool_count;//常量池數量
    cp_info        constant_pool[constant_pool_count-1];//常量池信息
    u2             access_flags;//訪問標誌
    u2             this_class;//類索引
    u2             super_class;//父類索引
    u2             interfaces_count;//接口數(2位,所以一個類最多65535個接口)
    u2             interfaces[interfaces_count];//接口索引 
    u2             fields_count;//字段數
    field_info     fields[fields_count];//字段表集合 
    u2             methods_count;//方法數
    method_info    methods[methods_count];//方法集合
    u2             attributes_count;//屬性數
    attribute_info attributes[attributes_count];//屬性表集合
}

這個結構在本篇文章裏不會一一去解釋,如果一一去解釋的話一來顯得很枯燥,二來可能會佔據大量篇幅,這些東西腦子裏面有個整體的概念,需要的時候再查下資料就好了,後面的內容中,如果遇到一些非常常用的類結構含義會進行說明,如魔數等還是有必要了解一下的。

Class文件示例

我們先任意寫一個示例TestClassFormat.java文件:

package com.zwx.jvm;

public class TestClassFormat {

    public static void main(String[] args) {
        System.out.println("Hello JVM");
    }
}

然後進行編譯,得到TestClassFormat.class,利用16進制打開:

因爲Java虛擬機只認Class文件,所以必然會對Class文件的格式有嚴格的安全性校驗。

魔數

每個Class文件中都會以一個4字節的魔數(magic)開頭(u4),即上圖中的CA FE BA BE(咖啡寶貝)用來標記一個文件是不是一個Class文件。

主次版本號

魔數之後的2個字節(u2)就是minor_version(次版本號),再往後2個字節(u2)記錄的是major_version(次版本號),這個還是非常有必要了解的,下面這個異常我想可能很多人都曾經遇到過:

java.lang.UnsupportedClassVersionError: com/zwx/demo : Unsupported major.minor version 52.0
 at java.lang.ClassLoader.defineClass1(Native Method)
 at java.lang.ClassLoader.defineClassCond(ClassLoader.java:631)

這個異常就是提示主版本號不對。
Java中的版本號是從45開始的,也就是JDK1.0對應到Class文件的主版本號就是45,而JDK8對應到的主版本就是52。
上圖中類文件的主版本號(第7和第8位)00 34 ,轉成10進制就是52,也就是這個類就用JDK1.8來編譯的,然後因爲我用的是JDK1.6來運行,就會報上面的錯了,因爲高版本的JDK能向下兼容低版本的Class文件,但是不能向上兼容更高版本的Class文件,所以就會出現上面的異常。

其他

其他還有很多校驗,比如說常量池的一些信息和計數,訪問權限(public等)及其他一些規定,都是按照Class文件規定好的順序往後緊湊的排在一起。

類加載機制

.java文件經過編譯之後,就需要將class文件加載到內存了了,並將數據按照分類存儲在運行時數據區的不同區域。

一個類從被加載到內存,再到使用完畢之後卸載,總共會經過5大步驟(7個階段):
加載(Loading),連接(Linking),初始化(Initialization),使用(Using),卸載(Unloading) ,其中連接(Linking)又分爲:驗證(Verification),準備(Preparation),解析(Resolution)。

加載(Loading)

加載指的是通過一個完整的類或接口名稱來獲得其二進制流的形式並將其按照Java虛擬機規範將數據存儲到運行時數據區。

類的加載主要是要做以下三件事:

  • 1、通過一個類的全限定名獲取定義此類的二進制字節流。
  • 2、將這個二進制字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  • 3、在Java堆中生成一個代表這個類的java.lang.Class對象,作爲對方法區中這些數據的訪問入口。

上面的第1步在虛擬機規範中並沒有說明Class來源於哪裏,也沒有說明怎麼獲取,所以就會產生了非常多的不同實現方式,下面就是一些常用的實現方式:

  • 1、最正常的方式,讀取本地經過編譯後的.class文件。
  • 2、從壓縮包,如:zip,jar,war等文件中讀取。
  • 3、從網絡中獲取。
  • 4、通過動態代理動態生成.class文件。
  • 5、從數據庫中讀取。

執行Class(類或者接口)的加載操作需要一個類加載器,而一個良好的,合格的類加載器需要具有以下兩個屬性:

  • 1、對於同一個Class名稱,任何時候都應該返回相同的類對象
  • 2、如果類加載器L1委派另一個類加載器L2來加載一個Class對象C,那麼以下場景出現的任何類型T,兩個類加載器L1和L2應返回相同的Class對象:
    (1) C的直接父類或者父接口類型;
    (2) C中的字段類型;
    (3) C中方法或構造函數的中的參數類型;
    (4) C中方法的返回類型

在Java中的類加載器不止一種,而對於同一個類,用不同的類加載器加載出來的對象是不相等的,那麼Java是如何保證上面的兩點的呢?
這就是雙親委派模式,Java中通過雙親委派模式來防止惡意加載,雙親委派模式也確保了Java的安全性。

雙親委派模式

雙親委派模式的工作流程很簡單,當一個類加載器收到加載請求時,自己不去加載,而是交給它的父加載器去加載,以此類推,直到傳遞到最頂層類加載器,而只有當父加載器反饋說自己無法加載這個類,子加載器纔會嘗試去加載這個類。

上圖中就是雙親委派模型,細心的人可能注意到,頂層加載器我使用了虛線來表示,因爲頂層加載器是一個特殊的存在,沒有父加載器,而且從實現上來說,也沒有子加載器,是一個獨立的加載器,因爲擴展類加載器(Extension ClassLoader)和應用程序類加載器(Application ClassLoader)兩個加載器從繼承關係來看,是有父子關係的,均繼承了URLClassLoader。但是雖然從類的繼承關係來說啓動類加載器(Bootstrap ClassLoader)沒有子加載器,但是邏輯上擴展類加載器(Extension ClassLoader)還是會將收到的請求優先交給啓動類加載器(Bootstrap ClassLoader)來進行優先加載。

  • 啓動類加載器(Bootstrap ClassLoader),負責加載$JAVA_HOME\lib下的類或者被參數-Xbootclasspath指定的能被虛擬機識別的類(通過jar名字識別,如:rt.jar),啓動類加載器由Java虛擬機直接控制,開發者不能直接使用啓動類加載器。
  • 擴展類加載器(Extension ClassLoader),負責加載$JAVA_HOME\lib\ext下的類或者被java.ext.dirs系統變量指定的路徑中所有類庫(System.getProperty(“java.ext.dirs”)),開發者可以直接使用這個類加載器。
  • 應用程序類加載器(Application ClassLoader),負責加載$CLASS_PATH中指定的類庫。開發者能直接使用這個類加載器,正常情況下如果在我們的應用程序中沒有自定義類加載器,一般用的就是這個類加載器。
  • 自定義類加載器。如果需要,可以通過java.lang.ClassLoader的子類來定義自己的類加載器,一般我們都選擇繼承URLClassLoader來進行適當的改寫就可以了。

破壞雙親委派模式

雙親委派模式並不是一個強制性的約束模型,只是一種推薦的加載模型,雖然大家大都遵守了這個規則,但是也有不遵守雙親委派模型的,比如:JNDI,JDBC等相關的SPI動作並沒有完全遵守雙親委派模式

破壞雙親委派模式的一個最簡單的方式就是:繼承ClassLoader類,然後重寫其中的loadClass方法(因爲雙親委派的邏輯就寫在了loadClass()方法中)。

常見異常

如果加載過程中發生異常,那麼可能拋出以下異常(均爲LinkageError的子類):

  • ClassCircularityError:extends或者implements了自己的類或接口
  • ClassFormatError:類或者接口的二進制格式不正確
  • NoClassDefFoundError:根據提供的全限定類名找不到對應的類或者接口

ClassNotFoundException和NoClassDefFoundError

還有一個異常ClassNotFoundException可能也會經常遇到,這個看起來和NoClassDefFoundError很相似,但其實看名字就知道ClassNotFoundException是繼承自Exception,而NoClassDefFoundError是繼承自Error。

  • ClassNotFoundException
    當JVM要加載指定文件的字節碼到內存時,發現這個文件並不存在,就會拋出這個異常。這個異常一般出現在顯式加載中,主要有以下三種場景:
    (1)調用Class.forName() 方法
    (2)調用ClassLoader中的findSystemClass() 方法
    (3)調用ClassLoader中的loadClass() 方法
    解決方法:一般需要檢查classpath目錄下是否存在指定文件。
  • NoClassDefFoundError
    這個異常一般出現在隱式加載中,出現的情況是可能使用了new關鍵字,或者是屬性引用了某個類,或者是繼承了某個類或者接口,或者是方法中的某個參數中引用了某個類,這時候就會觸發JVM隱式加載,而在加載時發現類並不存在,則會拋出這個異常。
    解決方法:確保每個引用的類都在當前classpath下

連接(Linking)

鏈接是獲取類或接口類型的二進制形式並將其結合到Java虛擬機的運行時狀態以便執行的過程。鏈包含三個步驟:驗證,準備和解析。

注意:因爲鏈接涉及到新數據結構的分配,所以它可能會拋出異常OutOfMemoryError。

驗證(Verification)

這個步驟很好理解,類加載進來了肯定是需要對格式做一個校驗,要不然什麼東西都直接放到內存裏面,Java的安全性就完全無法得到保障。
主要驗證以下幾個方面:

  • 1、文件格式的驗證:比如說是不是以魔數開頭,jdk版本號的正確性等等。
  • 2、元數據驗證:比如說類中的字段是否合法,是否有父類,父類是否合法等等
  • 3、字節碼驗證:主要是確定程序的語義和控制流是否符合邏輯

如果驗證失敗,會拋出一個異常VerifyError(繼承自LinkageError)。

準備(Preparation)

準備工作是正式開始分配內存地址的一個階段,主要爲類或接口創建靜態字段(類變量和常量),並將這些字段初始化爲默認值。
以下是一些常用的初始值:

數據類型 默認值
int 0
long 0L
short (short)0
float 0.0f
double 0.0d
char ‘\u0000’
byte (byte)0
boolean false
引用類型 null

需要注意的是,假設某些字段的在常量池中已經存在了,則會直接在春被階段就會將其賦值。
如:

static final int i = 100;

這種被final修飾的會直接被賦初始值,而不會賦默認值。

解析(Resolution)

解析階段就是將常量池中符號引用替換爲直接引用的過程。在使用符號引用之前,它必須經過解析,解析過程中符號引用會對符號引用的正確性進行檢查。

注意:因爲Java是支持動態綁定的,有些引用需要等到具體使用的時候纔會知道具體需要指向的對象,所以解析這個步驟是可以在初始化之後才進行的。

常見異常

解析過程中可能會發生以下異常:

  • IllegalAccessError:權限異常,比如一個方法或者屬性被聲明爲private,但是又被調用了,就會拋出這個異常。
  • InstantiationError:實例化錯誤。在解析符號引用時,發現指向了一個接口或者抽象類而導致對象不能被實例化,就會拋出這個異常。
  • NoSuchFieldError:遇到了引用特定類或接口的特定字段的符號引用,但是類或接口不包含該名稱的字段。
  • NoSuchMethodError:遇到了引用特定類或接口的特定方法的符號引用,但該類或接口不包含該簽名的方法。

符號引用

符號引用以一組符號來描述鎖引用的目標,其中的符號可以是任何形式的字面量,只要根據符號唯一的定位到目標即可。比如說:String s = xx,xx就是一個符號,只要根據這個符號能定位到這個xx就是變量s的值就行。

直接引用

直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。對於同一個符號引用經過不同虛擬機轉換而得到的直接飲用一般是不相同的。當有了直接引用之後,那麼引用的目標必然已經存在於內存,所以這一步要在準備之後,因爲準備階段會分配內存,而這一步實際上也就是一個地址的配對的過程。

初始化(Initialization)

這個階段就是執行真正的賦值,會將之前賦的默認值替換爲真正的初始值,在這一步,會執行構造器方法。

那麼一個類什麼時候需要初始化?父類和子類的初始化順序又是如何?

初始化順序

在Java虛擬機規範中規定了5種情況必須立即對類進行初始化,主動觸發初始化的行爲也被稱之爲主動引用(除了以下5種情況之外,其餘不會觸發初始化的引用都稱之爲被動引用)。

  • 1、虛擬機啓動時候,會先初始化我們指定的需要執行的主類(即main方法所在類)。
  • 2、使用new關鍵字實例化對象時候,讀取或者設置一個類的靜態字段(final修飾除外),以及調用一個類的靜態方法時。
  • 3、初始化一個類的時候,如果其父類沒被初始化,則會先觸發父類的初始化。
  • 4、使用反射調用類的時候。
  • 5、JDK1.7開始提供的動態語言支持時,如果一個
    java.lang.invoke.MethodHandle實例解析的結果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄對應的類沒有被初始化,需要觸發其初始化。

注意:接口的初始化在第3點會有點不一樣,就是當一個接口在初始化的時候,並不要求其父接口全部都初始化,只有在真正使用到父接口的時候(如調用接口中定義的常量)纔會初始化

初始化實戰舉例

下面我們來看一些初始化的例子:

package com.zwx.jvm;

public class TestInit1 {
    public static void main(String[] args) {
        System.out.println(new SubClass());//A-先初始化父類,後初始化子類
//        System.out.println(SubClass.value);//B-只初始化父類,因爲對於static字段,只會初始化字段所在類
//        System.out.println(SubClass.finalValue);//C-不會初始化任何類,final修飾的數據初始化之前就會放到常量池
//        System.out.println(SubClass.s1);//D-不會初始化任何類,final修飾的數據初始化之前就會放到常量池
//        SubClass[] arr = new SubClass[5];//E-數組不會觸發初始化
    }
}

class SuperClass{
  static {
        System.out.println("Init SuperClass");
    }
    static int value = 100;

    final static int finalValue = 200;

    final static String s1 = "Hello JVM";
}

class SubClass extends SuperClass{
    static {
        System.out.println("Init SubClass");
    }
}
  • 1、語句A輸出結果爲:

    Init SuperClass Init SubClass com.zwx.jvm.SubClass@xxxxxx

因爲new關鍵字會觸發SubClass的初始化(主動引用情況2),而其父類沒有被初始化會先觸發父類的初始化(主動引用情況3)

  • 2、語句B輸出結果爲:

    Init SuperClass 100

調用了類的靜態常量(主動引用情況2),雖然是通過子類調用的,但是靜態常量卻定義在父類,所以只會觸發其父類初始化,因爲靜態屬性的調用只會觸發屬性所在類

  • 3、語句C和語句D輸出結果爲:

    200

因爲被final修飾的靜態常量存在於常量池中,在連接的準備階段就會將屬性直接進行賦值了,不需要初始化類

  • 4、語句E不會輸出任何結果
    因爲構造數組對象和直接構造對象是通過不同的字節碼指令來實現的,創建數組是通過一個單獨的newarray指令來實現的,並不會初始化對象。

使用(Using)

經過上面五個步驟之後,一個完整的對象已經加載到內存中了,接下來在我們的代碼中就可以直接使用啦。

卸載(Unloading)

當一個對象不再被使用之後,會被垃圾回收掉,垃圾回收會在JVM系列後續文章中進行介紹。

總結

本文主要介紹了Java虛擬機的類加載機制,相信看完這篇再結合上一篇對運行時數據區的講解,大家對Java虛擬機的類加載機制的工作原理有了一個整體的認知,那麼下一篇,我們會從更深層次的字節碼上來更具體更深入的分析Java虛擬機的方法調用流程及方法重載和方法重寫的原理分析。

請關注我,一起學習進步

上一篇:

【JVM系列1】深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

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