一.類加載機制
JVM類加載分為5個過程:加載,驗證,準備,解析,初始化,使用,解除安裝,如下圖所示:

1.1 加載
加載主要是将.class檔案(并不一定是.class。可以是ZIP包,網絡中擷取)中的二進制位元組流讀入到JVM中。
在加載階段,JVM需要完成3件事:
1)通過類的全限定名擷取該類的二進制位元組流;
2)将位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構;
3)在記憶體中生成一個該類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口。
1.2 連接配接
1.2.1 驗證
驗證是連接配接階段的第一步,主要確定加載進來的位元組流符合JVM規範。
驗證階段會完成以下4個階段的檢驗動作:
1)檔案格式驗證
主要驗證點:
- 是否以魔數0xCAFEBABE開頭 主次版本号是否在目前虛拟機處理範圍之内 常量池的常量是否有不被支援的類型 (檢查常量tag标志)
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料
- Class檔案中各個部分及檔案本身是否有被删除的或者附加的其他資訊
- …
2)中繼資料驗證(是否符合Java語言規範)
主要驗證點:
- 該類是否有父類(隻有Object對象沒有父類,其餘都有)
- 該類是否繼承了不允許被繼承的類(被final修飾的類)
- 如果這個類不是抽象類,是否實作了其父類或接口之中要求實作的所有方法
-
類中的字段、方法是否與父類産生沖突(例如覆寫了父類的final字段,出現不符合規則的方法重載,例如方法參數都一緻,但是傳回值類型卻不同)
…
3)位元組碼驗證(确定程式語義合法,符合邏輯)
主要是通過資料流和控制流分析,确定程式語義是合法的、符合邏輯的。在第二階段對中繼資料資訊中的資料類型做完校驗後,位元組碼驗證将對類的方法體進行校驗分析,保證被校驗類的方法在運作時不會做出危害虛拟機安全的事件。
主要有:
- 保證任意時刻操作數棧的資料類型與指令代碼序列都能配合工作,例如不會出現類似的情況:操作數棧裡的一個int資料,但是使用時卻當做long類型加載到本地變量中
- 保證跳轉不會跳到方法體以外的位元組碼指令上
- 保證方法體内的類型轉換是合法的。例如子類指派給父類是合法的,但是父類指派給子類或者其它毫無繼承關系的類型,則是不合法的。
4)符号引用驗證(確定下一步的解析能正常執行)
最後一個階段的校驗發生在虛拟機将符号引用轉化為直接引用的時候,這個轉化動作将在連接配接的第三階段解析階段發生。符号引用是對類自身以外(常量池中的各種符号引用)的資訊進行比對校驗。
通常有:
- 符号引用中通過字元串描述的全限定名是否找到對應的類 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段
- 符号引用中的類、方法、字段的通路性(private,public,protected、default)是否可被目前類通路
- 符号引用驗證的目的是確定解析動作能夠正常執行,如果無法通過符号引用驗證,那麼将會抛出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
驗證階段非常重要,但不一定必要,如果所有代碼極影被反複使用和驗證過,那麼可以通過虛拟機參數-Xverify: none來關閉驗證,加速類加載時間。
1.2.2 準備
準備是連接配接階段的第二步,主要為靜态變量在方法區配置設定記憶體,并設定預設初始值。
一種特殊情況是,如果字段屬性表中包含ConstantValue屬性,那麼準備階段變量value就會被初始化為ConstantValue屬性所指定的值,比如上面的value如果這樣定義:
public static final int value = 123;
編譯時,value一開始就指向ConstantValue,是以準備期間value的值就已經是123了。
1.2.3 解析
解析是連接配接階段的第三步,是虛拟機将常量池内的符号引用替換為直接引用的過程。
符号引用(Symbolic References):符号引用以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要可以唯一定位到目标即可。符号引用于記憶體布局無關,是以所引用的對象不一定需要已經加載到記憶體中。各種虛拟機實作的記憶體布局可以不同,但是接受的符号引用必須是一緻的,因為符号引用的字面量形式已經明确定義在Class檔案格式中。
直接引用(Direct References):直接引用時直接指向目标的指針、相對偏移量或是一個能間接定位到目标的句柄。直接引用和虛拟機實作的記憶體布局相關,同一個符号引用在不同虛拟機上翻譯出來的直接引用一般不會相同。如果有了直接引用,那麼它一定已經存在于記憶體中了。
對同一個符号進行多次解析請求是很常見的,除了invokedynamic指令以外,虛拟機基本都會對第一次解析的結果進行緩存,後面再遇到時,直接引用,進而避免解析動作重複。
對于invokedynamic指令,上面規則不成立。當遇到前面已經由invokedynamic指令觸發過解析的符号引用時,并不意味着這個解析結果對于其他invokedynamic指令同樣生效。這是由invokedynamic指令的語義決定的,它本來就是用于動态語言支援的,也就是必須等到程式實際運作這條指令的時候,解析動作才會執行。其它的指令都是“靜态”的,可以再剛剛完成記載階段,還沒有開始執行代碼時就解析。
1.3 初始化
初始化階段是類加載過程的最後一步,主要是根據程式中的指派語句主動為類變量指派。
編譯器收集的順序是由語句在源檔案中出現的順序決定的,靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句塊中可以指派,但是不能通路。
注:
1)當有父類且父類為初始化的時候,先去初始化父類;
2)再進行子類初始化語句。
什麼時候需要對類進行初始化?
1)使用new該類執行個體化對象的時候;
其中情況1中的4條位元組碼指令在Java裡最常見的場景是:
1 . new一個對象時
2 . set或者get一個類的靜态字段(除去那種被final修飾放入常量池的靜态字段)
3 . 調用一個類的靜态方法
2)讀取或設定類靜态字段的時候(但被final修飾的字段,在編譯器時就被放入常量池的靜态字段除外static final);
3)調用類靜态方法的時候;
4)使用反射Class.forName(“xxxx”)對類進行反射調用的時候,該類需要初始化;
5) 初始化一個類的時候,有父類,先初始化父類(注:1. 接口除外,父接口在調用的時候才會被初始化;2.子類引用父類靜态字段,隻會引發父類初始化);
6) 被标明為啟動類的類(即包含main()方法的類)要初始化;
7)當使用JDK1.7的動态語言支援時,如果一個java.invoke.MethodHandle執行個體最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
以上情況稱為對一個類進行主動引用,且有且隻要以上幾種情況需要對類進行初始化。
二.類加載器
類加載器實作的功能是即為加載階段擷取二進制位元組流的時候。
JVM提供了以下3種系統的類加載器:
- 啟動類加載器(Bootstrap ClassLoader):最頂層的類加載器,負責加載 JAVA_HOME\lib 目錄中的,或通過-Xbootclasspath參數指定路徑中的,且被虛拟機認可(按檔案名識别,如rt.jar)的類。
- 擴充類加載器(Extension ClassLoader):負責加載 JAVA_HOME\lib\ext 目錄中的,或通過java.ext.dirs系統變量指定路徑中的類庫。
- 應用程式類加載器(Application ClassLoader):也叫做系統類加載器,可以通過getSystemClassLoader()擷取,負責加載使用者路徑(classpath)上的類庫。如果沒有自定義類加載器,一般這個就是預設的類加載器。
類加載器之間的層次關系如下:
類加載器之間的這種層次關系叫做雙親委派模型。
雙親委派模型要求除了頂層的啟動類加載器(Bootstrap ClassLoader)外,其餘的類加載器都應當有自己的父類加載器。這裡的類加載器之間的父子關系一般不是以繼承關系實作的,而是用組合實作的。
雙親委派模型的工作過程
如果一個類接受到類加載請求,他自己不會去加載這個請求,而是将這個類加載請求委派給父類加載器,這樣一層一層傳送,直到到達啟動類加載器(Bootstrap ClassLoader)。
隻有當父類加載器無法加載這個請求時,子加載器才會嘗試自己去加載。
雙親委派模型的優點
系統安全性:Java類随着加載它的類加載器一起具備了一種帶有優先級的層次關系。比如,Java中的Object類,它存放在rt.jar之中,無論哪一個類加載器要加載這個類,最終都是委派給處于模型最頂端的啟動類加載器進行加載,是以Object在各種類加載環境中都是同一個類。如果不采用雙親委派模型,那麼由各個類加載器自己取加載的話,那麼系統中會存在多種不同的Object類
雙親委派模型的代碼實作
雙親委派模型的代碼實作集中在java.lang.ClassLoader的loadClass()方法當中。
1)首先檢查類是否被加載,沒有則調用父類加載器的loadClass()方法;
2)若父類加載器為空,則預設使用啟動類加載器作為父加載器;
3)若父類加載失敗,抛出ClassNotFoundException 異常後,再調用自己的findClass() 方法。
loadClass源代碼如下:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
//1 首先檢查類是否被加載
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//2 沒有則調用父類加載器的loadClass()方法;
c = parent.loadClass(name, false);
} else {
//3 若父類加載器為空,則預設使用啟動類加載器作為父加載器;
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
//4 若父類加載失敗,抛出ClassNotFoundException 異常後
c = findClass(name);
}
}
if (resolve) {
//5 再調用自己的findClass() 方法。
resolveClass(c);
}
return c;
}
破壞雙親委派模型
雙親委派模型很好的解決了各個類加載器加載基礎類的統一性問題。即越基礎的類由越上層的加載器進行加載。
若加載的基礎類中需要回調使用者代碼,而這時頂層的類加載器無法識别這些使用者代碼,怎麼辦呢?這時就需要破壞雙親委派模型了。
下面介紹兩個例子來講解破壞雙親委派模型的過程。
JNDI破壞雙親委派模型
JNDI是Java标準服務,它的代碼由啟動類加載器去加載。但是JNDI需要回調獨立廠商實作的代碼,而類加載器無法識别這些回調代碼(SPI)。
為了解決這個問題,引入了一個線程上下文類加載器。 可通過Thread.setContextClassLoader()設定。
利用線程上下文類加載器去加載所需要的SPI代碼,即父類加載器請求子類加載器去完成類加載的過程,而破壞了雙親委派模型。
Spring破壞雙親委派模型
Spring要對使用者程式進行組織和管理,而使用者程式一般放在WEB-INF目錄下,由WebAppClassLoader類加載器加載,而Spring由Common類加載器或Shared類加載器加載。
那麼Spring是如何通路WEB-INF下的使用者程式呢?
使用線程上下文類加載器。 Spring加載類所用的classLoader都是通過Thread.currentThread().getContextClassLoader()擷取的。當線程建立時會預設建立一個AppClassLoader類加載器(對應Tomcat中的WebAppclassLoader類加載器): setContextClassLoader(AppClassLoader)。
利用這個來加載使用者程式。即任何一個線程都可通過getContextClassLoader()擷取到WebAppclassLoader。
三 附上Tomcat類加載架構:
Tomcat目錄下有4組目錄:
- /common目錄下:類庫可以被Tomcat和Web應用程式共同使用;由 Common ClassLoader類加載器加載目錄下的類庫;
- /server目錄:類庫隻能被Tomcat可見;由 Catalina ClassLoader類加載器加載目錄下的類庫;
- /shared目錄:類庫對所有Web應用程式可見,但對Tomcat不可見;由 Shared ClassLoader類加載器加載目錄下的類庫;
- /WebApp/WEB-INF目錄:僅僅對目前web應用程式可見。由 WebApp ClassLoader類加載器加載目錄下的類庫;
- 每一個JSP檔案對應一個JSP類加載器。
四.經典面試題
class Singleton{
private static Singleton singleton = new Singleton();
public static int value1;
public static int value2 = 0;
private Singleton(){
value1++;
value2++;
}
public static Singleton getInstance(){
return singleton;
}
}
class Singleton2{
public static int value1;
public static int value2 = 0;
private static Singleton2 singleton2 = new Singleton2();
private Singleton2(){
value1++;
value2++;
}
public static Singleton2 getInstance2(){
return singleton2;
}
}
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println("Singleton1 value1:" + singleton.value1);
System.out.println("Singleton1 value2:" + singleton.value2);
Singleton2 singleton2 = Singleton2.getInstance2();
System.out.println("Singleton2 value1:" + singleton2.value1);
System.out.println("Singleton2 value2:" + singleton2.value2);
}
說出運作的結果:
Singleton1 value1 : 1
Singleton1 value2 : 0
Singleton2 value1 : 1
Singleton2 value2 : 1
分析如下:
Singleton輸出結果:1 0:
1 首先執行main中的Singleton singleton = Singleton.getInstance();
2 類的加載:加載類Singleton
3 類的驗證
4 類的準備:為靜态變量配置設定記憶體,設定預設值。這裡為singleton(引用類型)設定為null,value1,value2(基本資料類型)設定預設值0
5 類的初始化(按照指派語句進行修改):
執行private static Singleton singleton = new Singleton();
執行Singleton的構造器:value1++;value2++; 此時value1,value2均等于1
執行
public static int value1;
public static int value2 = 0;
此時value1=1,value2=0
Singleton2輸出結果:1 1 :
1 首先執行main中的Singleton2 singleton2 = Singleton2.getInstance2();
2 類的加載:加載類Singleton2
3 類的驗證
4 類的準備:為靜态變量配置設定記憶體,設定預設值。這裡為value1,value2(基本資料類型)設定預設值0,singleton2(引用類型)設定為null,
5 類的初始化(按照指派語句進行修改):
執行
public static int value2 = 0;
此時value2=0(value1不變,依然是0);
執行
private static Singleton singleton = new Singleton();
執行Singleton2的構造器:value1++;value2++;
此時value1,value2均等于1,即為最後結果