1.引言
java源文件經過編譯后生成字節碼class文件,需要經過虛擬機加載并轉換成匯編指令才能執行,那么虛擬機是如何一步步加載這些class文件的對于java程序員是完全透明的,本文嘗試全面分析jvm類加載機制。
2.思考
開始之前我們來簡單思考一下,如果讓你來寫虛擬機類加載你覺得要怎么做?
首先,肯定有一個加載過程,虛擬機要讀取class字節碼。
其次,為了保證虛擬機的安全性,需要對輸入做校驗,只有校驗通過了才能繼續執行,程序設計總是這樣,才能保證系統安全穩定。
再次,校驗通過后將字節碼轉換成類對象。
最后,將類對象建立全局索引方便引用。
如果把類加載也當成一個工程子模塊,從邏輯上看,我們上面的分析沒有什么問題,但工程實踐經驗表明,實際情況肯定要復雜一些,因為隨著深入,總有新問題產生,至于復雜多少需要我們繼續深入分析。
3.類的生命周期
類從被加載到jvm內存開始,到卸載出內存需要經過7個階段:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)。

類生命周期的七個階段并非串行執行,比如在進行驗證時與此同時準備階段或解析階段已經開始了,階段是相互嵌套并行執行的,只是按照邏輯分類可以這樣進行區分。又比如正常情況下解析階段過后是初始化階段,但為了支持java語言的運行時綁定(也成為動態綁定或晚期綁定),在初始化階段之后才開始解析階段。
類的主動引用和被動引用
什么情況下開始類加載呢,一般下面四種情況必須立即進行類初始化工作,如果類加載沒做自然也必須立馬做:
- 遇到new、getstatic、putstatic、invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發初始化。
- 對類進行反射調用時。
- 當初始化一個類時,如果父類還沒初始化,則需要先初始化父類。
- 包含main方法的主類,當虛擬機啟動時需要初始化該主類。
以上4種情況稱為對類的主動引用。
除了以上4種情況,其他對類的引用被稱為被動引用,
case1:子類引用父類的的靜態字段,只會觸發父類的初始化而不會觸發子類初始化。
public class SuperClass {public static int value = 123; static {System.out.println("SuperClass init!");}
}
public class SubClass extends SuperClass {static {System.out.println("SubClass init!");}
}
public class NotInitialization {public static void main(String[] args) {System.out.println(SubClass.value);}
}
上述代碼運行之后,最后輸出的是“SuperClass init!”。
case2:通過數組定義來引用類,不會觸發此類的初始化
public class NotInitialization {public static void main(String[] args) {SuperClass[] superArray = new SuperClass[10];}
}
這段代碼并不會觸發SuperClass初始化,即不會輸出“SuperClass init!”。
注:這段代碼會觸發[LSuperClass初始化,這個類代表SuperClass一維數組,相對c/c++,java對一維數組的封裝抱枕了安全性,當數組發生越界時,將拋出java.lang.ArrayIndexOutOfBoundsException。
case3:常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
public class ConstClass {public static final String HELLOWORLD = "Hello World"; static {System.out.println("ConstClass init!");}
}
public class NotInitialization {public static void main(String[] args) {System.out.println(ConstClass.HELLOWORLD);}
}
對常量ConstClass.HELLOWORLD的引用實際被轉換成NotInitialization類自身常量池的引用。
接口加載過程差異點
接口和類加載過程略有不同,根本差異點在于:當一個類初始化時,要求其父類全部已經初始化過了,但在一個接口初始化時,并不要求其父接口全部初始化,只有在真正使用到父接口時才會初始化。
4.類加載過程
類加載過程包含5個階段:加載、驗證、準備、解析和初始化。下面來分析一下這5個階段JVM都做了什么。
加載階段
加載階段需要完成3個事:
- 通過一個類的全限定名來獲得定義此類的二進制字節流。
- 將這個字節流所代表的靜態存儲結構轉換成方法區的運行時數據結構。
- 在堆中生成一個代表這個類的java.lang.Class對象,作為方法區數據的訪問入口。
虛擬機規范并沒有要求二進制字節流要從哪里獲取,也就是說,在加載階段是開了口子的,很開放的,富有創造性的程序員在這個舞臺玩出了各種花樣,比如字節流獲取可以從:
- 從ZIP獲取,最終形成了jar、war等格式。
- 從網絡中獲取,典型場景是Applet。
- 從其他文件生成,比如jsp。
- 運行時計算,典型場景就是動態代理技術:用ProxyGenerator.generateProxyClass來為特定接口生成*$Proxy代理類的二進制字節流。
還有其他方式,只有你想不到的。
相對于類加載其他階段的透明性,加載階段是程序員可控性最強的階段,因為加載階段可以使用系統提供的類加載器,也可以用戶自定義類加載器,我們在下文還會詳細說明Java的類加載器。
驗證階段
驗證階段屬于連接階段的第一步,是出于虛擬機自身安全考慮,確保二進制字節流包含的信息符合虛擬機的要求。這也說明了Java語言是相對安全的語言,使用純粹的Java代碼無法做到諸如訪問數據邊界以外的數據,將一個對象轉換成一個未知類型,跳轉到不存在的代碼行之類的行為。
驗證階段一般需要完成4個階段的校驗過程:文件格式校驗、元數據校驗、字節碼校驗和符號引用校驗。
文件格式校驗
文件格式校驗主要是完成語法校驗,即檢查二進制字節流是否符合Class文件格式規范,目標是保證輸入的字節流能正確地解析并存儲于方法區之內,格式上符合描述Java類型信息的要求。
校驗項具體包含:
- 是否以魔數0xCAFEBABE開頭。
- 主次版本號是否在虛擬機處理范圍。
- 常量池常量是否有不被支持的常量類型(即檢查常量tag標志)。
- CONSTANT_Utf8_info型常量中是否有不符合UTF8編碼的數據。
- Class文件中各個部分和文件本身是否有被刪除的或被附加的其他信息。
- ...
經過了這層驗證,字節流才會進入方法區內存儲。我們也可以看到驗證階段其實是和加載階段交織在一起的。
元數據校驗
元數據校驗階段是對字節碼信息進行語義分析,以保證數據符合Java語言規范,比如是否繼承了一個不允許被繼承的父類。具體驗證點包含:
- 這個類是否有父類,因為除了java.lang.Object外,所有類都有父類。
- 這個類是否繼承了final修飾的類。
- 如果這個類不是抽象類,那么,是否實現了其父類和接口之中要求實現的所有方法。
- 類中的字段、方法是否與父類產生了矛盾:比如是否覆蓋了父類的final方法,出現了不符合規范的方法重載等等。
- ...
元數據階段主要是完成數據類型校驗。
字節碼校驗
字節碼驗證階段將對類的方法體(數據流和控制流)進行驗證分析,是整個驗證階段最復雜的階段。這個階段保證方法在運行時不會出現危害虛擬機安全的行為。比如需要做:
- 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,比如不會出現操作數棧放置了一個int類型的數據,使用時卻按long類型來來載入本地變量表中。
- 保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
- 保證方法體內的類型轉換是有效的,比如允許把子類型賦值給父類數據類型,但不允許把父類對象類型賦值給子類數據類型。
- ...
但方法體內邏輯校驗無法做到絕對可靠,即不能指望校驗程序準確地檢查出程序能否在有限時間之內結束運行。
符號引用校驗
符號引用校驗可以看做是對類自身以外的信息進行匹配性校驗,發生時機是虛擬機將符號引用轉換成直接引用時。校驗內容包含:
- 符號引用中通過字符串描述的全限定名能否找到對應的類。
- 在指定類中是否存在符號方法的字段描述符以及簡單名稱所描述的方法、字段。
- 符號引用中類、字段和方法的訪問性是否允許當前類的訪問。
- ...
符號引用驗證的目的是保證解析動作能正常執行,如果沒有通過符號引用驗證將拋出java.lang.IncompatibleClassChangeError異常的子類,比如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
以上是驗證階段校驗的內容。
準備階段
準備階段正式為類變量分配內存并設置類變量的初始值,這些變量將在方法區中進行分配,也就是準備階段分配的是類變量,并非實例變量。
準備階段初始化類變量零值,以下是基本類型的零值:

舉個例子,假設一個類變量定義如下:
public static int value = 123;
那么value在準備階段初始化value = 0而非123,那什么時候會變成123呢,把value變成123的是putstatic指令是存放在類構造器<clinit>()方法中,而類構造器方法在初始化階段才會執行。
但也存在特殊情況類變量賦值不是0的情況,比如在類中定義常量,如果類字段的字段表屬性表中存在ConstantValue屬性,則在準備階段就會賦值。
public static final int value = 123;
解析階段
解析階段是虛擬機將常量池內的符號引用(Symbolic References)替換為直接引用(Direct References)的過程。符號引用在符號引用驗證中提到過,它以CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info及CONSTANT_InterfaceMethodref_info等類型的常量出現。符號引用和直接引用的差別在于:
- 符號引用是以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內部布局無關,引用的目標不一定已經加載到內存中。
- 直接引用是可以直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。
解析什么時候發生呢,虛擬機并未明確指定,一般虛擬機實現會根據需要來判斷,解析可能發生在類被加載器加載時就對常量池中的符號引用進行解析,亦或等到一個符號引用將要使用前才去解析。
此外,對同一個符號的多次解析請求是很常見的,為了避免重復解析,虛擬機實現可能會對第一次解析的結果緩存,即在運行時常量池中記錄直接引用,并把常量標識為已解析狀態。
解析動作主要針對類、接口、字段、類方法、接口方法四類符號引用。
類或接口的解析過程
假設當前代碼所處的類是D,如果要把一個從未解析過的符號引用N解析為一個類或接口C的直接引用,那虛擬機完成整個解析的過程需要包含以下3個步驟:
- 如果C不是數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C,在加載過程中,由于元數據、字節碼驗證的需要,可能觸發其他類的加載過程。
- 如果C是數組類型,并且數組元素是一個對象類型(以[Ljava.lang.Integer為例),那將會按照第一點的規則加載數組元素類型。虛擬機會生成一個代表此數組維度和元素的數組對象。
- 如果上述步驟沒有出現異常,那么C在虛擬機中已經成為了一個有效的類或接口了。在解析前還需要檢驗D是否具備對C的訪問權限,如果沒有訪問權限將拋出java.lang.IllegalAccessError。
字段解析
解析一個未被解析過的字段前,首先需要對字段表內class_index(即字段的類索引)的CONSTANT_Class_info符號引用進行解析,也就是或在進行字段解析之前,需要先完成類或接口的符號解析。
假設一個需要解析的字段所屬的類或接口為C,虛擬機規范要求按如下步驟對C后序字段進行搜索:
- 如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
- 如果在C中實現了接口,將會按照繼承關系從上到下遞歸搜索各個接口和它的父接口,如果接口中包含簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
- 如果C不是java.lang.Object,將會按照繼承關系從上往下遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
- 否則,查找失敗,拋出java.lang.NoSuchFieldError異常。
如果在父類和接口中存在同名字段會發生什么呢?如果是這樣情況,編譯器將拒絕編譯,比如下面這種情況:
public class FieldResolution {interface Interface0 {int a = 0;} interface Interface1 extends Interface0 {int a = 1;} interface Interface2 {int a = 2;}static class Parent implements Interface1 {public static int a = 3;} static class Sub extends Parent implements Interface2 {public static int a = 4;} public static void main(String[] args) {System.out.println(Sub.a);}
}
若將Sub靜態成員變量?publc static int a = 4?注釋掉,編譯器將返回“The field Sub.A is ambiguous”。
下面我們將類方法解析和接口方法解析,兩者是分開的。
類方法解析
類解析和字段解析一樣,需要先解析出類方法表的class_index索引所屬類或接口的符號引用。如果類或接口符號引用接口成功,我們依然用C來表示類或接口,接下來將按如下步驟進行搜索:
- 如果發現C是一個接口,將拋出java.lang.IncompatibleClassChangeError異常。
- 在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
- 在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回方法的直接引用,查找結束。
- 在類C的接口列表和它們的父接口中遞歸查找是否有簡單名稱和描述符和目標相匹配的方法,如果查找到,則說明類C是一個抽象類,將拋出java.lang.AbstractMethodError異常。
- 若以上未查詢到,則宣告查找失敗,拋出java.lang.NoSuchMethodError。
查找結束若成功返回,還需要對方法權限進行校驗。
接口方法解析
與類方法解析類似,步驟如下:
- C是否是類,如果是則拋出java.lang.IncompatibleClassChangeError異常。
- 在接口中查找。
- 在接口的父接口中遞歸查找。
- 若以上未查詢到,則宣告查找失敗,拋出java.lang.NoSuchMethodError。
以上是解析階段所做的工作。
初始化階段
類初始化階段是類加載過程的最后一步,到了初始化階段,才真正執行類中定義的字節碼。在準備階段,變量已經賦過一次初始值,初始化階段將執行類構造器<clinit>()方法的過程。
- <clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊static{}塊中的語句結合產生的。編譯器收集的順序是由語句在源文件中出現的順序決定的,比如靜態語句塊只能訪問定義在靜態語句塊之前的變量。
- <clinit>()方法和類的構造方法不同,并不需要顯示的調用父類構造器,虛擬機保證在子類<clinit>()方法執行之前,父類構造器先執行,因此,在虛擬機中第一個執行類構造器的類為java.lang.Object。
- <clinit>()方法對于類或接口來說并不是必須的。
- 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖和同步。
5.類加載器(ClassLoader)
上一節我們在一開始就聊到虛擬機允許用戶自定義類加載器,這就給java語言帶來了很大的靈活性,類加載器可以說是Java語言的一項創新,也是Java語言流行的重要原因之一。類加載器在類層次劃分、OSGi、熱部署、代碼加密等領域大放異彩,成為Java技術體系的一塊重要基石。
類和類加載器
類的唯一性是有類加載器決定的,比較2各類是否相等除了比較類本身還需要比較類加載器。如果一個類被2個不同的類加載器加載,那么加載的類是2個不同的類。如下所示:
public class ClassLoaderTest {public static void main(String[] args) {ClassLoader myLoader = new ClassLoader() {@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {try {String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";InputStream is = this.getClass().getResourceAsStream(fileName);if (is == null) {return super.loadClass(name);}byte[] b = new byte[is.available()];is.read(b);return defineClass(name, b,, 0, b.length);}catch (IOException e) {throw new ClassNotFoundException(name);}}};Object obj = myLoader.loadClass("ClassLoaderTest").newInstance();System.out.println(obj instanceof ClassLoaderTest);}
}
運行結果:false。
類加載器的層次結構
“橫看成嶺側成峰”,站在Java虛擬機角度看,只存在2種不同類型的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器是C++實現的,是虛擬機自身一部分;另外一種是其他類加載器,這種類加載器都是虛擬機外部的,由Java實現,并且全部繼承自抽象類java.lang.ClassLoader。站在Java程序員角度看,可以分成以下3種:
- 啟動類加載器,上面剛提到過,負載加載存放在JAVA_HOME/lib路徑中的類或者被-Xbootclasspath參數所指定路徑下的類。啟動類加載器無法被Java程序直接引用。
- 擴展類加載器(Extension ClassLoader),這個加載器由sum.misc.Launcher$ExtClassLoader實現,負責加載JAVA_HOME/lib/ext路徑下的類,或者由java.ext.dirs系統變量所指定下的類庫。
- 應用程序類加載器(Application ClassLoader),這個類加載器由sum.misc.Launcher$AppClassLoader來實現。由于這個類加載器可以由ClassLoader.getSystemClassLoader()獲取,因此也叫系統類加載器。AppClassLoader復雜加載用戶類路徑(ClassPath)上指定的類庫,開發者可以直接使用。
以下是類加載器的層次結構

雙親委派模型
類加載器雙親委派模型是JDK1.2引入被應用于幾乎所有的Java程序中。但它并不是一個強制性的約束模型,二是Java設計者推薦的一種類加載方式。
雙親委派有他的適用場景(它能夠適用于絕大多數場景),模型可以保證Java程序的穩定運行,防止重復加載和任意修改。那具體是如何做到的呢?
雙親委派模型的工作過程如下:如果一個類加載器收到了類加載的請求,它首先不會自己嘗試加載這個類,而是把請求委派給父類加載器去完成,如上圖所示,每一層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有父類反饋自己無法完成這個加載請求時(即它的搜索范圍沒有找到指定的類),字加載器才會嘗試自己加載。
比如java.lang.Object,無論哪個類加載器需要加載這個類,最終都由Bootstrap ClassLoader加載,因此Object在程序的各個類加載器環境都是同一個類。相反,如果如果不用雙親委派模型進行加載,用戶自定義了一個Object類并放置在類路徑下,最終可能會引發程序混亂。
雙親委派模型很好地解決了基礎類的統一問題,保證了虛擬機的安全性。
非雙親委派模型
線程上下文類加載器
雙親委派模型適用于大部分場景,但也有它自身的缺陷,假設基礎類由Bootstrap類加載器加載,但是基礎類需要回調用戶的代碼,基礎代碼卻是由應用類加載器加載,這個時候該怎么辦呢?
JNDI(Java Naming and Directory Interface)服務就是上面描述的這種場景,JNDI是Java的標準服務,它自身的代碼由Bootstrap類加載器加載,由于JNDI的目的就是對資源進行集中管理和查找,它需要調用獨立廠商實現的JNDI接口提供者(SPI)的代碼,獨立廠商提供的代碼jar包放置在ClassPath下,如果使用雙親委派模型加載類的方式是搞不定的,怎么辦呢?
為了解決這個困境,Java設計團隊引入了線程上下文類加載器(Thread Context ClassLoader),雖然它確實不太優雅,但解決問題啊。這個類加載器可以通過java.lang.Thread類的setContextClassLoader()方法進行設置,如果創建線程還未設置,它將從父線程中繼承一個,如果在應用程序的全局范圍內都沒有設置過,那么這個類加載器就是AppClassLoader。有了線程上下文類加載器,JNDI服務就可以加載所需要的SPI代碼,即父類加載器可以請求子類加載器完成類加載動作,這其實是違反了雙親委派原則的。實際上JNDI、JDBC、JCE、JAXB、JBI等所有涉及SPI加載動作的基本都采取的這種方式。
總結:線程上下文加載器之所以打破雙親委派模型是因為雙親委派模型依賴的單一方向的,并不能解決父類加載器去依賴子類加載器這種逆方向需求。
Tomcat類加載器
實際上,不只是Driver驅動的實現是這樣,只要有需要,在雙親委派機制無法滿足需求前提下,在tomcat、spring等等的容器框架也是通過一些手段繞過雙親委派機制。
雙親委派模型要求除了頂層的啟動類加載器之外,其余的類加載器都應當由自己的父類加載器加載。tomcat 為了實現隔離性,沒有遵守這個約定,每個webappClassLoader加載自己的目錄下的class文件,不會傳遞給父類加載器。如下圖所示

從圖中的委派關系中可以看出:
- CommonClassLoader能加載的類都可以被Catalina ClassLoader和SharedClassLoader使用,從而實現了公有類庫的共用。
- CatalinaClassLoader和Shared ClassLoader自己能加載的類則與對方相互隔離。
- WebAppClassLoader可以使用SharedClassLoader加載到的類,但各個WebAppClassLoader實例之間相互隔離。
JasperLoader的加載范圍僅僅是這個JSP文件所編譯出來的那一個.Class文件,它出現的目的就是為了被丟棄:當Web容器檢測到JSP文件被修改時,會替換掉目前的JasperLoader的實例,并通過再建立一個新的Jsp類加載器來實現JSP文件的HotSwap功能。
總結:tomcat之所以破壞雙親委派模型,我想主要在于雙親委派模型只看到了共享性,沒有看到隔離性需求,即共享是有條件的共享。
OSGI類加載器
非雙親委派模型的另一種需求來自程序動態性追求。比如代碼熱替換(HotSwap)、模塊熱部署(Hot Deployment)。可以哪USB熱插拔技術來做比方。熱部署對生產系統來說具有很大吸引力,不用停機就能完成部署效率啊。
OSGI是Java模塊化標準,OSGI實現模塊熱部署的關鍵是它自定義的類加載器,每一個模塊都有一個自己定義的類加載器,當需要更換一個Bundle時,則把Bundle連同類加載器一同替換以實現熱替換。
在OSGI環境下,類加載器不再是樹型結構的雙親委派模型,而是網狀結構,當收到類加載請求時,OSGI是按照下面順序進行類搜索的:
- 以“java.*”開頭的類,委派給父類加載器加載。
- 將委派列表名單內的類,委派給父類加載器加載。
- 將Import列表中的類,委派給Export這個類的Boundle的類加載器加載。
- 查找當前Boundle的ClassPath,使用自己的類加載器加載。
- 查找類是否在Fragment Boundle中,如果在,則委派給Fragment Boundle類加載器加載。
- 查找Dynamic Import列表的Boundle,委派給對應的Boundle類加載器加載。
如果以上都未查詢到,則查找失敗。
上面的搜索順序除了1,2兩點和雙親委派類似,其余都是平級類加載過程。
總結:OSGI Boundle類加載器提供了類加載的另一種機制,加載器結構不一定非得是樹型結構,也可以是網狀結構。
全文總結
本文較全面的分析了jvm的類加載機制,分析了類加載的5個階段,包含:加載、驗證、準備、解析、初始化,最后總結了類加載器加載類的幾種模型:雙親委派模型、SPI的類加載模型、tomcat類加載模型以及OSGI類加載模型。
The end.
轉載請注明來源,否則嚴禁轉載。