【JVM系列2】Java虛擬機器類載入機制 機製及雙親委派模式分析

2020-08-12 11:46:21

前言

上一篇我們粗略的介紹了一下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虛擬機器的方法呼叫流程及方法過載和方法重寫的原理分析。
請關注我,和孤狼一起學習進步