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

前言

上一篇咱們粗略的介紹了一下Java虛擬機的運行時數據區,並對運行時數據區內的劃分進行了解釋,今天咱們就會從類加載開始分析並會深刻去看看數據是具體以什麼格式存儲到運行時數據區的。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文件大體由以下結構組成:jvm

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];//屬性表集合
}

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

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虛擬機規範將數據存儲到運行時數據區。

類的加載主要是要作如下三件事:

  • 一、經過一個類的全限定名獲取定義此類的二進制字節流。

  • 二、將這個二進制字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構。

  • 三、在Java堆中生成一個表明這個類的java.lang.Class對象,做爲對方法區中這些數據的訪問入口。

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

  • 一、最正常的方式,讀取本地通過編譯後的.class文件。

  • 二、從壓縮包,如:zip,jar,war等文件中讀取。

  • 三、從網絡中獲取。

  • 四、經過動態代理動態生成.class文件。

  • 五、從數據庫中讀取。

執行Class(類或者接口)的加載操做須要一個類加載器,而一個良好的,合格的類加載器須要具備如下兩個屬性:

  • 一、對於同一個Class名稱,任什麼時候候都應該返回相同的類對象

  • 二、若是類加載器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的安全性就徹底沒法獲得保障。
主要驗證如下幾個方面:

  • 一、文件格式的驗證:好比說是否是以魔數開頭,jdk版本號的正確性等等。

  • 二、元數據驗證:好比說類中的字段是否合法,是否有父類,父類是否合法等等

  • 三、字節碼驗證:主要是肯定程序的語義和控制流是否符合邏輯

若是驗證失敗,會拋出一個異常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種狀況以外,其他不會觸發初始化的引用都稱之爲被動引用)。

  • 一、虛擬機啓動時候,會先初始化咱們指定的須要執行的主類(即main方法所在類)。

  • 二、使用new關鍵字實例化對象時候,讀取或者設置一個類的靜態字段(final修飾除外),以及調用一個類的靜態方法時。

  • 三、初始化一個類的時候,若是其父類沒被初始化,則會先觸發父類的初始化。

  • 四、使用反射調用類的時候。

  • 五、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");
    }
}
  • 一、語句A輸出結果爲:

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

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

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

    Init SuperClass 100

調用了類的靜態常量(主動引用狀況2),雖然是經過子類調用的,可是靜態常量卻定義在父類,因此只會觸發其父類初始化,由於靜態屬性的調用只會觸發屬性所在類

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

    200

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

  • 四、語句E不會輸出任何結果
    由於構造數組對象和直接構造對象是經過不一樣的字節碼指令來實現的,建立數組是經過一個單獨的newarray指令來實現的,並不會初始化對象。

使用(Using)

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

卸載(Unloading)

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

總結

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

**請關注我,一塊兒學習進步**

相關文章
相關標籤/搜索