上一篇我們粗略的介紹了一下Java虛擬機器的執行時數據區,並對執行時數據區內的劃分進行瞭解釋,今天我們就會從類載入開始分析並會深入去看看數據是具體以什麼格式儲存到執行時數據區的。
一個.java檔案經過編譯之後,變成了了.class檔案,主要經過留下步驟:
.java -> 詞法分析器 -> tokens流 -> 語法分析器 -> 語法樹/抽象語法樹 -> 語意分析器 -> 註解抽象語法樹 -> 位元組碼生成器 -> .class檔案 。
具體的過程不做分析,涉及到編譯原理比較複雜,我們需要分析的是.class檔案到底是一個什麼樣的檔案?
在Java中,每個類檔案包含單個類或介面,每個類檔案由一個8位元位元組流組成。所有16位元、32位元和64位元的量都是通過分別讀取2個、4個和8個連續的8位元位元組來構建的。
Java虛擬機器規範中規定,Class檔案格式使用一種類似於C語言的僞結構來儲存數據,class檔案中只有兩種數據型別,無符號數和表。注意**,class檔案中沒有任何對齊和填充的說法,所有數據都按照特定的順序緊湊的排列在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];//屬性表集合
}
這個結構在本篇文章裡不會一一去解釋,如果一一去解釋的話一來顯得很枯燥,二來可能會佔據大量篇幅,這些東西腦子裏面有個整體的概念,需要的時候再查下資料就好了,後面的內容中,如果遇到一些非常常用的類結構含義會進行說明,如魔數等還是有必要瞭解一下的。
我們先任意寫一個範例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)。
載入指的是通過一個完整的類或介面名稱來獲得其二進制流的形式並將其按照Java虛擬機器規範將數據儲存到執行時數據區。
類的載入主要是要做以下三件事:
上面的第1步在虛擬機器規範中並沒有說明Class來源於哪裏,也沒有說明怎麼獲取,所以就會產生了非常多的不同實現方式,下面 下麪就是一些常用的實現方式:
執行Class(類或者介面)的載入操作需要一個類載入器,而一個良好的,合格的類載入器需要具有以下兩個屬性:
在Java中的類載入器不止一種,而對於同一個類,用不同的類載入器加載出來的物件是不相等的,那麼Java是如何保證上面的兩點的呢?
這就是雙親委派模式,Java中通過雙親委派模式來防止惡意載入,雙親委派模式也確保了Java的安全性。
雙親委派模式的工作流程很簡單,當一個類載入器收到載入請求時,自己不去載入,而是交給它的父載入器去載入,以此類推,直到傳遞到最頂層類載入器,而只有當父載入器反饋說自己無法載入這個類,子載入器纔會嘗試去載入這個類。
上圖中就是雙親委派模型,細心的人可能注意到,頂層載入器我使用了虛線來表示,因爲頂層載入器是一個特殊的存在,沒有父載入器,而且從實現上來說,也沒有子載入器,是一個獨立的載入器,因爲擴充套件類載入器(Extension ClassLoader)和應用程式類載入器(Application ClassLoader)兩個載入器從繼承關係來看,是有父子關係的,均繼承了URLClassLoader。但是雖然從類的繼承關係來說啓動類載入器(Bootstrap ClassLoader)沒有子載入器,但是邏輯上擴充套件類載入器(Extension ClassLoader)還是會將收到的請求優先交給啓動類載入器(Bootstrap ClassLoader)來進行優先載入。
雙親委派模式並不是一個強制性的約束模型,只是一種推薦的載入模型,雖然大家大都遵守了這個規則,但是也有不遵守雙親委派模型的,比如:JNDI,JDBC等相關的SPI動作並沒有完全遵守雙親委派模式
破壞雙親委派模式的一個最簡單的方式就是:繼承ClassLoader類,然後重寫其中的loadClass方法(因爲雙親委派的邏輯就寫在了loadClass()方法中)。
如果載入過程中發生異常,那麼可能拋出以下異常(均爲LinkageError的子類):
還有一個異常ClassNotFoundException可能也會經常遇到,這個看起來和NoClassDefFoundError很相似,但其實看名字就知道ClassNotFoundException是繼承自Exception,而NoClassDefFoundError是繼承自Error。
鏈接是獲取類或介面型別的二進制形式並將其結合到Java虛擬機器的執行時狀態以便執行的過程。鏈包含三個步驟:驗證,準備和解析。
注意:因爲鏈接涉及到新數據結構的分配,所以它可能會拋出異常OutOfMemoryError。
這個步驟很好理解,類載入進來了肯定是需要對格式做一個校驗,要不然什麼東西都直接放到記憶體裏面,Java的安全性就完全無法得到保障。
主要驗證以下幾個方面:
如果驗證失敗,會拋出一個異常VerifyError(繼承自LinkageError)。
準備工作是正式開始分配記憶體地址的一個階段,主要爲類或介面建立靜態欄位(類變數和常數),並將這些欄位初始化爲預設值。
以下是一些常用的初始值:
數據型別 | 預設值 |
---|---|
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修飾的會直接被賦初始值,而不會賦預設值。
解析階段就是將常數池中符號參照替換爲直接參照的過程。在使用符號參照之前,它必須經過解析,解析過程中符號參照會對符號參照的正確性進行檢查。
注意:因爲Java是支援動態系結的,有些參照需要等到具體使用的時候纔會知道具體需要指向的物件,所以解析這個步驟是可以在初始化之後才進行的。
解析過程中可能會發生以下異常:
符號參照以一組符號來描述鎖參照的目標,其中的符號可以是任何形式的字面量,只要根據符號唯一的定位到目標即可。比如說:String s = xx,xx就是一個符號,只要根據這個符號能定位到這個xx就是變數s的值就行。
直接指向目標的指針、相對偏移量或是一個能間接定位到目標的控制代碼。對於同一個符號參照經過不同虛擬機器轉換而得到的直接飲用一般是不相同的。當有了直接參照之後,那麼參照的目標必然已經存在於記憶體,所以這一步要在準備之後,因爲準備階段會分配記憶體,而這一步實際上也就是一個地址的配對的過程。
這個階段就是執行真正的賦值,會將之前賦的預設值替換爲真正的初始值,在這一步,會執行構造器方法。
那麼一個類什麼時候需要初始化?父類別和子類的初始化順序又是如何?
在Java虛擬機器規範中規定了5種情況必須立即對類進行初始化,主動觸發初始化的行爲也被稱之爲主動參照(除了以下5種情況之外,其餘不會觸發初始化的參照都稱之爲被動參照)。
注意:介面的初始化在第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");
}
}
Init SuperClass
Init SubClass
com.zwx.jvm.SubClass@xxxxxx
因爲new關鍵字會觸發SubClass的初始化(主動參照情況2),而其父類別沒有被初始化會先觸發父類別的初始化(主動參照情況3)
Init SuperClass
100
呼叫了類的靜態常數(主動參照情況2),雖然是通過子類呼叫的,但是靜態常數卻定義在父類別,所以只會觸發其父類別初始化,因爲靜態屬性的呼叫只會觸發屬性所在類
200
因爲被final修飾的靜態常數存在於常數池中,在連線的準備階段就會將屬性直接進行賦值了,不需要初始化類。
經過上面五個步驟之後,一個完整的物件已經載入到記憶體中了,接下來在我們的程式碼中就可以直接使用啦。
當一個物件不再被使用之後,會被垃圾回收掉,垃圾回收會在JVM系列後續文章中進行介紹。
本文主要介紹了Java虛擬機器的類載入機制 機製,相信看完這篇再結合上一篇對執行時數據區的講解,大家對Java虛擬機器的類載入機制 機製的工作原理有了一個整體的認知,那麼下一篇,我們會從更深層次的位元組碼上來更具體更深入的分析Java虛擬機器的方法呼叫流程及方法過載和方法重寫的原理分析。
請關注我,和孤狼一起學習進步。