Java虛擬機知識整理——類加載的過程

接下來詳細說明Java虛擬機中類加載的全過程,也就是加載、驗證、準備、解析和初始化這5個階段所執行的具體動作。

加載

加載是類加載過程的一個階段,需要注意分清楚。在加載階段,虛擬機需要完成以下3件事情:

  1. 通過一個類的全限定明來獲取定義此類的二進制字節流。
  2. 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  3. 在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。
    虛擬機規範的這3點要求其實並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的。例如“通過一個類的全限定名來獲取定義此類的二進制字節流”這條,它沒有指定二進制字節流要聰一個Class文件中獲取,準確的說是根本沒有指明要從哪裏獲取、怎樣獲取。虛擬機設計團隊在類加載階段搭建了一個相當開放的,廣闊的“舞臺”,Java發展里程中,充滿創造力的開發人員則在這個“舞臺”上玩出了各種花樣,許多劇組輕重的Java技術鬥建立在這一基礎之上,例如:
    • 從ZIP包中讀取,這很常見,最終成爲日後JAR、EAR、WAR格式的基礎。
    • 從網絡中獲取,這種場景最典型的應用就是Applet
    • 運行時計算生成,這種場景使用得最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generactor.generateProxyClass來爲特定接口生成形式爲“*Proxy”的代理類的二進制字節流。
      -由其他文件生成,典型場景是JSP應用,即用JSP文件生成相應的Class類。
    • 從數據庫中讀取,這種場景相對少見寫,例如有些中間件服務器(如SAPNetweaver)可以選擇吧程序安裝到數據庫中來完成程序代碼在集羣間的分支。
      ······ 還有很多
      相對於類加載過程的其他階段,一個非數組類的加載階段(準確地說,是加載階段中獲取類的二進制字節流的動作)是開放人員可控制性最強的,因爲加載階段既可以使用系統提供的引導類加載器來完成,也可以由用戶自動以個類加載器去完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式。
      對於數組而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接創建的。但數組類與類加載器仍然有很密切的關係,因爲書族類的元素類型最終是要靠類加載器去創建,一個數組類創建過程就遵循以下原則:
    • 如果數組的組件類型是應用類型,那就遞歸採用本文定義的加載過程去加載這個組件類型,數組C將在加載該組件類型的累加再起的雷明臣空間上被標識
    • 如果數組的組件類型不是引用類型,Java虛擬機將會把數組C標記爲與引導類加載器關聯。
    • 數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認爲public
      類加載階段完成後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規範未規定此區域的具體數據結構。然後在內存中實例化一個java.lang.Class類的對象,(並沒有明確規定是在Java堆中)將這個對象將作爲程序訪問方法區中的這類數據的外部接口。
      加載階段的內容與連接階段的部分內容是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於鏈接階段的內容,兩個階段的開始實踐仍然保持這固定的先後順序。

驗證

驗證是鏈接階段的第一步,這一階段的目的是爲了確保Class文件字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
Java語言本身就是相對安全的語言(依然是相對於C/C++來說),使用純粹的Java代碼無法做到諸如訪問數組邊界意外的數據、將一個對象轉型爲它並未實現的類型、跳轉到不存在的代碼之類的事情,如果這樣做了,編譯器將拒絕編譯。但之前說過Class文件並不一定要求用Java源碼編譯而來,可以使用任何途徑產生,甚至暴扣用十六進制編譯器直接編寫來產生Class文件。在字節碼語言層面上,上述Java代碼無法做到的事情都是可以實現的,至少語義上是可以表達出來的。虛擬機如果不檢查輸入的字節流,對齊完全信任的話,很可能會因爲載入了有害的字節流而導致系統崩潰,所以驗證是虛擬機對自身保護的一項重要工作。
驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機是否能承受惡意代碼的攻擊,從執行性能的角度上講,驗證階段的工作量在虛擬機的類加載系統之中又佔了相當大的一部分。驗證階段大致上會完成下面4個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。
1. 文件格式驗證
第一階段要驗證字節流是否符號Class文件格式的規範,並且能被當前版本的虛擬機處理,這一階段可能包括下面這些驗證點:

  • 是否以魔術0xCAFFBABE開頭
  • 主、次版本號是否存在當前虛擬機處理範圍之內。
  • 常量池中的常量是否有不被支持的常量類型(檢查常量tag標誌)。
  • 指向常量的各種索引值能否不被支持的常量類型。
  • 指向常量的各種索引值是否有指向不存在的常量或不符合類型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據。
  • Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
    ······
    實際上第一階段的驗證點遠不止上面這些,這些只是一小部分內容,該驗證階段的主要目的是確保輸入的字節流能正確地解析並存儲於方法區之內,格式上符合描述一個Java類型信息的要求。該階段的驗證是基於二進制字節流進行的,只有通過了這個階段的驗證後,字節流纔會進入內存的方法去進行存儲,所以後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作字節流。

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

      • 這個類是否有父類(除了java.lang.Object之外,所有的類鬥應該有父類)。
      • 這個類的父類是否集成了不允許被繼承的類(被final修飾的類)。
      • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
      • 類中的字段,方法,是否與父類產生的矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。
        ·····
        第二階段的主要木墊是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規範的元數據信息。
    2. 字節碼驗證
      第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的,符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗後,這個階段將對類的方法進行校驗分析,保證被校驗類的方法在運行時不會作出危害虛擬機安全的事件。例如
      • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧防止了一個int類型的數據,使用時卻按long類型來加載入本地變量表中。
      • 保證跳轉指令不會跳轉到方法以外的字節碼指令上
      • 保證方法體中的類型轉換時有效的,例如可以吧一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給它毫無繼承關係、完全不相干的一個數據類型,則是危險和不合法的。

····
如果一個類方法體的字節碼沒有通過字節碼驗證,那肯定是有問題的;如果一個方法體通過了字節碼驗證,也不能說明其一定是安全的。及時字節碼驗證之中進行了大量的檢查,也不能保證這一點。這裏涉及離散數學中一個很著名的問題“Halting Problem”;通俗一點的說法就是,通過程序去教研程序邏輯上是無法做到絕對準確的——不能通過程序準確的檢查出程序是否能在有限的時間內結束運行。
由於數據流驗證的高度複雜性,虛擬機設計團隊爲了避免過多的時間消耗在字節碼驗證階段,在JDK1.6之後的Javac編譯器和Java虛擬機中進行了一項優化,給方法體的Code屬性的屬性表中增加了一項名爲“StackMapTable”的屬性,這項屬性描述了方法體中所有的基本塊開始時本地變量表和操作棧應有的狀態,在字節碼驗證期間,就不需要根據程序推導這些狀態的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可。這樣字節碼驗證的類型校驗轉變爲類型檢查從而節省一些時間。
4. 符號引用驗證
最後一個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看做是對類自身以外的信息進行匹配性校驗,通常需要校驗一下內容:
- 符號引用中通過字符串描述的全限定名是否能找到對應的類。
- 在制定類中能否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
- 符號引用中的類、字段、方法的訪問性是否可被當前類訪問。

······
符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會拋出一個java.lang.IncomoatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
對於虛擬機的類加載機制來說,驗證階段是一個非常重要的、但是不一定必要的階段(因爲對程序運行期沒有影響)。而且一些虛擬機中可以關閉大部分的類驗證機制。

準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內從都講在方法區中進行分配。這個階段中有兩個容易產生混淆的概念:首先,這個時候進行內存分配的僅包括類變量,兒不包括實例變量,實例變量講會在對象實例化時隨着對象一起分配在Java堆中;其次,這裏所說的初始值“通常情況”下是數據類型的零值(及時一個變量public static int value =123,那麼value在準備階段後的初始值是0而不是123)。因爲這個時候尚未開始執行任何Java方法,而把value賦值的putstatic指令是程序編譯後,存放與構造器< clinit>()方法之中,所以把value賦值的動作將在出事戶階段纔會執行。
相對應的“特殊情況”:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量value就會被初始化爲ConstantValue屬性所指定的值。而給變量添加ConstantValue屬性,其中有一個方法就是final關鍵字修飾。

解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,在解析階段中所說的直接引用與符號引用的聯繫如下
- 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用是能無歧義地定位目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定已經加載到內存中。各種虛擬機實現的內存佈局可以各不相同,但是它們能接受的符號引用都是一致的,因爲符號引用的字面量形式明確定義在Java虛擬機規範Class文件格式中。
- 直接引用:直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。

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

初始化

類初始化階段是類加載過程的最後一部,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼。
在準備階段,便用已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源。換而言之:初始化階段是執行類構造器< clinit>()的過程。

  • < clinit>()方法是有編譯器自動收集類中所有類變量的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順訓所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。
  • < clinit>()方法與類的構造函數不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類的< clinit>()方法執行之前,父類的< clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的< clinit>()方法的類肯定是java.lang.Object。
  • 由於父類的< clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。
  • < clinit>()方法對於類或者接口來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成< clinit>()方法。
  • 接口中不能使用靜態語句塊,但讓然有變量初始化的複製操作,因此接口與類一樣都會生成< clinit>()方法。但接口與類不同的是,執行接口的< clinit>()方法不需要先執行父類的< clinit>()方法,其他縣城都需要阻塞等待,知道活動線程執行< clinit>()方法完畢。如果在一個類的< clinit>()方法中有耗時很長的操作,就可能造成很多進程阻塞,在實際應用中這種阻塞往往是很隱蔽。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章