我們知道Java程式在編譯的過程中需要先經過javac将Java檔案編譯成位元組碼檔案才能被虛拟機執行。而類加載指的就是将編譯好的位元組碼(不僅僅指.class檔案中的位元組碼,任意的位元組碼流都可以被讀取到JVM)讀取到JVM的記憶體中的過程。虛拟機在加載.class檔案時會對資料進行校驗、轉換解析和初始化。最終形成可以被虛拟機直接使用的Java類型。這個過程稱作虛拟機的類加載機制。類加載機制是虛拟機中很重要的一部分内容,在面試中出現的頻率也是比較高。是以,作為一個Java程式員,JVM的類加載機制是我們必須掌握的知識。
為了更好的了解類加載的過程,我們先來看一道類加載的面試題:
// Person.java
public class Person{
static{
System.out.println("I'm a person");
}
}
// Stuent.java
public class Student extends Person{
public static String indentity="Student";
static{
System.out.println("I'm a student");
}
}
public class Ryan extends Student{
static{
System.out.println("I'm Ryan");
}
}
接下來我們我們寫一個測試類:
public class Test {
public static void main(String[] args) {
System.out.println("Ryan.indentity=" + Ryan.indentity);
}
}
大家可以先不要看下邊的答案,試着分析一下會輸出什麼樣的結果。
好了,公布答案,上述代碼結果輸出如下:
I'm a persion
I'm a student
Ryan.indentity=Student
是否和你的答案一樣呢?如果不一樣,那說明你還沒有清楚的了解JVM的類加載機制。接下來不妨就來學習一下JVM的類加載吧。
一、類加載的過程
一個類從被加載到虛拟機記憶體中開始,到解除安裝出虛拟機記憶體為止,它的聲明周期會經曆加載(Loading)、連接配接(Linking)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)這幾個階段。而連接配接階段又包含驗證(Verification)、準備(Preparation)、解析(Resolution)三個階段。如下圖所示:
其中,加載、驗證、準備、初始化和解除安裝這五個階段的順序是确定的,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始。接下來,我們來詳細的了解Java虛拟機中類加載的過程,即加載、驗證、準備、解析和初始化這五個階段。
1.加載階段
加載階段是類加載過程的第一個階段。這一階段JVM會通過類的全限定名(可能來自.class檔案,也可能來自ZIP壓縮包、網絡等,甚至可以是運作時生成。)來讀取類的二進制位元組流。并将讀取到的二進制位元組流轉化為方法區的運作時資料結構,然後在記憶體中生成一個代表這個類的java.lang.Class對象。
簡單的來說,這一階段就是将類的位元組碼二進制流讀取到JVM,并生成代表這個類的Class對象。
2.連接配接
連接配接階段包含了驗證、準備、解析三個過程。
(1)驗證
這一階段的目的是為了保證class檔案位元組流符合目前虛拟機的要求。并且保證這些資料代碼運作後不會危害虛拟機自身安全。驗證階段大緻會完成四個階段的檢驗:檔案格式驗證、中繼資料驗證、位元組碼驗證、符号引用驗證。
檔案格式驗證
這一階段驗證位元組流是否符合Class檔案格式的規範,并且能被目前的虛拟機處理。
中繼資料驗證
這一階段是對位元組碼描述的資訊進行語義分析,保證其描述的資訊符合Java語言規範的要求。
位元組碼驗證
這一階段會通過資料流和控制流分析,确定程式語義是否合法,符合邏輯。這個階段對類的方法體就行校驗分析,保證被校驗的類的方法在運作時不會做危害虛拟機安全的事情。
符号引用驗證
最後一個階段的校驗發生在虛拟機符号引用轉化為直引用的時候,這個轉化動作将在連接配接的第三階段–解析階段發生。符号引用校驗可以看做是對類自身以外(常量池的各種符号引用)的資訊進行比對性校驗。
(2)準備
準備階段是類加載機制中很重要的一個階段。 這一階段是正式為類中定義的靜态變量(被static修飾的變量)配置設定記憶體并設定類變量初始值的階段。這些變量所使用的記憶體都應該在方法區中進行配置設定。我們知道,在JDK7之前,Hotspot虛拟機使用永久代來實作方法區。而在JDK8之後,方法區被放在了Java堆中。是以,類變量也會随着Class對象一起存放在Java堆中。
另外,關于準備階段有兩點需要注意:
① 為變量配置設定記憶體 我們知道,Java類中的變量可以分為成員變量與類變量,類變量是指被static修飾的變量,其他類型的變量都屬于成員變量。而準備階段的記憶體配置設定僅包括類變量,不包括成員變量,成員變量隻有在對象執行個體化的時候随着對象一起配置設定到Java堆中。
例如下面的代碼在準備階段隻會為value配置設定記憶體,而不會為str配置設定記憶體。
public class Test {
public static int value = 123;
public String str = "123";
}
② 為類變量賦初始值 在準備階段,JVM會為類變量配置設定記憶體,并對其初始化。而初始化的值并非我們在代碼中賦予的值,而是資料類型的零值。 例如上述代碼中經過準備階段後value的值是0,而并非123。但如果給value再加一個final修飾符,那麼經過準備階段,value的值就是123(因為此時的value相當于一個常量),這是因為在編譯時Javac會為value生成ConstantValue屬性,在準備階段虛拟機就會根據ConstantValue的設定将value指派為123。
(3)解析
解析階段是虛拟機将常量池的符号引用替換為直接引用的過程。這一階段不太重要,了解即可。
3.初始化
初始化是類加載的最後一個階段,也是類加載過程中最重要的一個階段。在這一階段使用者定義的Java程式代碼(位元組碼)才真正開始執行。什麼意思呢?剛才提到在準備階段JVM會為類變量賦預設的初始值,而初始化階段類變量才會被賦予我們在代碼中聲明的值。JVM會根據語句執行順序對類對象進行初始化。
在《Java虛拟機規範》中并沒有強制限制在什麼情況下開始執行類加載的第一個“加載”階段,但是對于初始化階段《Java虛拟機規範》中卻有着嚴格的限制。一般來說當 JVM 遇到下面 6 種情況的時候會觸發類加載的初始化(在執行初始化階段之前需要先執行加載、驗證和準備階段):
① 遇到 new、getstatic、putstatic、invokestatic 這四條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字執行個體化對象的時候、讀取或設定一個類的靜态字段(被final修飾、已在編譯器把結果放入常量池的靜态字段除外)的時候,以及調用一個類的靜态方法的時候。
② 使用 java.lang.reflect 包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
③ 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
④ 當虛拟機啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛拟機會先初始化這個主類。
⑤ 當使用 JDK1.7 動态語言支援時,如果一個 java.lang.invoke.MethodHandle執行個體最後的解析結果
REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且這個方法句柄所對應的類沒有進行初始化,則需要先出觸發其初始化。
⑥ 當一個接口中定義了JDK8新加入的預設方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實作類發生了初始化,那該接口要在其之前被初始化。
二、類加載例題分析
在了解了類加載的過程後,我們通過幾個例題分析來深入的了解類加載。
1.開篇例題分析
開篇的面試題中在main方法中調用了Ryan.indentity,而indentity是位于Ryan父類Student中的類變量。根據初始化階段中的第①點我們可以知道:
讀取或設定一個類的靜态字段(被final修飾、已在編譯器把結果放入常量池的靜态字段除外)的時候,以及調用一個類的靜态方法的時候會觸發類的初始化。
是以,此時會首先去加載并初始化Student類(因為indentity是位于Student類中的靜态變量),而從初始化③中我們知道:
當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
是以,最先被加載并被初始化的類應該是Person類,Person類的初始化導緻了首先輸出 I’m a person語句。
接着Student類被加載,是以第二行輸出 了I’m a student 。在完成上述類加載後輸出 Ryan.indentity=Student
2.例題二
給出Singleton 類如下代碼所示,請分析程式的輸出結果。大家可以先不要答案,試着自己分析一下結果。
public class Singleton {
private static Singleton singleton = new Singleton();
public static int x;
public static int y = 0;
private Singleton() {
++x;
++y;
System.out.println("Singleton構造方法執行,x = " + x +",y = " + y);
}
public static void main(String[] args) {
System.out.println("singleton.x = " + singleton.x);
System.out.println("singleton.x = " + singleton.y);
}
}
輸出結果:
Singleton構造方法執行,x = 1,y = 1
singleton.x = 1
singleton.x = 0
如果不了解類加載的過程,會覺得這是一個很奇怪的輸出結果。x、y的初始值都是0,在構造方法種經過了同樣的“++”操作,而最後的輸出結果為什麼不一樣呢?我們還是先從出發類初始化的幾個條件着手。
1)從觸發類初始化的第④ 個條件可知,虛拟機啟動時會先加載包含main方法的類。是以Singleton 首先會觸發類加載流程。
2)而經過加載、驗證流程後,進入類加載的準備階段,這一階段虛拟機會為類變量配置設定記憶體和并對其進行初始化指派。注意,準備階段隻會給類變量賦預設值,經過準備階段後結果如下:
public class Singleton {
private static Singleton singleton = null;
public static int x = 0;
public static int y = 0;
}
3)初始化階段會根據代碼順序為類變量賦代碼中聲明的值。是以,首先會執行個體化Singleton ,并将執行個體化後的值賦給singleton。而此時,由于x、y還沒有被指派。是以x、y均為0。是以,在經過“++"操作後輸出x、y的值均為1。
4)接下來為x、y賦代碼中聲明的值,而在我們的代碼中x沒有賦初始值,y則被指派為0。是以,此時x仍然為1,而y則被指派為0.
5)類加載完成後列印x、y的值。
經過以上兩個例題的分析,相信大家對JVM的類加載機制有了一個更清楚的認識。而在類加載機制中除了類加載的過程,還有一個很重要的知識點,那就是類加載器,我們接着往下看。
三、類加載器
前兩章我們了解了類加載的過程,而類加載的過程則是由類加載器來完成的。類加載器在Java程式中起到的作用可以說遠超類加載階段。我們在程式中使用到的任意一個類都需要類加載器将其加載到虛拟機,并且由類加載器保證被加載類的唯一性。這裡我們需要明白一點:兩個類是否相等的前提條件是這兩個類是由同一個類加載器加載的。如果兩個類來自同一個Class檔案,但是被同一個虛拟機下不同的類加載器加載,那麼這兩個類必定不相等。
那麼問題來了,虛拟機是如何保證同一個Class檔案隻能被同一個類加載器加載的呢?要解答這個問題首先要了解類加載器的劃分。
1.類加載器的分類
在Java中類加載器分為啟動類加載器(Bootstrap Class Loader)、擴充類加載器(Extension Class Loader)、應用類加載器(Application Class Loader)以及自定義類加載器(User Class Loader)。接下來我們就分别來認識這幾種類加載器。
1)啟動類加載器(Bootstrap Class Loader)
這個類加載器是虛拟機的一部分,使用C++語言實作。這個類加載器隻負責加載存放在<JAVA_HOME>\lib目錄,或者被-Xbootclasspath參數所指定的路徑中存放的Java虛拟機能夠識别的(按照檔案名識别,如rt.jar、tool.jar。名字不符合的類庫即使放在lib目錄下也不會被加載)類庫加載到虛拟機中。
2)擴充類加載器(Extension Class Loader)
這個類加載器位于類sun.miss.Launcher$ExtClassLoader中,并且是由Java代碼所實作的。它負責加載<Java_HOME>\lib\ext目錄中,或被java.ext.dirs系統變量所指定的路徑中所有的類庫。開發者可以直接在程式中使用擴充類加載器來加載Class檔案。
3)應用程式類加載器(Application Class Loader)
這個類加載器位于sun.misc.Launcher$AppClassLoader中,同樣是由Java語言實作。他負責加載使用者類路徑(ClassPath)上所有的類庫。開發者同樣可以直接在代碼中使用這個類加載器。如果程式中沒有自定義的類加載器,一般情況下這個就是程式中預設的類加載器。
4)自定義類加載器(User Class Loader)
除了上述三種Java系統中的類加載器外,很多情況下使用者還會通過自定義類加載器加載所需要的類。諸如增加除了磁盤位置之外的Class檔案來源,或者通過類加載器實作類的隔離、重載等功能
2.雙親委派模型
在本章開頭我們已經提到兩個類相等的前提條件應該是這兩個類是由同一個類加載器加載的。既然Java種存在這麼多的類加載器,那麼Java是如何保證同一個類都是由同一個類加載器加載的呢?這主要得益于類加載器的“雙親委派模型”。接下來我們就來認識一下什麼是“雙親委派模型”。
如下圖所示,展示了各個類加載器之間的層次關系就是本節要講的“雙親委派模型”
雙親委派模型要求除了頂層啟動類加載器外,其餘的類加載器都應該有自己的父類加載器。而這裡類加載器之間的父子關系不是通過繼承來實作的,而是通過組合的關系來複用父加載器的代碼。
雙親委派模型的工作過程如下:
如果一個類加載器收到了類加載的請求,首先它不會自己嘗試加載這個類,而是把這個請求委派給父類加載器來完成,每個層次的類加載器都是如此。是以,所有的類加載請求最終都會被傳送到最頂層的啟動類加載器種,隻有當父加載器無法找到這個加載請求的類時,子類加載器才會嘗試去完成加載。
雙親委派模型的代碼實作非常簡單,如下:
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先檢查該類型是否已經被加載過了
Class c = findLoadedClass(name);
if (c == null) { //如果這個類還沒有被加載,則嘗試加載該類
try {
if (parent != null) { // 如果存在父類加載器,就委派給父類加載器加載
c = parent.loadClass(name, false);
} else { // 如果不存在父類加載器,就嘗試使用啟動類加載器加載
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {// 父類加載器找不到要加載的類,則抛出ClassNotFoundException
// 嘗試調用自身的findClass方法進行類加載
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
這段代碼的邏輯非常清晰易懂,代碼中已經做了詳細的注釋說明。正是因為雙親委派模型具備一種帶有優先級的層次關系,使得無論哪個類加載最終都會委派給處于最頂層的啟動類加載器進行加載,是以保證了在程式種各個類加載器環境中都能夠保證是同一個類。
四、小結
類加載機制通常是很多面試者的噩夢,碰到類加載的面試題隻能束手無策。而通過本篇文章的學習,你會發現其實類加載是一個很簡單的過程,隻要記住類加載的幾個重要階段,就能輕松駕馭大多數相關試題。另外,類加載器也是一個重要的知識點。雖然重要,但也簡單,用短短幾行代碼通過”雙親委派模型“就實作了類加載的過程。不得不讓我們佩服Java設計的精妙。
五、參考&推薦閱讀
《深入了解Java虛拟機 第三版》 作者周志明
兩道面試題,帶你解析Java類加載機制