類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

Life_Goes_On發表於2020-09-06

一、類檔案的結構

我們都知道,各種不同平臺的虛擬機器,都支援 “位元組碼 Byte Code” 這種程式儲存格式,這構成了 Java 平臺無關性的基石。甚至現在平臺無關性也開始演變出 “語言無關性” ,就是其他語言也可以執行在 Java 虛擬機器之上,比如現在的 Kotlin、Scala 等。

實現語言無關性的基礎仍然是虛擬機器和位元組碼儲存格式,Java 虛擬機器步包括 Java 語言在內的任何語言繫結,他只和 “Class 檔案” 這種特定的二進位制檔案格式所關聯,Class 檔案中包含了 Java 虛擬機器指令集、符號表以及其他若干輔助資訊。

Java 的各種語法、關鍵字、常量變數和運算子號的語義最終都會由多條位元組碼指令組合來表達,這決定了位元組碼指令所能提供的語言描述能力必須比 Java 語言本身更強大才行。

jvm 提供的語言無關性如下圖所示:

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

Java 技術能夠一直保持著非常良好的向後相容性,Class檔案結構的穩定功不可沒。JDK1.2 時代的 Java 虛擬機器中就定義好的 Class 檔案格式的各項細節,到今天幾乎沒有出現任何改變。Class檔案格式進行了幾次更新,但基本上只是在原有結構基礎上新增內容、擴充功能。

類檔案格式

Class 檔案格式採用一種類似 C 語言結構體的偽結構來儲存資料,這種偽結構中只有兩種資料型別:“無符號數”和“表”。後面的解析都要以這兩種資料型別為基礎。

  • 無符號數屬於基本資料型別,以 u1、u2、u4、u8分別代表1、2、4、8個位元組的無符號數,無符號數可以描述數字、索引引用、數量值或者utf-8 編碼構成字串值。
  • 表是多個無符號數或者其他表組成的複合型別,為了便於區分,所有表的命名都是習慣性地以 “_info” 結尾。整個 Class 檔案本質上也可以視為一張表。

Class 檔案格式的資料項如下所示:

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

這裡面可以看到,一個 Class 檔案的有些資料項是固定的 數量 × 長度,有些則不是。如果一個型別的資料數量不定,會採用多一個資料項來實現,一個前置的資料項作為容量計數器,後面連續的資料項,而數量就是前面的容量計數器的值,這時候這一系列連續的某一型別的資料稱為某一型別的 “集合”。

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

比如上面的這個,從字面意思也看得出來,因為常量池本身就是很多常量複合組成的,數量就會先用一個 u2 型別的資料項來表示,也就是我們剛說過的容量計數器,然後接著這個常量池集合本身就有了數量。

這麼嚴格要求的原因是,Class 檔案沒有任何分隔符,所以整個 Class 檔案的格式,順序、數量這樣的細節,都是嚴格限定的,全都不允許改變。

接下來,我們來看各個資料項的含義。總共分為 7 項,按照上面的那張圖的顏色框劃分也很容易看出來,並且表示的資訊也是見名知意的。

1.1 魔數與 Class 檔案的版本

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型
  • 每個 Class 檔案的頭 4 個位元組被稱為魔數(Magic Number),唯一作用是確定這個檔案是否為一個能被虛擬機器接收的 Class 檔案。(很多檔案格式標準都有魔數這個概念,比如gif或者jpeg)

Class 檔案的魔數是 0xCAFEBABE,咖啡寶貝。是因為 java 開發小組最初的關鍵成員覺得他象徵著名咖啡品牌最受歡迎的咖啡,似乎對 java 的商標也有預示

  • 魔數後面的 4 個位元組是 Class 檔案的版本號:5、6 位元組是次版本號,7、8位元組是主版本號。

1.2 常量池

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

通常常量池是佔用 Class 檔案空間最大的資料項之一。

分為兩個部分,一個2位元組的資料代表常量池容量計數值;下面是常量池的內容,可以看到這裡使用容量的大小用的是 constan_pool_count-1 ,因為常量池的容量計數是 1 開始,而不是 0,比如這個 constan_pool_count 值翻譯成十進位制是 22,那麼代表常量池有 21 項常量。

除了常量池,剩下的資料項表示都是從 0 開始計數的。

常量池中存放兩大類常量:字面量和符號引用,具體含義和分類很複雜,這裡不介紹了。

1.3 訪問標誌

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

2 個位元組,用於識別一些類或者介面層次的訪問資訊,包括 “這個 Class 是類還是介面” ,“是否定義為 public 型別”;“是否定義為 abstract 型別”,“如果是的話,是否被宣告為final”。

2 個位元組總共有 16 個標誌位,目前只定義了 9 個,沒有使用的標誌位一律置為 0。

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

1.4 類索引、父類索引和介面索引集合

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

Class 檔案中由這三項資料來確定該類的繼承關係,顯然因為 java 是單繼承,卻可以實現多個介面,所以有了 super_class 是一個 u2 的資料,而 interfaces 則需要一個 interfaces_count 。

類索引+父類索引這兩項的值,就指向的是一個 類描述符常量,通過這個索引值就能找到對應的類。

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

1.5 欄位表集合

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

描述類或介面中定義的變數,java 語言的 Field 包括:

  1. 類級變數;
  2. 例項級變數。

但是不包括在方法內部宣告的區域性變數

因為 field_info 本身也是一個表,具體的這裡就不說明。

1.6 方法表集合

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

和欄位表集合類似。

但是放發表的結構有一個特點,就是裡面並沒有方法體裡的程式碼,方法體的程式碼在下一個屬性表裡。

1.7 屬性表集合

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

Class 檔案,欄位表,方法表,三個集合內部都可以巢狀攜帶屬性表集合。

具體屬性表的格式之類的,也是很複雜,這裡不贅述。

1.8 位元組碼指令

Java 虛擬機器的指令由一個位元組長度的、代表著某種特定操作含義的數字以及跟隨其後的零至多個代表此操作所需的引數構成。

由於Java虛擬機器採用面向操作數棧而不是面向暫存器的架構,所以大多數指令都不包含運算元,只有一個操作碼,指令引數都存放在運算元棧中。

在Java虛擬機器的指令集中,大多數指令都包含其操作所對應的資料型別資訊。

舉個例子,iload指令用於從區域性變數表中載入 int 型的資料到運算元棧中,而 fload 指令載入的則是 float 型別的資料。這兩條指令的操作在虛擬機器內部可能會是由同一段程式碼來實現的,但在 Class檔案中它們必須擁有各自獨立的操作碼。

對於大部分與資料型別相關的位元組碼指令,它們的操作碼助記符中都有特殊的字元來表明專門為哪種資料型別服務:i 代表對 int 型別的資料操作, l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。

位元組碼指令可以分為:

  • 載入和儲存指令:講資料在棧幀中的區域性變數表和運算元棧之間來回傳輸;

  • 運算指令:對兩個運算元棧上的值進行運算,並把結果重新存入運算元棧頂;

  • 型別轉換指令:將不同數值型別相互轉換;

  • 物件建立與訪問指令;

  • 運算元棧管理指令:直接操作運算元棧的指令,出棧入棧等;

  • 控制轉移指令:讓jvm從指定位置的下一條指令繼續執行程式,可以認為是在修改PC暫存器的值;

  • 方法呼叫和返回指令;

  • 異常處理指令;

  • 同步指令:Java虛擬機器可以支援方法級的同步方法內部一段指令序列的同步,這兩種同步結構都是使用管程( Monitor,更常見的是直接將它稱為“鎖”) 來實現的。

    • 方法級的同步是隱式的,無須通過位元組碼指令來控制,它實現在方法呼叫和返回操作之中。虛擬機器可以從方法常量池中的方法表結構中的 ACC_SYNCHRONIZED 訪問標誌得知一個方法是否被宣告為同步方法。當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒就要求先成功持有管程,然後才能執行方法,最後當方法完成 (無論是正常完成還是非正常完成)時釋放管程。在方法執行期間,執行執行緒持有了管程,其他任何執行緒都無法再獲取到同一個管程。如果一個同步方法執行期間丟擲了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的管程將在異常拋到同步方法邊界之外時自動釋放。
    • 同步一段指令集序列通常是由 Java 語言中的 synchronized 語句塊來表示的,Java虛擬機器的指令集中有 monitor enter 和 monitor exit 兩條指令來支援 synchronized 關鍵字的語義。正確實現 synchronized 關鍵字需要 Javac 編譯器與 Java 虛擬機器兩者共同協作支援。

二、類載入機制

定義:

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

從定義裡就可以看出來,java 和哪些編譯時要進行連線的語言不同,java 的型別的載入、連線、初始化都是程式執行期間完成的,這給 java 應用提供了極高的擴充套件性,java 的可動態擴充套件的語言特性就是依賴於執行期動態載入和動態連線這個特點實現的

例如,編寫一個面向介面的程式,可以等到執行時再指定其實際的實現類,使用者可以通過 java 預置或自定義類載入器,讓某個本地應用程式在執行時從網路或者其他地方載入一個二進位制流作為其程式程式碼的一部分。

(後面說的類載入的“類”,實際上可能是介面或者類)

2.1 一個類的生命週期

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

如上圖所示,一個類從被載入到虛擬機器的記憶體中開始,到解除安裝出記憶體為止,生命週期分為 7 個階段:

  1. 載入;
  2. 連線:
    • 驗證;
    • 準備;
    • 解析;
  3. 初始化;
  4. 使用;
  5. 解除安裝。

其中驗證、準備、解析三個階段可以合起來稱為連線。

載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的,型別的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結特性(也稱為動態繫結或晚期繫結)

請注意“開始”,而不是按部就班地“進行“,或按部就班地“完成”,強調這點是因為這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中呼叫、啟用另一個階段。

2.2 什麼時候類會被載入

關於什麼時候需要開始類載入過程的第一個階段“載入”,虛擬機器規範沒有強制約束,可以交給虛擬機器的具體實現。但是初始化階段,嚴格規定了有且只有 6 種情況必須立即對類進行初始化(這就意味著,載入驗證準備都必須在此之前開始):

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

上面的六種場景中的行為,叫做對一個型別進行主動引用除了這六種外的引用型別的方式都不會觸發初始化,被稱為被動引用。

2.3 類載入的過程

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

接下來看詳細過程。

2.3.1 載入

注意啊,“載入”只是整個“類載入”中的一個階段。

載入階段,虛擬機器主要做三件事:

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

其中,第一點的來源可以是各種各樣,zip包,網路中,也可以利用動態代理技術在執行時計算生成。

第二點是要用到類載入器的,相對於五個階段的其他階段:

  1. 非陣列型別的載入階段(就是當前階段)是可控性最強的,可以通過 jvm 內建的引導類載入器,也可以自定義類載入器,開發人員通過定義自己的類載入器去控制位元組流的獲取方式(重寫一個類載入器的 findClass() 或 loadClass() 方法)。
  2. 陣列型別來說,陣列類不通過類載入器建立,而是由 jvm 直接在記憶體中動態構造出來,但是陣列的元素類本身最終還是要靠類載入器來完成載入,這個過程就是 jvm 把陣列降一個維度,然後決定繼續遞迴還是直接可以和類載入器關聯。

第三點就是如上面所說。

2.3.2 驗證(連線之 1 )

驗證是連線階段的第一步,這一階段的目的是確保 Class 檔案的位元組流中包含的資訊符合《Java虛擬機器規範》的全部約束要求,保證這些資訊被當作程式碼執行後不會危害虛擬機器自身的安全。

為什麼要驗證?

結合上一個步驟,就是因為 Class 檔案不一定就是 java 原始碼編譯來的,可能是各種途徑,甚至是自己手敲的 01 碼,所以有必要驗證位元組碼。

一般驗證的內容分為四個:

  1. 檔案格式驗證:檢查位元組流是否符合 Class 檔案格式的規範,就是魔數啊、版本號之類的;
  2. 後設資料驗證:上一步格式沒問題,然後對位元組碼描述的資訊進行語義分析,看類關係、欄位方法有沒有矛盾之類;
  3. 位元組碼驗證:最複雜的一步,通過資料流分析和控制流分析,確定程式語義合法、合邏輯,上一步資料型別等到沒問題,這一步就要進入方法體的邏輯分析;
  4. 符號引用驗證:發生在虛擬機器將符號引用轉化為直接引用的時候,轉化動作本身實在連線之3階段——解析階段發生的。(所以前面說這些順序只是開始順序,執行的時候是互相切換的),目的是驗證引用的類、欄位等內容是否能找到並訪問之類的。

驗證階段很重要,卻不一定必須執行,因為通過了驗證階段,後面對程式執行就沒有影響了,如果程式反覆被驗證和使用過就可以用引數關閉大部分的類驗證措施:

-Xverify: none

2.3.3 準備(連線之 2 )

準備階段是正式為類中定義的變數(即靜態變數,被 static 修飾的變數)分配記憶體、並設定類變數初始值的階段。

從概念上講,這些變數所使用的記憶體都應當在方法區中進行分配,但方法區本身是一個邏輯上的區域

在上一篇,jvm 的記憶體結構裡多次強調。在 JDK7及之前, HotSpot 使用永久代來實現方法區,所以還可以勉強把方法區這個概念保留;而在 JDK8 及之後,永久代也沒有了,所以類變數隨著 Class 物件一起存放在 Java 堆中,這時候 “類變數在方法區” 就有點牽強。

注意:

  1. 這個階段進行記憶體分配的僅包括類變數,不包括例項變數。現在講的整個過程都只是類載入的過程,例項變數會在物件例項化的時候隨物件一起分配在 java 堆中。
  2. 設定初始值通常指的是資料型別的 0 值。

比如:

public static int value = 123;

經過這裡的準備階段,初始值 value 是 0,因為這個時候任何 java 方法都沒有執行,初始化的指令是 putstatic ,這個指令是在類構造器的 <clinit>() 方法裡的。

所以 value 變成 123 是在類的初始化階段才會執行的,就是 2.3.5

2.3.4 解析(連線之 3 )

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

前面的 Class 檔案格式部分提過一次,那解析階段中所說的直接引用與符號引用又有什麼關聯呢?

  • 符號引用(Symbolic References):一組符號來描述引用目標,任何字面量,只要能定位就行。
  • 直接引用(Direct References):直接指向目標的指標、相對偏移量或者一個間接定位到目標的控制程式碼。

解析動作主要針對 7 類符號引用進行轉換:

  1. 類或介面;
  2. 欄位;
  3. 類方法;
  4. 介面方法;
  5. 方法型別;
  6. 方法控制程式碼;
  7. 呼叫點限定符。

2.3.5 初始化

類的初始化階段是類載入過程的最後一個步驟。

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

2.3.3 的準備階段,已經給變數賦過值了,是初始 0 值,而初始化階段,會根據程式碼初始化類變數和其他資源,另一種更直接的形式來表達這個過程:

初始化階段就是執行類構造器的 <clinit>() 方法的過程,這個方法不是程式設計師自己寫的,是 javac 編譯器的自動生成物。

2.4 類載入器

Java虛擬機器設計團隊有意把類載入階段中的:

“通過一個類的全限定名來獲取描述該類的二進位制位元組流”

這個動作放到 Java 虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需的類。(就是上面講的類載入過程的第一個步驟)

實現這個動作的程式碼被稱為 “類載入器” ( Class loader)。

2.4.1 類與類載入器

類載入器雖然只用於實現類的載入動作,但它在Java程式中起到的作用卻遠超類載入階段。

對於任意一個類,都必須由載入它的類載入器、和這個類本身一起共同確立其在Java虛擬機器中的唯一性,每個類載入器,都擁有一個獨立的類名稱空間

這句話可以表達得更通俗一些:比較兩個類是否“相等”,只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個Class檔案,被同一個Java虛擬機器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等

這裡的相等,包括比較物件的 equals() 方法,isInstance() 方法、isAssignableFrom() 等的返回結果,以及 instanceof 關鍵字的判定結果。

2.4.2 雙親委派模型

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

  • 一種是啟動類載入器( BootstrapClassloader),這個類載入器使用C++語言實現,是虛擬機器自身的一部分;
  • 另外一種就是其他所有類載入器,這些類載入器都由 Java 語言實現,獨立存在於虛擬機器外部,並且全都繼承自抽象類 java.lang.ClassLoader 。

站在Java開發人員的角度來看,類載入器就應當劃分得更細緻一些。自 JDK12 以來,Java 一直保著三層類載入器、雙親委派的類載入架構。

2.4.2.1 三層類載入器

注意:下面提及的原始碼目錄在JDK9之後,因為模組化的改變,所以按照這些目錄大概率自己的 jdk 檔案裡找不到的。

  1. 啟動類載入器 ( Bootstrap Class Loader)

前面已經介紹過,這個類載入器負責載入存放在 <JAVA_HOME>\lib 目錄,或者被 -Xbootclasspath 引數所指定的路徑中存放的,而且是 Java 虛擬機器能夠識別的(按照檔名) 類庫載入到虛擬機器的記憶體中。

啟動類載入器無法被 Java 程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給引導類載入器去處理,那直接使用 null 代替即可。

  1. 擴充套件類載入器(Extension Class Loader)

這個類載入器是在類 sun.misc.Launcher$ExtClassLoader 中以 Java 程式碼的形式實現的。

它負責載入 <JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變數所指定的路徑中所有的類庫

根據 “擴充套件類載入器” 這個名稱,就可以推斷出這是一種 Java 系統類庫的擴充套件機制,JDK 的開發團隊允許使用者將具有通用性的類庫放置在 ext 目錄裡以擴充套件 JavaSe 的功能,在JDK9之後,這種擴充套件機制被模組化帶來的天然的擴充套件能力所取代。由於擴充套件類載入器是由 Java 程式碼實現的,開發者可以直接在程式中使用擴充套件類載入器來載入 Class 檔案。

  1. 應用程式類載入器(Application Class Loader)

這個類載入器由 sun.misc.LaunchersappClassloader 來實現。由於這個類載入器是 Classloader 類中的 getSystemClassloader() 方法的返回值,所以有些場合中也稱它為“系統類載入器”。

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

除了這三種外,如果使用者有必要,還可以自定義來進行擴充套件:

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

2.4.2.2 雙親委派模型

上面的圖畫出來的關係,就被稱為類載入器的 “雙親委派模型( Parents DelegationModel)”

雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類載入器。不過這裡類載入器之間的父子關係一般不是以類繼承的關係來實現的,而是通常使用組合關係來複用父載入器的程式碼。

雙親委派模型的工作過程

如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此。

因此所有的載入請求最終都應該傳送到最頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求 (它的搜尋範圍中沒有找到所需的類) 時,子載入器才會嘗試自己去完成載入。

使用雙親委派模型來組織載入器之間的關係,一個顯而易見的好處就是:java 類隨著類載入器就具有了一種層級關係,比如 Object 類,不論哪個類載入器載入他,都會委派給模型最頂端的啟動類載入器紀念性載入,因此 Object 類在各種類載入器環境裡都能保證是同一個類,這樣 java 整個體系的最基礎行為就得到了保證。

雙親委派模型的實現,可以在 java.lang.ClassLoader 的 loadClass() 方法裡看到:

類檔案的結構、JVM 的類載入過程、類載入機制、類載入器、雙親委派模型

相關文章