虛擬機類加載機制乾貨知識點

概述

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

與那些在編譯時需要進行連接工作的語言不同,在Java語言裏面,類型的加載、連接和初始化過程都是在程序運行期間完成的,這種策略雖然會令類加載時稍微增加一些性能開銷,但是會爲java應用程序提供高度的靈活性,在java裏天生可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的。

類加載的時機

圖片

加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是爲了支持java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。

對於加載,java虛擬機規範並沒有進行強制約束。但是對於初始化階段,虛擬機規範則進行了嚴格規定了有且僅有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):

  • 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令最常見的java代碼場景是:使用new關鍵字實例化對象的時候,讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
  • 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  • 當使用jdk1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

這5中場景中的行爲被稱爲對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱爲被動引用

被動引用的例子

圖片圖片

上述代碼只會輸出“SuperClass init!”。對於靜態字段,只有直接定義這個字段的類纔會被初始話,因此用過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。至於是否要觸發子類的加載和驗證,在虛擬機規範中並未明確規定,這點取決於虛擬機的具體實現。對於HotSpot虛擬機來說,可通過-XX:+TraceClassLoading參數觀察到此操作會導致子類的加載。圖片

運行之後沒有輸出“SuperClass init”,說明沒有觸發SuperClass的初始化階段。但是這段代碼裏面觸發了另外一個名爲“[Lorg.fenxisoft.classloading.SuperClass”的類的初始化階段,對於用戶代碼來說。這並不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承於java.lang.Object的子類,創建動作由字節碼指令newarray觸發。這個類代表了一個元素類型爲org.fenixsoft.classloading.SuperClass的一維數組,數組中應有的屬性和方法都實現在這個類裏。圖片

上述代碼運行之後,也沒有輸出“ConstClass init!”,這是因爲雖然在java源碼中引用了ConstClass類中的常量HELLOWORLD,但其實在編譯階段通過常量傳播優化,已經將此常量的值“hello world”存儲到了NotInitialization類的常量池中,以後NotInitialization對常量ConstClass.HELLOWORLD的引用實際都被轉化爲NotInitialization類自身常量池的引用了,也就是說,實際上NotInitialization的Class文件之中並沒有ConstClass類的符號引用入口,這兩個類在編譯成Class之後就不存在任何聯繫了。

注意:接口的加載過程與類加載過程稍有一些不同,針對接口需要做一些特殊說明。接口也有初始化過程,這點與類是一致的。接口與類真正有所區別的是前面講述的5種“有且僅有”需要初始化場景中的第3種:當一個類在初始化時,要求父類全部都已經初始化過了,但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有真正使用到父接口的時候(如引用接口中定義的常量)纔會初始化。

類加載的過程

1、加載

“加載”是“類加載”過程的一個階段。在加載階段,虛擬機需要完成以下3件事:

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

虛擬機規範的這3點要求並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的。例如“通過一個類的全限定名來獲取定義此類的二進制字節流”這條,他沒有指明二進制字節流要從一個Class文件中獲取,準確的說是根本沒有指明要從哪裏獲取、怎樣獲取。

  • 從ZIP包中讀取,這很常見,最終成爲日後JAR、EAR、WAR格式的基礎
  • 從網絡中獲取,這種場景最典型的應用就是Applet。
  • 運行時計算生成,這種場景使用的最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator。generateProxyClass來爲特定接口生成形式爲“ *$Proxy ”的代理類的二進制字節流。
  • 由其他文件生成,典型場景就是JSP應用
  • 從數據庫中讀取,這種場景相對少見,例如有些中間件服務器(如SAP Netweaver)可以選擇吧程序安裝到數據庫中來完成程序代碼在集羣間的分發

注意:對於數組內而言,數組類本身不通過類加載器創建,它是由java虛擬機直接創建的。但數組類與類加載器仍然有密切的關係,因爲數組內的元素類型(Element Type,指的是數組去掉所有維度的類型)最終是要靠類加載器去創建,一個數組類(下面簡稱爲C)創建過程就遵循以下規則:

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

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

2、驗證

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

2011年發佈的《Java虛擬機規範(Java SE 7版)》,大幅增加了描述驗證過程的篇幅。但從整體上看,驗證階段大致上會完成下面4個階段的校驗動作:

1.文件格式驗證

第一階段要驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理。

第一階段可能包括下面這些驗證點:

  • 是否以魔數開頭。
  • 主次版本號是否在當前虛擬機處理範圍之內
  • 常量池的常量中是否有不被支持的常量類型(檢查常量tag標誌)

該驗證階段的主要目的是保證輸入的字節流能正確的解析並存儲於方法區之內,格式上符合描述一個java類型信息的要求。這階段的驗證是基於二進制字節流進行的,只有通過了這個階段的驗證後,字節流纔會進入內存的方法區中進行存儲,所以後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作字節流。

2.元數據驗證

第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合java語言規範的要求,這個階段可能包括的驗證點如下:

  • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要實現的所有方法
  • … … …

3.字節碼驗證

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

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似在操作棧放置了一個int類型的數據,使用時卻按long類型來加載如本地變量表中
  • 保證跳轉指令不會跳轉到方法體以外的字節碼指令上
  • 保證方法體中的類型轉換是有效的
  • … … … …

注意:即使通過了字節碼驗證,也不能說明其一定就是安全的。這裏涉及離散數學中一個很著名的問題“Halting Problem”:通俗一點的說法就是,通過程序去校驗程序邏輯是無法做到絕對準確的——不能通過程序準確地檢查出程序是否能在有限的時間之內結束運行。

由於數據流驗證的高複雜性,虛擬機設計團隊爲了避免過多的時間消耗在字節碼校驗階段,在jdk1.6之後的javac編譯器和java虛擬機中進行了一項優化,給方法體的Code屬性的屬性表中增加了一項名爲**“StackMapTable”的屬性**,這項屬性描述了方法體中所有的基本塊(Basic Block,按照控制流拆分的代碼塊)開始時本地變量表和操作數棧應有的狀態,在字節碼驗證期間,就不需要更具程序推導這些狀態的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可。這樣字節碼驗證的類型推導轉變爲類型檢查而節省一些時間。

4.符號引用驗證

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

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

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

注意:對於虛擬機的類加載機制來說,驗證階段是一個非常重要的、但不是一定必要(因爲對程序運行期沒有影響)的階段。如果所運行的全部代碼(包括自己編寫的及第三方包中的代碼)都已經被反覆使用和驗證過,那麼在實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機加載的時間。

3、準備

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

注意:這個階段中有兩個容易產生混淆的概念需要強調一下。

  • 這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在java堆中。
  • 這裏所說的初始值“通常情況”下是數據的零值,假設一個類變量的定義爲:

public static int value = 123;那變量value在準備階段過後的初始值爲0而不是123,因爲這時候尚未開始執行任何java方 法,而value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器()方法之中,所以把value賦值爲123的動作將在初始化階段纔會執行。圖片

上面提到,在“通常情況”下初始值是零值,那相對的會有一些“特殊情況”:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化爲ConstantValue屬性所指定的值,假設上面類變量value的定義爲:

public static final int value = 123;

編譯時javac將會爲value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值爲123.

4、解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定已經加載到內存中。各種虛擬機實現的內存佈局可以各不相同,但是他們能接受的符號引用必須是一致的,因爲符號引用的字面量形式明確定義在java虛擬機規範的class文件格式中
  • 直接引用(Direct References):直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不同虛擬機實現上翻譯出來的直接引用一般不會相同。如果遊客直接引用,那引用的目標必定已經在內存中存在。

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

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_interfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7種常量類型。

對同一個符號引用進行多次解析請求是很常見的事情,除了invokedynamic指令以外,虛擬機實現可以對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用,並把常量標示爲已解析狀態)從而避免解析動作重複進行。無論是否真正執行了多次解析動作,虛擬機需要保證的是在同一個實體中,如果一個符號引用之前已經被成功解析過,那麼後續的引用解析請求就應當一直成功;同樣的,如果第一次解析失敗了,那麼其他指令對這個符號的解析請求也應該收到相同的異常。

對於invokedynamic指令,上面規則則不成立。當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味這這個解析結果對於其他invokedynamic指令也同樣生效。因爲invokedynamic指令的目的本來就是用於動態語言支持(目前僅使用java語言不會生成這條字節碼指令),它所對應的的引用稱爲**“動態調用點限定符”(Dynamic Call Site Specifer)**,這裏“動態”的含義就是必須等到程序實際運行到這條指令的時候,解析動作才能進行。相對的,其餘可觸發解析的指令都是“靜態”的,可以在剛剛完成加載階段、還沒有開始執行代碼時就進行解析。

5、初始化

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

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器()方法的過程。

  • ()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。圖片
  • ()方法與類的構造函數(或者說實例構造器()方法)不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類的()方法執行之前,父類的()方法已經執行完畢。因此在虛擬機中第一個被執行的()方法的類肯定是java.lang.Object。
  • 由於父類的()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。
  • ()方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成()方法。
  • 接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成()方法。但接口與類不同的是,執行接口的()方法不需要先執行父接口()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外。接口的實現類在初始化時也一樣不會執行接口的()方法。
  • 虛擬機會保證一個類的()方法在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的()方法,其他線程都需要阻塞等待,直到活動線程執行()方法完畢。如果在一個類的()方法中有耗時很長的操作,就可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的。

本文以上知識點摘自——《深入理解java虛擬機》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章