JVM筆記:Java虛擬機的類加載機制

前言

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

  • 類加載的流程 類從被加載到虛擬機內存中開始,到卸載出內存位置,它的整個生命週期包括:加載、驗證、準備、解析、初始化、使用和卸載,其中驗證、準備、解析三個部分統稱為連接。這七個階段的發生順序如圖1-1所示。
JVM筆記:Java虛擬機的類加載機制

上圖中,加載、驗證、準備、初始化和卸載這5個階段的順序是固定的,類的加載過程必須按照這種順序按部就班地開始,但是解析階段則不一定:他在否種情況下可以再初始化階段之後再開始,這是為了支持Java語言的運行時綁定。同事,上面這是階段通常都是互相交叉地混合進行的,通常會在一個階段執行的過程中調用、激活另一個階段(例如在一個類的內部初始化另一個類)。

  • 類加載的時機 什麼情況下需要開始類加載過程的第一個階段:加載?Java虛擬機規範中並沒有進行強制約束,這點交給虛擬機的具體實現來自由把握。但是對於初始化階段,虛擬機規範則是嚴格規定了有且只有5中情況必須立即對類進行初始化(加載、驗證、準備自然需要在此之前開始)。 遇到new 、getstatic、putstatic、invokestatic這四條字節碼指令時,如果類沒有進行國儲石化,則需要先觸發其初始化。生成這四條指令的場景是:使用new關鍵字實例化對象,讀取或這隻一個類的靜態變量(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有經過初始化,則需要先觸發其初始化 當初始化一個類的時候,如果其父類還沒有經過初始化,則需要先觸發其父類的初始化。 虛擬機啟動時,用戶需要制定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。 當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有經過初始化,則需要先觸發其初始化。 對於以上5種會觸發類進行初始化的場景,虛擬機規範中使用了一個很強烈的現定於:有且只有,這5種場景中的行為被稱為對一個類進行主動引用,但是除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用,如下面例子:

對於靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化,至於是否要觸發子類的加載和驗證,在虛擬機規範中並未明確規定,這點取決於虛擬機的具體實現,對於Sun HotSpot虛擬機來說,可通過-XX:_TraceClassLoading參數觀察到次操作會導致子類的加載。

除此之外,通過數組定義來引用類,不會觸發此類的初始化。

運行上述代碼後什麼輸出也沒有,說明並沒有觸發Parent類的初始化階段。但是這段代碼裡面觸發了另一個名為[Lxxx.xxx.Parent(前面的xxx指代類的包名)的類的初始化,這裡是不是看起來有點眼熟,在前面字節碼的文章裡可以知道[L這裡表示的是一個對象數組。它是由虛擬機自動生成的、直接繼承與Object的類,創建動作由字節碼指令newarray觸發。 這個類表示了一個元素類型為Parent的一維數組,數組中應有的屬性和方法(可被用戶直接調用的方法只有length和clone)都實現在這個類裡。在Java語言中,當檢查到數組越界時會拋出ArrayIndexOutOfBoundsException異常,但是這個異常檢測不是封裝在數組元素訪問的類中,而是封裝在數組訪問的xaload、xastore字節碼指令中。

當引用一個類的靜態且被final修飾的常量時,不會觸發此類的初始化

因為作為final修飾的常量時一個不可變的值,所以在編譯階段會通過常量傳播優化,將此常量的值1存儲到了主類(main方法所在的類)的常量池中,所以以後主類中對常量1的引用實際都被轉化了主類對自身常量池的引用,也就是說,實際上主類的Class文件中並沒有Parent類得符號引用,這兩個類在編異常Class之後就不存在任何聯繫了。

接口的架子啊過程與類加載過程稍有不同,針對接口需要做一些特殊說明:接口也有初始化過程,這點和類是一致的,但是接口中不能使用static{}語句塊,但是編譯器仍然會為接口生成類構造器,用於初始化接口中所定義的成員變量。接口與類正則有所區別的是前面講述的需要初始化場景的第三種:當一個類在初始化時,要求其父類全部都已經初始化過了。但是一個接口在初始化時,並不要求其負藉口全部都完成了初始化,只有在真正使用到負藉口的時候(如引用接口中定義的常量)才會被初始化。

  • 類加載的步驟

接下來詳細講解一下類加載的全過程,也就是加載、驗證、準備、解析、初始化這5個階段鎖執行的具體動作。

  • 加載

加載是類加載過程的一個階段,在加載階段,虛擬機主要完成一下三件事

  • 1.通過一個類的全限定名來獲取定義此類的二進制字節流。
  • 2.將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
  • 3.在內存中生成一個代表這個類的Class對象,作為方法區這個類得各種數據的訪問入口。

對於類加載過程的其他階段,一個非數組的加載階段(準確的說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的,因為加載階段可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器去完成(例如對字節碼加密,然後通過自定義類加載器來解密後加載類),開發人員可以通過定義自己的類加載器去控制字節流的獲取方式。

但是數組類並不是通過類加載器創建的,它是由Java虛擬機直接創建的。不過數據類型與類加載器仍然有很密切的關係,因為數組類的元素類型最終還是要考類加載器去創建,一個數組類的創建過程就遵循以下規則:

  • 1 . 如果數組的類型時一個引用類型,那就需要去加載這個組件類型,然後在加載該組件類型的類加載器的類名稱空間上被標識,這一點在後續的類加載器中會講述到。
  • 2 . 如果數組的類型時基礎數據類型,Java虛擬機會把數組標記為與引導類加載器關聯。
  • 3 . 數組類的可見性與它的組件類型可見性一致,如果組件類型不是引用類型,那數組的可見性將默認為public。

加載階段完成後,虛擬機將外部的二進制字節流按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機自行定義,然後在內存中實例化一個Class類的對象(並沒有明確是在Java堆中,對於HotSpot虛擬機而言mClass對象比較特殊,他雖然是對象,但是存放在方法區裡面),這個對象將作為程序訪問方法區中的這些類型數據的外部接口。

加載階段和後續的連接階段的部分內容是交叉進行的,加載階段尚未完成時,連接階段可能已經開始了,但是這些夾在加載階段的動作仍然屬於連接階段。

  • 驗證 驗證是連接階段的第一部,這一步的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。虛擬機如果不檢查輸入的字節流,對其完全信任的話,很可能會因為載入了有害的字節流而導致系統崩潰,所以驗證是虛擬機對自身保護的一項重要工作。 從2011年發佈的《Java虛擬機規範(JSE 7版)》中從整體上上看,研製階段大致上會完成下面4個階段的校驗動作:文件格式校驗、元數據校驗、字節碼校驗、符號引用驗證。
  • 1 . 文件格式驗證 驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理,可能包含下面這些驗證點: 是否以魔數0xCAFEBABY開頭。 主次版本號是否在當前虛擬機處理範圍之內。 常量池的常量中是否有不被支持的常量類型(檢查常量的tag標誌)。 指向常量的各種索引值是否有指向不存在的常量或不符合類型的常量。 CONSTANT_Utf8_info類型的常量中是否有不符合UTF8編碼的數據。 Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。 ......

上面只是驗證的一小部分點,目的是包在輸入的字節流能正確地解析並且格式上符合一個Java類型的數據要求。只有通過這個階段的兗州,字節流才會進入內存的方法區進行存儲,後面的三個驗證階段全部是基於方法取得存儲結構進行的,不會再直接操作字節流。

  • 2 . 元數據驗證 第二步是對字節碼描述的信息進行語義分析,保證其描述的信息符合Java語言規範的要求,這個階段的包含的驗證點如下: 這個類是否有父類(除了Object,所有的類都應該有父類)。 這個類的父類是否繼承了不被允許繼承的類(被final修飾的類)。 如果這個類不是抽象類,是否實現了其父類或接口中的要求實現的所有方法。 類中的字段、方法是否和父類產生了矛盾(例如覆蓋了父類的final字段)。 ......
  • 3 .字節碼驗證 這是驗證過程中最複雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。交驗完元數據後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件,例如: 操作數棧的數據類型和指令代碼序列能配合工作,例如不會出現操作數棧存入了int類型數據,加載時卻用long類型。 保證跳轉指令(goto)不會跳轉到方法體以外的字節碼指令上。 保證方法體中的類型轉換時有效的。 ...... 如果一個類方法體沒通過校驗,那肯定是有問題的,但是通過了校驗也不一定是完全安全的,即通過程序去校驗程序邏輯是無法做到絕對準確的。 虛擬機設計團隊為了避免過多的時間消耗在字節碼校驗階段,在JDK1.6之後Javac虛擬機中進行了一項優化,給方法體的Code屬性的屬性表中增加了一項名為StackMapTable的屬性,這項屬性描述了方法體中所有的基本虧啊開始時本地變量表和操作數棧應有的狀態,字節碼校驗期間,就不需要根據程序推導這些狀態的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可,這樣將字節碼驗證的類型推導轉換為類型檢查,從而節省一些時間。
  • 4 .符號引用驗證 最後一個階段校驗發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段解析中發生,符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗,同樣需要校驗下列內容: 符號引用中通過字符串描述的全限定名是否能找到對應的類。 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。 符號引用中的類、字段、方法的訪問類型是否可被當前類訪問。 ...... 符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用,那麼會拋出一個IncompatibleClassChangeError異常的子類,例如NoSuchField(Method)Error。 對於虛擬機來說,驗證階段是一個重要,但不是必要的階段,如果你的代碼已經被反覆使用和驗證過了,那麼在實施階段就可以考慮用-Xverify:none參數來關閉大部分的類驗證措施,以縮短類加載的時間。
  • 準備 準備階段是正式為類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段有兩個容易混淆的概念需要強調一下:首先,這個時候進行內存分配的僅包含類變量(static變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中;其次,這裡所說的初始值他那個場情況下是數據類型的零值。

上面例子中number在準備階段後的初始值為0而不是1,因為這個時候尚未開始執行仍和Java方法,而把number賦值為1的putstatic指令時程序被編譯後,存放於類構造器()方法之中,所以把number賦值為1的動作將在初始化階段才會執行。

但是在特殊情況下,如果類字段的字段屬性表中存在ConstantValue屬性(被final修飾),那在準備階段變量numberFinal就會被初始化為指定的值。編譯時Javac將會為numberFinal生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將值設為123。

  • 解析 解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,符號引用在JVM筆記:Java虛擬機的常量池提到過很多次了,在Class文件中他以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所說的直接引用和符號引用又有什麼關聯呢? 符號引用(SymbolicReferences):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義地定位到目標即可。但是引用的目標並不一定已經加載到內存中,它在很多情況下類似一個佔位符,表示將來需要指向這麼一個內容,然後在後續階段將其替換為直接引用。各種虛擬機所能接受的符號引用必須是一致的,沒因為符號引用的字面量形式明確定義在Java虛擬機規範的Class文件格式中。
    直接引用(SymbolicReferences):直接引用可以是直接指向目標的指針、相對偏移量或一個能簡介定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。 虛擬機規範中並未規定解析階段發生的具體時間,只要求了在執行anewarray、multianewarray、checkcast、getfield、getstatic、instanceof、invoke(dynamic,interfance,special,static,virtual)、ldc、ldc_w、new、putfield、putstatic這16個字節碼之前,先對他們所使用的符號引用進行解析。所以虛擬機實現可以根據需要來判斷到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。 除了invokedynamic指令以外,虛擬機實現了對第一次解析的結果進行緩存,在運行時常量池中記錄直接引用,並把常量標識為已解析狀態,從而避免解析動作重複,如果一個符號引用解析成功或失敗,那麼後續對其的引用解析也應該收到成功或者異常告知。 對於invokedynamic指令,當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味著這個解析結果對於其他invokedynamic指令也同樣生效。因為invokedynamic指令的目的本來就是用於動態語言支持,它所對應的引用稱為動態調用點限定符,這裡動態的含義就是必須等到程序運行到這條指令的時候,解析動作才能進行。相對的,其餘可觸發解析的指令都是靜態的,即可以在剛剛完成加載階段,還沒有開始執行代碼時就開始進行解析。
    解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行,這裡主要介紹前面4種,後面三種與JDK新增的動態語言支持息息相關,暫時這裡不多做贅述,前面三種分別對於常量池的CONSTANT_(Class、Fieldref、Methodref、InterfaceMethodref)_info。 1 . 類或接口的解析 假設在類W要把一個從未解析過的符號引用N解析為一個類或接口O的直接引用,那虛擬機完成整個過程主要分為以下三個步驟。 如果O不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給W的類加載器中去加載這個類O。在加載過程中,由於元數據驗證,字節碼驗證的需要,有可能觸發其他相關類的加載動作,一旦這個加載過程出現了異常,解析過程就宣告失敗。 如果O是一個數組類型,並且數組類型為對象(描述符為[Lxxx/xxx),那將會按照上面的規則加載數組元素類型,如果N的描述符如前面鎖假設的形式,那麼就會加載該元素類型的對象,接著由虛擬機生成一個代表此數組維度和元素的數組對象。 如果上面兩步沒有出現異常,那麼在c虛擬機中實際上已經成為一個有效的類或接口了,但在解析完成前還要進行符號引用驗證,確認W是否具備對O的訪問權限,如果發現不具備訪問權限,將拋出IlleagalAccessError異常。 2 . 字段解析
    解析一個未被解析過得字段符號引用,首先將會對字段表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類和接口的符號引用,也就是說,欲解字段,必先解其所在類。 解析完類後,如果類本身包含了簡單名稱和字段描述符都與目標匹配的字段,則直接返回該字段的直接引用 如果該類實現了接口,將會按照繼承關係遞歸搜索各個接口和他的父接口,如果接口中包含了簡單名稱和字段描述符都與目標匹配的字段,則直接返回該字段的直接引用。 如果該類不是Object的話,將會按照繼承關係遞歸搜索其父類,如果父類中包含了簡單名稱和字段描述符都與目標匹配的字段,則直接返回該字段的直接引用。 如果以上步驟都失敗,那麼拋出NoSuchFieldError異常。 同樣的如果不具備對返回的字段引用的訪問權限,拋出IlleagalAccessError異常。 如果一個同名字段同時出現在類的接口和父類中,或者在自己父類的多個接口中出現,那麼編譯器將可能拒絕編譯。 3 . 類方法解析 類方法解析第一個步驟和字段解析一樣,也需要先解析出該方法所在的類。然後按照下面步驟進行後續的類方法搜索。 1)類方法和接口方法符號引用的常量類型定義是分開的(一個是Methodref,一個是InterfaceMethodref),如果類方法表中發現索引的是一個接口,那麼會拋出IncompatibleClassChangeError異常。 2)如果通過第一步,接著在類中查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。 3)否則,在類的父類中遞歸查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。 4)否則,在類實現的接口列表和他們的父接口中遞歸查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,如果存在,說明該類是一個抽象類(如果不是抽象類,該類中會查找到這個方法),這時候拋出AbstractMethodError異常。 5)以上步驟都不行,拋出NoSuchMethodError異常。 6)同樣的如果不具備對返回的方法引用的訪問權限,拋出IlleagalAccessError異常。
    4 . 類方法解析 老樣子,接口方法也需要先解析出接口方法表class_info想中索引的方法所屬的類或接口的符號引用。然後按照下面步驟進行後續的接口方法搜索。 1)與類方法解析相反,如果在接口方法表中發現該接口所對應的是一個類而不是接口,拋出IncompatibleClassChangeError異常。 2)如果通過第一步,接著在接口中查找是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。 3)否則,在接口的父接口中遞歸查找,直到Object類為止,看是否包含了簡單名稱和字段描述符都與目標匹配的方法,則直接返回該方法的直接引用。 4)以上步驟都不行,拋出NoSuchMethodError異常。 5)因為接口方法默認都是public的沒所以不存在訪問權限,所以接口方法不會拋出IlleagalAccessError異常。
  • 初始化 類初始化時類加載過程的最後一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說字節碼)。 在準備階段,變量已經賦值過一次系統要求的初始值,而在初始化階段,則根據程序制定的計劃去初始化類變量和其他資源,從另一個角度來表達:初始化階段是指向類構造器()方法的過程。
    ()方法是由編譯器自動手機類中所有的類變量(static變量)的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量嗎,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問

上面代碼中可以在代碼塊中對a進行賦值,但是沒啥作用,因為會被後面的a重新賦值為1,而且代碼塊內不能調用下面的類變量,會顯示illeagal forward reference錯誤

()方法與類的構造方法,也就是實例構造器 ()不同,它不需要顯示地調用它父類構造器,虛擬機會保證在子類的 ()方法執行之前,父類的 ()方法已經執行完畢,也就是說,父類中定義的靜態語句塊要由於子類的變量賦值操作,因此在虛擬機中第一個被執行的 ()方法的類肯定是Object。

下面例子中輸出的結果就是2,因為父類的靜態賦值操作比子類先執行

()方法不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那麼編譯器可以不為這個類生成 ()方法。

接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口和類一樣都會生成 ()方法方法,但接口與類不同的是,魔之心接口的 ()方法不需要先執行父接口的 ()方法,只有當父接口中定義的變量使用時,父接口才會初始化,另外,接口的實現類在初始化時也不會執行接口的 ()方法。

虛擬機會保證一個類的 ()方法在多線程環境中被正確的加鎖,用不,如果多個線程同時去初始化一個類,那麼只有一個線程回去執行這個類 ()方法,其他線程都需要阻塞等待,這也是靜態單例實現的原理。


分享到:


相關文章: