JVM學習筆記-第七章-虛擬機器類載入機制

7.1 概述

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

兩個約定:

  1. 後文直接對“型別”的描述都同時蘊含著類和介面的可能性
  2. 本章所提到的“Class檔案”也並非特指某個存在於具體磁碟中的檔案,而應當是一串二進位制位元組流。

7.2 類載入的時機

一個型別從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,它的整個生命週期將會經歷載入、驗證、準備、解析、初始化、使用、解除安裝。其中驗證、準備、解析三個部分稱為連線。載入、驗證、準備、初始化、解除安裝這個五個階段的順序是確定的。型別的載入過程必須按照這種順序按部就班地開始,而解析的階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結特性。

有且只有六種情況必須立即對類進行“初始化”:

  1. 遇到new、getstatic、putstatic、invokestatic這四條位元組碼指令時,如果型別沒有進行過初始化,則需要先觸發其初始化階段。可以生成這四條指令的典型Java程式碼場景有:使用new關鍵字例項化物件的時候、讀取或設定一個型別的靜態欄位的時候、呼叫一個型別的靜態方法的時候。
  2. 使用java.lang.reflect包的方法對型別進行反射呼叫的時候。
  3. 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要觸發其父類的初始化。
  4. 當虛擬機器啟動時,使用者需要制定一個要執行的主類,虛擬機器會先初始化這個主類。
  5. 但是用JDK 7新加入的動態語言支援時,如果一個MethodHandle例項最後的解析結果為REF_getstatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法控制代碼,並且這個方法控制代碼對應的類沒有進行過初始化,則需要先出發其初始化 。
  6. 當一個介面中定義了JDK 8新加入的預設方法(被default關鍵字修飾的介面方法)時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化。

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

介面中不能使用"static{ }"語句塊,但編譯器仍然會為介面生成"( )"類構造器,用於初始化介面中所定義的成員變數。於類不同的是觸發初始化場景中的第三條:當一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候才會初始化。


7.3 類載入的過程

7.3.1 載入

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

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

陣列類本身不通過類載入器建立,它是由Java虛擬機器直接在記憶體中動態構造出來的,但還是跟類載入器仍有很密切的關係,因為陣列類的元素型別最終還是通過類載入器完成載入的。

載入階段尚未完成,連線階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於連線階段的一部分。

7.3.2 驗證

驗證時連線階段的第一步,這個階段的目的是確保Class檔案的位元組流中包含的資訊符合了《Java虛擬機器規範》的全部約束要求。驗證階段大致上會完成下面四個階段的校驗動作:

1. 檔案格式驗證

第一階段要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。這階段的驗證時基於二進位制位元組流進行的,只有通過了這個階段之後,這段位元組流才被允許進入Java虛擬機器記憶體的方法區進行儲存,所以後面的三個驗證階段全部都是基於方法區的儲存結構上進行的,不會再直接讀取、操作位元組流了。

2. 元資料驗證

第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合《Java語言規範》的要求。主要目的是對類的元資料資訊進行語義校驗,保證不存在與《Java語言規範》的定義相悖的元資料資訊。

3. 位元組碼驗證

第三個階段是最複雜的一個階段,主要目的是通過資料流分析合控制流分析確定程式語義是合法的、符合邏輯的。這階段對類的方法體(Class檔案中的Code屬性)進行校驗分析,保證被校驗類的方法體在執行時不會做出危害虛擬機器安全的行為。

優化操作:在方法體Code屬性的屬性表中新增一項名為"StackMapTable"的新屬性,描述了方法體的所有基本塊開始時本地變量表和操作棧應有的狀態。在位元組碼驗證期間只需要檢查StackMapTable屬性中的記錄是否合法即可。

4. 符號引用驗證

最後一個階段的校驗行為發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化發生在連線的第三階段——解析階段。作用為檢視該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、欄位等資源。主要目的是確保解析行為能正常執行。

7.3.3 準備

準備階段是正式為類中定義的變數分配記憶體並設定類變數初始值的階段。這些變數所使用的記憶體都應當在方法區中進行分配,JDK 7及之前,HotSpot使用永久代來實現方法區,JDK 及之後,類變數會隨著Class物件一起存放在Java堆中。記憶體分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。類變數在準備階段的初始值為0,因為這時尚未開始執行任何java方法,而賦值命令時程式被編譯之後,存放於類構造器( )方法之中。所以賦值的動作需要到類的初始化階段才會被執行。特殊情況下:

如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數值就會被初始化為ConstantValue屬性所指定的初始值。

7.3.4 解析

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

  • 符號引用:以一組符號來描述所引用的目標,符號可以時任何形式的字面量,只要使用時能無歧義地定位到目標即可。引用目標不一定是已經載入到虛擬機器記憶體當中的內容。不通虛擬機器能接受的符號引用必須是一致的。
  • 直接引用:可以直接指向目標的指標、相對偏移量或者是一個能間接定位到目標的控制代碼。與虛擬機器的記憶體佈局直接相關,如果有了直接引用,引用的目標必定已經在虛擬機器記憶體中存在。

除了invokedynamic指令之外,虛擬機器實現可以對第一次解析的結果進行快取,可以在剛剛完成載入階段還沒有開始執行程式碼時提前進行解析。對於invokedynamic指令,必須要等到程式實際執行到這條指令時,解析動作才能開始。

解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符這7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8種常量型別。對於每種型別是如何解析的可以自行百度。

7.3.5 初始化

類的初始化階段是類載入過程的最後一個步驟,之前介紹的幾個類載入的動作裡,除了在載入階段使用者應用程式可以通過自定義類載入器的方式區域性參與外,其餘動作都完全由Java虛擬機器來主導控制。直到初始化階段,Java虛擬機器才真正開始執行類中編寫的Java程式程式碼,將主導權移交給應用程式。

進行準備階段時,變數已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程式設計師通過程式編碼制定的主觀計劃去初始化類變數和其他資源。

類的初始化可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器()方法的過程。()並不是程式設計師在Java程式碼中直接編寫的方法,它是Javac編譯器的自動生成物,但我們非常有必要了解這個方法具體是如何產生的,以及()方法執行過程中各種可能會影響程式執行行為的細節,這部分比起其他類載入過程更貼近於普通的程式開發人員的實際工作。

()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序決定的。

()方法與類的建構函式(即在虛擬機器視角中的例項構造器()方法)不同,它不需要顯式地呼叫父類構造器,Java虛擬機器會保證在子類的()方法執行前,父類的()方法已經執行完畢。因此在Java虛擬機器中第一個被執行的()方法的型別肯定是java.lang.Object。

由於父類的()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。

()方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成()方法。

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

Java虛擬機器必須保證一個類的()方法在多執行緒環境中被正確地加鎖同步,如果多個執行緒同時去初始化一個類,那麼只會有其中一個執行緒去執行這個類的()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行完畢()方法。如果在一個類的()方法中有耗時很長的操作,那就可能造成多個程序阻塞,在實際應用中這種阻塞往往是很隱蔽的。


7.4 類載入器

Java虛擬機器設計團隊有意把類載入階段中的“通過一個類的全限定名來獲取描述該類的二進位制位元組流”這個動作放到Java虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需的類。實現這個動作的程式碼被稱為“類載入器”(Class Loader)。

7.4.1 類與類載入器

類載入器雖然只用於實現類的載入動作,但它在Java程式中起到的作用卻遠超類載入階段。對於任意一個類,都必須由載入它的類載入器和這個類本身一起共同確立其在Java虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。比較兩個類是否相等,只有在這兩個類是由同一個類載入器載入的前提下才有意義。不同的類載入器載入的條件下,兩個類必定不相等。

7.4.2 雙親委派模型

站在Java虛擬機器的角度來看,只存在兩種不同的類載入器:

  • 一種是啟動類載入器(BootstrapClassLoader),這個類載入器使用C++語言實現,是虛擬機器自身的一部分。

  • 另外一種就是其他所有的類載入器,這些類載入器都由Java語言實現,獨立存在於虛擬機器外部,並且全都繼承自抽象類java.lang.ClassLoader。

站在Java開發人員的角度來看,類載入器就應當劃分得更細緻一些,對於這個時期的Java應用,絕大多數Java程式都會使用到以下3個系統提供的類載入器來進行載入:

  • 啟動類載入器(Bootstrap Class Loader):該類載入器負責載入存放在<JAVA_HOME>\lib目錄,或者被-Xbootclasspath引數所指定的路徑中存放的,而且是Java虛擬機器能夠識別的類庫載入到虛擬機器的記憶體中。啟動類載入器無法被Java程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給引導類載入器去處理,那直接使用null代替即可。
  • 擴充套件類載入器(Extension Class Loader):這個類載入器是在類sun.misc.Launcher$ExtClassLoader中以Java程式碼的形式實現的。它負責載入<JAVA_HOME>\lib\ext目錄中,或者被java.ext.dirs系統變數所指定的路徑中所有的類庫。允許使用者將具有通用性的類庫放置在ext目錄裡以擴充套件Java SE的功能。
  • 應用程式類載入器(Application Class Loader):這個類載入器由sun.misc.Launcher$AppClassLoader來實現。由於應用程式類載入器是ClassLoader類中的getSystem-ClassLoader()方法的返回值,所以有些場合中也稱它為“系統類載入器”。它負責載入使用者類路徑(ClassPath)上所有的類庫,開發者同樣可以直接在程式碼中使用這個類載入器。如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

圖7-2中展示的各種類載入器之間的層次關係被稱為類載入器的“雙親委派模型(ParentsDelegation Model)”。雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類載入器。不過這裡類載入器之間的父子關係一般不是以繼承(Inheritance)的關係來實現的,而是通常使用組合(Composition)關係來複用父載入器的程式碼。

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

使用雙親委派模型來組織類載入器之間的關係,一個顯而易見的好處就是Java中的類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。

7.4.3 破壞雙親委派模型

雙親委派模型並不是一個具有強制性約束的模型,而是Java設計者推薦給開發者們的類載入器實現方式。

在Java的世界中大部分的類載入器都遵循這個模型,但也有例外的情況,直到Java模組化出現為止,雙親委派模型主要出現過3次較大規模“被破壞”的情況:

第一次“被破壞”:

由於雙親委派模型在JDK 1.2之後才被引入,但是類載入器的概念和抽象類java.lang.ClassLoader則在Java的第一個版本中就已經存在,面對已經存在的使用者自定義類載入器的程式碼,無法再以技術手段避免loadClass()被子類覆蓋的可能性,只能在JDK 1.2之後的java.lang.ClassLoader中新增一個新的protected方法findClass(),並引導使用者編寫的類載入邏輯時儘可能去重寫這個方法,而不是在loadClass()中編寫程式碼。上節我們已經分析過loadClass()方法,雙親委派的具體邏輯就實現在這裡面,按照loadClass()方法的邏輯,如果父類載入失敗,會自動呼叫自己的findClass()方法來完成載入,這樣既不影響使用者按照自己的意願去載入類,又可以保證新寫出來的類載入器是符合雙親委派規則的。

第二次“被破壞”:

是由這個模型自身的缺陷導致的,雙親委派很好地解決了各個類載入器協作時基礎型別的一致性問題(越基礎的類由越上層的載入器進行載入),基礎型別之所以被稱為“基礎”,是因為它們總是作為被使用者程式碼繼承、呼叫的API存在,但程式設計往往沒有絕對不變的完美規則,如果有基礎型別又要呼叫回用戶的程式碼,那該怎麼辦呢?

為了解決這個困境,Java的設計團隊只好引入了一個不太優雅的設計:執行緒上下文類載入器(Thread Context ClassLoader)。這個類載入器可以通過java.lang.Thread類的setContext-ClassLoader()方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。

第三次“被破壞”:

是由於使用者對程式動態性的追求而導致的,這裡所說的“動態性”指的是一些非常“熱”門的名詞:程式碼熱替換(Hot Swap)、模組熱部署(HotDeployment)等。

例如OSGi實現模組化熱部署的關鍵是它自定義的類載入器機制的實現,每一個程式模組(OSGi中稱為Bundle)都有一個自己的類載入器,當需要更換一個Bundle時,就把Bundle連同類載入器一起換掉以實現程式碼的熱替換。在OSGi環境下,類載入器不再雙親委派模型推薦的樹狀結構,而是進一步發展為更加複雜的網狀結構,當收到類載入請求時,OSGi將按照下面的順序進行類搜尋:

1)將以java.*開頭的類,委派給父類載入器載入。

2)否則,將委派列表名單內的類,委派給父類載入器載入。

3)否則,將Import列表中的類,委派給Export這個類的Bundle的類載入器載入。

4)否則,查詢當前Bundle的ClassPath,使用自己的類載入器載入。

5)否則,查詢類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類載入器載入。

6)否則,查詢Dynamic Import列表的Bundle,委派給對應Bundle的類載入器載入。

7)否則,類查詢失敗。


7.5 Java模組化系統

在JDK 9中引入的Java模組化系統(Java Platform Module System,JPMS)是對Java技術的一次重要升級,為了能夠實現模組化的關鍵目標——可配置的封裝隔離機制,Java虛擬機器對類載入架構也做出了相應的變動調整,才使模組化系統得以順利地運作。

JDK 9以後,如果啟用了模組化進行封裝,模組就可以宣告對其他模組的顯式依賴。public型別不再意味著程式的所有地方的程式碼都可以隨意訪問到它們,模組提供了更精細的可訪問性控制,必須明確宣告其中哪些public的型別可以被其他哪一些模組訪問。這種訪問控制也主要是在類載入過程中完成的。

7.5.1 模組的相容性

JDK 9提出了“模組路徑”的概念:就是莫個類庫到底是模組還是傳統的JAR包,只取決於它存放在哪種路徑上。

模組化系統將按照以下規則來保證傳統路徑依賴的Java程式可以不經過修改直接執行在JDK 9及以後的版本上:

  • JAR檔案在類路徑的訪問規則:所有類路徑下的JAR檔案及其他資原始檔,都被是為自動打包在一個匿名模組裡,這個匿名模組幾乎沒有任何隔離,它可以看到和使用類路徑上的所有的包、JDK系統模組中的所有匯出包,以及模組路徑上所有模組中匯出的包。
  • 模組在模組路徑的訪問規則:模組路徑下的具名模組只能訪問到它依賴定義中列明依賴的模組和包
  • JAR檔案在模組路徑的訪問規則:如果把一個傳統的、不包含模組定義的JAR檔案放置到模組路徑中,它就會變成一個自動模組。

7.5.2 模組化下的類載入器

模組化下的類載入器仍然發生了一些應該被注意到的變動,主要包括以下幾個方面:

  • 擴充套件類載入器被平臺類載入器取代
  • 平臺類載入器和應用程式類載入器都不再派生自java.net.URLClassLoader。啟動器載入器、平臺類載入器、應用程式類載入器全都繼承於jdk.internal.loader.BuiltinClassLoader
  • 啟動類載入器現在是在java虛擬機器內部和java類庫共同協作實現的類載入器

類載入的委派關係也發生了變動:當平臺及應用程式類載入器收到類載入請求,在委派給父載入器載入前,要先判斷該類是否能夠歸屬到某一個系統模組中,如果可以找到這樣的歸屬關係,就要優先委派給負責那個模組的載入器完成載入。


END