深入理解Java虛擬機器 - 類載入機制

beyondlicg發表於2018-03-13

程式碼編譯的結果從本地機器碼轉換為位元組碼,是儲存格式發展的一小步,卻是程式語言發展的一大步。

概述

虛擬機器把描述一個類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器使用的Java型別,這就是虛擬機器的類載入機制。

類載入的時機

類載入的整個生命週期包括:載入、驗證、準備、解析、初始化、使用、解除安裝7個過程,其中驗證、準備和解析統稱為連線。
虛擬機器沒有對什麼時候進行類的載入有強制約束,但是對於初始化階段,虛擬機器規範則是嚴格規定了有且只有5中情況必須立即對類進行初始化(載入、驗證、準備和初始化自然得在初始化前完成):

  1. 遇到new、getstatic、putstatic和invokestatic這四條位元組碼指令時,如果類沒有進行過初始化,則需要觸發其初始化(初始化自然存在類的載入)。這四條指令最常見的場景:使用new關鍵字例項化物件、獲取或設定一個類的靜態欄位(被final修飾的除外)的時候和使用一個類的靜態方法時。
  2. 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果沒有對類進行過初始化,則觸發初始化。
  3. 當初始化一個子類時,發現其父類沒有初始化時,需先觸發父類的初始化。
  4. 當虛擬機器啟動時,使用者需要指定一個要執行的主類(含有main方法的類)時,虛擬機器會先初始化這個類。
  5. 當使用JDK1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化,則需要先觸發其初始化。

類載入的過程

載入

類載入階段,虛擬機器需要完成以下3件事件:

  1. 通過一個類的全限定名獲取定義該類的二進位制位元組流
  2. 將位元組流代表的靜態儲存結構轉換為方法區的執行時資料結構
  3. 在方法區中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口

一個非陣列類的載入,既可以使用虛擬機器提供的引導類載入器來完成,也可以自定義類載入器完成(即重寫一個類載入器的loadClass方法)。對於陣列類而言,情況有所不一樣,陣列類本身不通過類載入器建立,而是由虛擬機器自己建立。

驗證

驗證是連線階段的第一步,目的是保證Class檔案的位元組流包含的資訊符合當前虛擬機器的要求,保證輸入的位元組流能正確被解析並儲存於方法區。驗證階段主要包括以下4個階段:檔案格式驗證、後設資料驗證、位元組碼驗證、符號引用驗證。

  • 檔案格式驗證
    第一階段驗證位元組流是否符合Class檔案的格式規範,並且能當前被虛擬機器處理。這一階段主要驗證點:
  1. 是否以魔數開頭
  2. 版本是否在本機虛擬機器處理範圍內
  3. 指向常量的各種索引值是否有指向不存在的常量
  4. ......
  • 後設資料驗證
    第二階段主要是對類的後設資料資訊進行語義分析,保證不存在不符合Java語言規範的後設資料。驗證點有:
  1. 這個類是否有父類
  2. 這個類是否繼承了不允許被繼承的類(被final修飾的類)
  3. 如果這個類不是抽象類,是否實現了其父類或繼承的介面要求實現的方法
  4. ......
  • 位元組碼驗證
    第三個階段是驗證過程中最複雜的階段,主要目的是通過資料流和控制流分析程式語義是否合法。這個階段對類的方法體進行校驗,保證被校驗方法執行時不會危害虛擬機器:
  1. 保證跳轉指令不會跳轉到方法體以外的位元組碼指令
  2. 保證方法體中的型別轉換時是正確的
  3. ......
  • 符號引用驗證
    最後一個階段發生在虛擬機器將符號引用轉換為直接引用的時候,這個轉換動作發生在連線的第三個階段 - 解析。符號引用可以看做是對常量池中各種符號引用進行校驗,驗證點有:
  1. 符號引用中通過字串描述的全限定名是否找到對應的類或介面
  2. 符號引用中的類、欄位、方法的訪問性是否可被當前類訪問
  3. ......

準備

準備階段是正式為類變數(被static修飾的變數,不包括例項變數)分配記憶體並設定類變數初始值的階段,這些記憶體在方法區進行分配。還有,這裡所說的初始值通常情況下是指資料型別的零值

public static int value = 123
複製程式碼

value變數在準備階段後的初始值為0,而不是123,因為這個時候並未開始執行任何java方法,而把value賦值為123的putstatic指令時程式被編譯後,存放於類構造器方法中的,所以把value賦值為123的操作是在初始化階段才執行的。
上面提到,通常情況下是資料型別的零值,但是有一些特殊情況就不一樣:如果類變數被final修飾,在準備階段 ,類變數就會被初始化為指定的值

public static final int value = 123;
複製程式碼

在準備階段,value的值就會被賦值為123.

解析

解析階段就是虛擬機器將常量池內的符號引用替換為直接引用的過程。虛擬機器規範並未對什麼時候進行解析階段有規定,只要求了**在執行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokeestatic、involevirtual、ldc、ldc_w、multianewarray、new、putstatic和putfield這16個用於操作符號引用的位元組碼指令之前,先對他們所使用的符號引用進行解析 **。所以虛擬機器可以根據需要來判斷是在類被載入器載入時就對符號引用進行解析或是在符號引用在被使用前才去解析。
解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行解析。

  • 類或介面的解析
    假設當前程式碼所處的類為D,如果想把一個從未解析過的符號引用N解析到一個類或介面C的直接引用,虛擬機器完成整個解析階段的過程分為以下3步:
  1. 如果C是不是一個陣列型別,虛擬機器將會把符號引用N的全項定類名傳遞給D的類載入器去載入這個類C。在載入的過程中由於需要驗證,可能又會觸發其他類的載入,一當載入過程出現錯誤,解析過程直接失敗。
  2. 如果C是一個陣列型別,陣列元素也是物件型別的話,N的描述符將會是類似[Ljava/lang/Integer的形式。那將會按照第一點的規則載入陣列元素型別,接著由虛擬機器生成一個代表此陣列維度和元素的陣列物件
  3. 如果前面的步驟都沒有出現錯誤,在解析完成前還需要進行符號引用的驗證,確認D是否具備對C的訪問許可權,如果D沒有對C的訪問許可權,丟擲java.lang.IllegalAccessEroor異常。
  • 欄位解析
    要解析一個未被解析過的欄位的符號引用。首先會對欄位表內的class_index項索引的CONSTANT_Class_info符號引用解析,也就是欄位所屬的類或介面的符號引用。如果在解析這個類或介面的符號引用出現異常,都會導致欄位解析的失敗。如果這個類或介面解析成功,將對這個欄位所屬的類或介面用C表示,然後對C進行後續的欄位搜尋:
  1. 如果C本身就包含了簡單名稱和欄位描述符都與目標欄位相同的欄位,則返回這個欄位的 直接引用,查詢結束
  2. 否則,如果在C中實現了介面,將會按照繼承關係從下往上遞迴搜尋每個介面和它的父介面,然後按照步驟1去查詢
  3. 否則,如果C不是object類的話,按照繼承關係從下往上遞迴搜尋其父類,然後按照步驟1去查詢
  4. 否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常。
  • 類方法解析
    類方法的解析的第一個步驟與欄位解析一樣,也需要先解析出類方法表的claaa_index索引的方法所屬類或介面的符號引用,如果解析成功,用C表示這個類,接下來虛擬機器按照以下步驟進行類方法的搜尋:
  1. 在類C中查詢是否有簡單名稱和描述符都與目標匹配的方法,如果有返回這個方法的直接引用,查詢結束
  2. 否則在類C的父類中遞迴查詢
  3. 否則在類C的介面或父介面中查詢
  4. 否則查詢失敗,丟擲java.lang.NoSuchMethodError異常。
  • 介面方法解析
    介面方法解析與類方法解析類方法解析類似,這裡不再冗餘。

初始化

初始化階段是類載入過程的最後一步。在前面的類載入過程中,除了在載入階段可以自動定義載入器參與類的載入過程外,其餘的動作完全由虛擬機器主導和控制。到了初始化階段,才真正開始執行類中定義的Java程式碼
在準備階段,變數已經被賦值為系統要求的零值,而在初始化階段,則根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源。或者說初始化階段是執行類構造器方法的過程。

  1. 方法是由編譯器自動收集類中的所有類變數的複製操作和靜態語句塊(static{})中的所有語句合併而生的。靜態語句塊只能訪問到定義在靜態語句前的變數,定義在它之後的變數,只能在靜態語句塊中賦值而不能訪問。
  2. 方法與類的建構函式不同,它不需要顯示地呼叫父類構造器,虛擬機器會保證在子類的方法執行前執行父類的方法。
  3. 方法並不是必須的,如果一個類沒有靜態語句塊,也沒有變數的賦值操作,編譯器可以不為這個類生成方法。
  4. 虛擬機器會保證一個類的方法在多執行緒環境下被正確地加鎖、同步,如果多執行緒同時去初始化一個類,只會有一個執行緒執行方法。

類與類載入器

虛擬機器設計團隊把類載入階段的通過一個類的全限定名獲取此類的二進位制位元組流這個動作放到Java虛擬機器外部實現,以便讓開發人員自己決定如何獲取所需要的類,實現這個動作的程式碼模組稱為“類載入器”。
對於任意一個類,都需要載入它的類載入器和這個類本身一同確定其所在虛擬機器的唯一性。通俗地說,比較兩個類是否相等,只有在相同的類載入器的前提下才有意義,否則,即使這兩個類來自於同一個Class檔案,被同一個虛擬機器載入,只要類載入器不一樣,這兩個類就不可能相等。

雙親委派模型

從虛擬機器的角度來講,只存在兩種不同的類載入器:一種是啟動類載入器,是虛擬機器的一部分;另一種是其他的類載入器,獨立於虛擬機器之外,而且全都繼承於抽象類java.lang.ClassLoader.
從開發人員的角度來看,絕大部分java程式都會使用到以下3種系統提供的類載入器:

  • 啟動類載入器
    這個類負責將放在<JAVA_HOME>\lib目錄下的並且被虛擬機器識別的(按照檔名識別,名字不符合的類庫即使放在lib目錄下也不會被載入)類庫載入到虛擬機器記憶體中。啟動類載入器無法被java程式直接引用。

  • 擴充類載入器
    它負責載入<JAVA_HOME>\lib\ext目錄下的所有類庫,開發者可以直接使用擴充類載入器

  • 引用程式類載入器
    它負責載入使用者類路徑(ClassPath)下所指定的類庫,開發者可以直接使用。如果程式中沒有自定義自己的類載入器,一般情況下這個就是程式預設的類載入器。

深入理解Java虛擬機器 - 類載入機制
雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應該有自己的父類載入器、但是這裡的類載入器之間的父子關係不是以繼承關係實現的,而是使用組合關係來複用父類載入器。

  • 雙親委派模型的工作流程:
    如果一個類載入器收到了一個類載入請求,它不會自己去載入這個類,而是將請求委派給它的父類載入器去載入,每一個層次的類載入器都是這樣,因此所有的類載入請求最終都會落到頂層的啟動類載入器,只有當父類載入器五法載入這個請求時(它的搜尋範圍中沒有找到所需的類),子載入器才會嘗試自己去載入。使用雙親委派的好處就是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。

相關文章