深入了解Java虛擬機---虛擬機類加載機制

什么是虛擬機的類加載機制?
虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析以及初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。

類的生命周期

類加載時機

類從被加載到虛擬機內存中開始,直至從內存卸載為止一共包括7個階段,如上圖所示。其中 驗證、準備以及解析這個三個過程統稱為連接。

加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,累計的加載過程必須按照這種順序按部就班地開始,但是解析階段不一定,這是為了支持Java語言的動態綁定。但是這幾個階段又不是完全孤立分割的,也就是說某幾個階段可能會交叉進行。

有且僅有5種情況必須對類進行“初始化”(加載、驗證、準備階段在此之前就要完成):

  • 在遇到new、getstatic、putstatic、或invokestatic這4條字節碼指令時,如果此時類未進行初始化, 那就需先觸發其初始化。
    對應的Java代碼場景是:使用new關鍵字實例化對象、讀取或者設置一個靜態字段(被final修飾,在編譯時期把結果放入常量池中的靜態字段除外)的值以及調用類的靜態方法時;
  • 使用java.lang.reflect包的方法對類進行反射調用時,如果類沒有進行過初始化,那就需先觸發其初始化;
  • 當初始化一個類時,如果發現其父類尚未初始化,則需先觸發其父類的初始化;
  • 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類;
  • 當使用動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先進行初始化。
注意:有且僅有上面五種情況會觸發類的初始化

那么除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。
被動引用舉例:

只會輸出“SuperClass Init!”
不會觸發SuperClass初始化
不會觸發ConstClass類的初始化

接口的加載過程和類的加載過程稍有不同:
接口也有初始化的過程,這和類是一致的。但是接口中不允許出現“static{ }”語句塊。
接口與類有真正區別的是在上面五種“有且僅有”條件中的第三個:

當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求父接口已經完成初始化,而是要在用到父接口時才對其進行初始化。

類加載過程

Java虛擬機中類加載過程包括5個階段:加載、準備、驗證、解析、初始化。

加載

“加載(Loading)”是“類加載(Class Loading)”過程中的一個階段。

“加載”階段虛擬機需要完成以下3件事情:

  • 通過類的全限定類名來獲取此類的二進制字節流(可以從多種渠道獲取,例如從ZIP包中、從網絡中,或者是運行時計算生成等);
  • 將這個字節流所代表的靜態存儲結構轉換為方法區的運行時數據結構;
  • 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區中這個類的各種數據的訪問入口。

相對于類加載過程中的其他階段,非數組類的加載階段(確切地說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的。因為加載階段既可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器去完成,開發人員可以通過自定義自己的類加載器去控制字節流的獲取方式(即重寫一個類加載器的loadClass()方法)。

對于數組類而言,情況有所不同。
數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。但是數組類與類加載器仍然有很密切的關系。因為數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終是要靠類加載器去創建,一個數組類(下面簡稱C)創建過程需要遵循以下規則:

  • 如果數組的組件類型(Component Type,指的是數組去掉一個維度的類型)是引用類型,那就遞歸上面定義的加載過程去加載這個組件類型,數組C將在加載該組件類型的類加載器的類名稱空間上被標識(一個類必須與類加載器一起確定唯一性);
  • 如果數組的組件類型不是引用類型(例如int[ ]數組),Java虛擬機將會把數組C標記為與引導類加載器關聯;
  • 數組類的可見性與它的組件類型的可見性一致,如果組建類型不是引用類型,那數組類的可見性將默認為public。

加載完成之后,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機自行定義,虛擬機規范沒有對此區域的具體數據結構進行規定。然后再內存中實例化一個java.lang.Class類的對象(并沒有明確規定是在Java堆中,對于HotSpot虛擬機而言,Class對象比較特殊,它雖然是對象,但是存放在方法區里面),這個對象將作為程序訪問方法區中的這些類型數據的外部接口。

加載階段和連接階段的部分內容(如一部分字節碼格式驗證動作)是交叉進行的,加載階段未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬于連接階段的內容,這兩個階段的開始時間仍然保持著固定的順序。

驗證

驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。

驗證階段大致會包含下面4個階段的檢驗動作:

  1. 文件格式驗證
  1. 元數據驗證
  2. 字節碼驗證
  3. 符號引用驗證

1.文件格式驗證
第一階段要驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。
這一階段可能包含下面這些驗證點:

  • 是否以魔數0XCAFEBABE開頭;
  • 主、次版本號是否在當前虛擬機處理范圍之內;
  • 常量池中的常量是否有不被支持的常量類型;
  • 指向常量的各種索引值是否有指向不存在的常量或不符合類型的常量;
  • Class文件中各個部分以及文件本身是否有被刪除的或附加的其他信息。

其實第一階段的驗證內容遠不止上面這幾條。
該驗證階段的目的是保證輸入的字節流能正確地解析并存儲于方法區之內,格式上符合描述一個Java類型信息的要求。該階段的驗證是基于二進制字節流進行的,只有通過了這個階段的驗證之后,字節流才會進入內存的方法區中進行存儲。
所以后面的3個驗證階段全部是基于方法區的存儲結構進行的,不會再操作字節流。

2.元數據驗證
第二階段的驗證是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規范的要求,這個階段可能包含的驗證點如下:

  • 這個類是否有父類(除了java.lang.Object之外,其他類都應該有父類)
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法
  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,如方法名和參數都一致,但是返回值類型缺不同等)。

第二階段的驗證的主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規范的元數據信息。

3.字節碼驗證
第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。
在第二階段對元數據信息中的數據類型做完校驗之后,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事情,如:

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似于這樣的情況:在操作數棧中放置了一個int類型的數據,使用時卻按long類型來載入本地變量表中;
  • 保證跳轉指令不會跳轉到方法體以外的字節碼指令上;
  • 保證方法體中的類型轉換是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的。但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關系、完全不相干的數據類型,則是危險和不合法的。

如果一個類方法體的字節碼沒有通過字節碼驗證,那就說明是有問題的。
但是就算一個方法體通過了字節碼驗證,也不能說它就一定是安全的:通過程序去校驗程序邏輯是無法做到絕對準確的----不能通過程序準確地檢查出程序是否能在有限的時間之內結束運行。

4.符號引用驗證
最后一個階段的驗證發生在虛擬機將符號引用轉換為直接引用的時候,這個轉化動作將在連接的第三階段---解析階段中發生。符號引用可以看作是對類自身(常量池中的各種符號引用)的信息進行匹配性校驗,通過需要校驗下列內容:

  • 符號引用中通過字符串描述的全限定性類名是否能找到對應的類;
  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段;
  • 符號引用中的類、字段、方法的訪問性(private、protected、public、default)是否可以被當前類訪問。

符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那么將拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

對于虛擬機來說,驗證階段是非常重要,但不是必需的。如果說所運行的代碼都已經被反復使用和驗證過,那么在實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

準備

準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都在方法區中進行分配。

需要注意兩點:

  • 這時候進行內存分配的僅包括類變量(被static修飾的變量,即靜態變量),而不包括實例變量,實例變量會在對象實例化的時候隨著對象一起分配在Java堆中;
  • 這里所說的初始值“通常情況”下是數據類型的零值:
    假設一個類變量的定義為: public static int value = 123;
    那變量value在準備階段過后的初始值為0而不是123,因為此時尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是在程序被編譯之后,存放于類構造器<clint>()方法中,所以把value賦值為123的動作將在初始化階段才會執行。

那么在非“通常情況”之下呢?
如果類的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化為ConstantValue屬性所指定的值。
假設類變量value的定義為:
public static final int value = 123;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值為123。

解析

解析階段是虛擬機將常量池中的符號引用轉化為直接引用的過程。

  • 符號引用(Symbolic Reference):

符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標并不一定已經加載到內存當中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。

  • 直接引用(Direct Referenct):

直接引用可以是直接指向目標的指針、相對偏移量或者是一個能簡介定位到目標的句柄。直接引用是和虛擬機實現的內存布局息息相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。

虛擬機規范中未規定解析發生的具體時間,只要求在執行anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic這16個用于操作符號引用之前,先對它們所使用的符號引用進行解析。所以虛擬機實現可以根據需要來判斷是在類被加載之前時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。

注意:

  • 因為對同一個符號引用進行多次解析式很常見的事情,除invokedynamic指令以外,虛擬機可以實現對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用。并把常量標識為已經解析的狀態)從而避免解析動作重復進行。無論是否執行了多次解析動作,虛擬機需要保證的是在同一個實體當中,如果一個符號引用之前就已經解析過,那么后續的引用解析請求也應當一直成功;同樣,如果第一次解析失敗了,那么其他指令對此引用的解析請求也應當收到相同的異常;
  • 對于invokedynamic指令,以上規則就不成立。當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,并不意味著這個解析結果對于其他invokedynamic指令也同樣生效。

1.類或接口的解析
2.字段解析
3.類方法解析
4.接口方法解析

初始化

類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作均由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源。

初始化階段是執行類構造器<clint>()方法的過程

  • <clinit>()方法是由編譯器自動收集類中所有類變量的賦值動作和靜態代碼塊中的語句合并產生的。靜態代碼塊中只能訪問到定義在靜態代碼塊之前的靜態變量。假如說有個靜態變量在靜態代碼塊之后定義,那么代碼塊可以給它賦值,但是不能訪問它:
非法向前引用變量
  • <clinit>()方法和類的構造函數(實例構造器<linit><>方法)不同,它不用顯式調用父類構造器。虛擬機會在子類的<clinit>()方法執行前,保證已經執行完了父類的<clinit>()方法。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object

  • 由于父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先于子類靜態變量的賦值操作:

字段B的值為2而不是1
  • <clinit>()方法對于類或者接口來說不是必需的,假如一個類中沒有靜態代碼塊,并且沒有對類變量的賦值操作,那么編譯器就不會給這個類生成<clinit>()方法。

  • 接口中不能使用靜態代碼塊,但是仍然有為變量進行賦值的操作,所以和類一樣,接口也會生成<clinit>()方法。但是與類不同的是,執行接口的<clinit>()方法時候并不要求父接口已經將<clinit>()方法執行完畢。只有當父接口中定義的變量被使用時,才會執行父接口的<clinit>()方法。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

  • 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多線程同時去初始化一個類,那么只有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞。需要注意的是,其他線程雖然會被阻塞,但如果執行<clinit>()那條線程退出<clinit>()方法后,其他線程喚醒之后不會再次進入<clinit>()方法。同一個類加載器下,一個類型只會初始化一次。

關于類加載器

虛擬機設計團隊把類加載階段中的“通過一個類的全限定性類名來獲取描述此類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何獲取所需要的類。實現這個動作的代碼模塊稱為“類加載器”。

類加載器雖然只用于實現類的加載動作,但它在Java程序中起到的作用卻遠遠不限于類加載階段。對于任意一個類,都需要加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性。每一個類加載器都擁有一個獨立的類命名空間。通俗點說,也就是比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則即使這兩個類來源于同一個Class文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

雙親委派模型

從虛擬機角度來講,只存在兩種類加載器:一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;另一種就是所有其他的累加器其,這些類加載器全部由Java語言實現,獨立于虛擬機外部,并且全都繼承自抽象類java.lang.ClassLoader。

從Java開發人員的角度,絕大部分Java程序會使用到以下三種類加載器:

  • 啟動類加載器(Bootstrap ClassLoader):

這個類加載器負載將存放在<JAVA_HOME>\lib目錄中的,或者被 -Xbootclasspath 參數所指定的路徑中的,并且虛擬機能夠識別的類庫(僅按照文件名識別,如 rt.jar ,名字不符合的類庫即使存放在lib目錄中也不會被加載)加載到虛擬機內存中。

  • 拓展類加載器(Extension ClassLoader):

這個加載器由sum.misc.Launcher$ExtClassLoader實現,它負責加載<JAVA_HOME>\lib\ext 目錄中的,或者被 java.ext.dirs 系統變量所指定的路徑中的所有類庫,開發者可以直接使用拓展類加載器。

  • 應用類加載器(Application ClassLoader):

由 sum.misc.Launcher$ApplicationLoader 實現。由于這個類加載器是CalssLoader中的 getSystemClassLoader()方法的返回值,所以一般也稱它為系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序當中沒有自定義過自己的類加載器,一般情況下默認使用這個類加載器。

類加載器雙親委派模型

上圖展示的類加載器之間的關系,稱為類加載器的雙親委派模型(Parents Delegation Mode)。雙親委派模型要求除了頂層的啟動類加載器之外,其他的類加載器都應該有自己的父類加載器。這里類加載器之間的父子關系一般不會以繼承(Inheritance)的關系來實現,而是都使用組合(Composition)關系來復用父加載器的代碼。

雙親委派機制工作原理:

如果一個類收到了類加載的請求,它首先不會自己去加載這個類,而是把請求委派給父類加載器去完成,每一個層次的類加載器都是如此。因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成此加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。

使用雙親委派模型的好處:

Java類隨著它的類加載器一起具備了一種帶有優先級的層次關系。如類java.lang.Object,它存放在 rt.jar 之中,無論哪個類加載器要加載這個類,最終都是委派給頂層的啟動類加載器進行加載,因此Object類在程序中的各種類加載器環境中都是同一個類。相反,若沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個叫做java.lang.Object的類,并存放在程序的ClassPath中,那么系統將會出現多個不同的Object類,Java類型體系中最基礎的行為也就無法保證,應用程序也會變得一片混亂。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,197評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,415評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,104評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,884評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,647評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,130評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,208評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,366評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,887評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,737評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,478評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,174評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,586評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,827評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,608評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,914評論 2 372

推薦閱讀更多精彩內容