首頁 > 軟體

深入理解Java虛擬機器(類檔案結構+類載入機制+位元組碼執行引擎)

2020-06-16 16:41:38

之前在閱讀 ASM 文件時,對於已編譯類的結構、方法描述符、存取標誌、ACC_PUBLIC、ACC_PRIVATE、各種位元組碼指令等等許多概念聽起來都是雲山霧罩、一知半解,原因就在於對類檔案結構和類載入機制不夠了解。直到後來細讀了《深入理解 Java 虛擬機器》中虛擬機器執行子系統的相關內容,才建立了清晰的認知。如果你也和我一樣,不了解類結構和類載入,但是工作中又涉及到位元組碼相關內容,相信後面兩篇文章會對你有所幫助。

我們所編寫的每一行程式碼,要在機器上執行最終都需要編譯成二進位制的機器碼 CPU 才能識別。但是由於虛擬機器的存在,遮蔽了作業系統與 CPU 指令集的差異性,類似於 Java 這種建立在虛擬機器之上的程式語言通常會編譯成一種中間格式的檔案來儲存,比如我們今天要聊的位元組碼(ByteCode)檔案。

一. 語言無關性

Java 虛擬機器的設計者在設計之初就考慮並實現了其它語言在 Java 虛擬機器上執行的可能性。所以並不是只有 Java 語言能夠跑在 Java 虛擬機器上,時至今日諸如 Kotlin、Groovy、Jython、JRuby 等一大批 JVM 語言都能夠在 Java 虛擬機器上執行。它們和 Java 語言一樣都會被編譯器編譯成位元組碼檔案,然後由虛擬機器來執行。所以說類檔案(位元組碼檔案)具有語言無關性。

二. Class 檔案結構

Class 檔案是一組以 8 位位元組為基礎單位的二進位制流,各個資料嚴格按照順序緊湊的排列在 Class 檔案中,中間無任何分隔符,這使得整個 Class 檔案中儲存的內容幾乎全部都是程式執行的必要資料,沒有空隙存在。當遇到需要佔用 8 位位元組以上空間的資料項時,會按照高位在前的方式分割成若干個 8 位位元組進行儲存。

Java 虛擬機器規範規定 Class 檔案格式採用一種類似與 C 語言結構體的微結構體來儲存資料,這種偽結構體中只有兩種資料型別:無符號數和表。

  • 無符號數屬於基本的資料型別,以 u1、u2、u4、u8來分別代表 1 個位元組、2 個位元組、4 個位元組和 8 個位元組的無符號數,無符號數可以用來描述數位、索引參照、數量值或者按照 UTF-8 編碼結構構成的字串值。

  • 是由多個無符號數或者其他表作為資料項構成的複合資料型別,所有表都習慣性地以「_info」結尾。表用於描述有層次關係的複合結構的資料,整個 Class 檔案就是一張表,它由下表中所示的資料項構成。

型別名稱數量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

Class 檔案中儲存的位元組嚴格按照上表中的順序緊湊的排列在一起。哪個位元組代表什麼含義,長度是多少,先後順序如何都是被嚴格限制的,不允許有任何改變。

2.1 魔數與 Class 檔案版本

每個 Class 檔案的頭 4 個位元組稱為魔數(Magic Number),它的唯一作用是確定這個檔案是否為一個能被虛擬機器接收的 Calss 檔案。之所以使用魔數而不是檔案字尾名來進行識別主要是基於安全性的考慮,因為檔案字尾名是可以隨意更改的。Class 檔案的魔數值為「0xCAFEBABE」。

緊接著魔數的 4 個位元組儲存的是 Class 檔案的版本號:第 5 和第 6 兩個位元組是次版本號(Minor Version),第 7 和第 8 個位元組是主版本號(Major Version)。高版本的 JDK 能夠向下相容低版本的 Class 檔案,虛擬機器會拒絕執行超過其版本號的 Class 檔案。

2.2 常數池

主版本號之後是常數池入口,常數池可以理解為 Class 檔案之中的資源倉庫,它是 Class 檔案結構中與其他專案關聯最多的資料型別,也是佔用 Class 檔案空間最大的資料專案之一,同是它還是 Class 檔案中第一個出現的表型別資料專案。

因為常數池中常數的數量是不固定的,所以在常數池入口需要放置一個 u2 型別的資料來表示常數池的容量「constant_pool_count」,和電腦科學中計數的方法不一樣,這個容量是從 1 開始而不是從 0 開始計數。之所以將第 0 項常數空出來是為了滿足後面某些指向常數池的索引值的資料在特定情況下需要表達「不參照任何一個常數池專案」的含義,這種情況可以把索引值置為 0 來表示。

Class 檔案結構中只有常數池的容量計數是從 1 開始的,其它集合型別,包括介面索引集合、欄位表集合、方法表集合等容量計數都是從 0 開始。

常數池中主要存放兩大類常數:字面量符號參照

  • 字面量比較接近 Java 語言層面的常數概念,如字串、宣告為 final 的常數值等。

  • 符號參照屬於編譯原理方面的概念,包括了以下三類常數:

    • 類和介面的全限定名
    • 欄位的名稱和描述符
    • 方法的名稱和描述符

2.3 存取標誌

緊接著常數池之後的兩個位元組代表存取標誌(access_flag),這個標誌用於識別一些類或者介面層次的存取資訊,包括這個 Class 是類還是介面;是否定義為 public 型別;是否定義為 abstract 型別;如果是類的話,是否被申明為 final 等。具體的標誌位以及標誌的含義見下表:

標誌名稱標誌值含義
ACC_PUBLIC 0x0001 是否為 public 型別
ACC_FINAL 0x0010 是否被宣告為 final,只有類可設定
ACC_SUPER 0x0020 是否允許使用 invokespecial 位元組碼指令的新語意,invokespecial 指令的語意在 JKD 1.0.2 中發生過改變,微聊區別這條指令使用哪種語意,JDK 1.0.2 編譯出來的類的這個標誌都必須為真
ACC_INTERFACE 0x0200 標識這是一個介面
ACC_ABSTRACT 0x0400 是否為 abstract 型別,對於介面或者抽象類來說,此標誌值為真,其它類值為假
ACC_SYNTHETIC 0x1000 標識這個類並非由使用者程式碼產生
ACC_ANNOTATION 0x2000 標識這是一個註解
ACC_ENUM 0x4000 標識這是一個列舉

access_flags 中一共有 16 個標誌位可以使用,當前只定義了其中的 8 個,沒有使用到的標誌位要求一律為 0。

2.4 類索引、父類別索引與介面索引集合

類索引(this_class)和父類別索引(super_class)都是一個 u2 型別的資料,而介面索引集合(interfaces)是一組 u2 型別的資料集合,Class 檔案中由這三項資料來確定這個類的繼承關係。

  • 類索參照於確定這個類的全限定名
  • 父類別索參照於確定這個類的父類別的全限定名
  • 介面索引集合用於描述這個類實現了哪些介面

2.5 欄位表集合

欄位表集合(field_info)用於描述介面或者類中宣告的變數。欄位(field)包括類變數和範例變數,但不包括方法內部宣告的區域性變數。下面我們看看欄位表的結構:

型別名稱數量
u2 access_flag 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

欄位修飾符放在 access_flags 中,它與類中的 access_flag 非常相似,都是一個 u2 的資料型別。

標誌名稱標誌值含義
ACC_PUBLIC 0x0001 欄位是否為 public
ACC_PRIVATE 0x0002 欄位是否為 private
ACC_PROTECTED 0x0004 欄位是否為 protected
ACC_STATIC 0x0008 欄位是否為 static
ACC_FINAL 0x0010 欄位是否為 final
ACC_VOLATILE 0x0040 欄位是否為 volatile
ACC_TRANSIENT 0x0080 欄位是否為 transient
ACC_SYNTHETIC 0x1000 欄位是否由編譯器自動生成
ACC_ENUM 0x4000 欄位是否為 enum

2.6 方法表集合

Class 檔案中對方法的描述和對欄位的描述是完全一致的,方法表中的結構和欄位表的結構一樣。

因為 volatile 關鍵字和 transient 關鍵字不能修飾方法,所以方法表的存取標誌中沒有 ACC_VOLATILE 和 ACC_TRANSIENT。與之相對的,synchronizes、native、strictfp 和 abstract 關鍵字可以修飾方法,所以方法表的存取標誌中增加了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 標誌。

對於方法裡的程式碼,經過編譯器編譯成位元組碼指??後,存放在方法屬性表中一個名為「Code」的屬性裡面。

2.7 屬性表集合

在 Class 檔案、欄位表、方法表中都可以攜帶自己的屬性表(attribute_info)集合,用於描述某些場景專有的資訊。

屬性表集合不像 Class 檔案中的其它資料項要求這麼嚴格,不強制要求各屬性表的順序,並且只要不與已有屬性名重複,任何人實現的編譯器都可以向屬性表中寫入自己定義的屬性資訊,Java 虛擬機器在執行時會略掉它不認識的屬性。

寫在最後

為了控制篇幅,這篇文章裡丟棄了很多細節,比如常數池的專案型別、方法表、屬性表的具體內容等等。建議想要深入了解的同學可以自己動手將 Java 類編譯成二進位制位元組碼檔案,根據文章裡介紹的類檔案結構逐個字元去對照和實驗,有助於加深理解。

關於「類檔案結構」我們就介紹到這裡,下一篇我們來聊聊「虛擬機器的類載入機制」。

參考資料:

  • 《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐(第 2 版)》

IT145.com E-mail:sddin#qq.com