【笔记】深入理解 Java 虚拟机:类文件结构
概述
代碼編譯的結果從本地機器碼編程字節碼,是存儲格式發展的一小步,卻是編程語言發展的一大步。Java 在誕生之初,曾經有一個著名的口號“一次編譯,到處運行”,這句話充分表達了軟件開發人員對沖破平臺界限的渴求。
各種不同平臺的虛擬機都統一使用的存儲格式 —— 字節碼,是構成平臺無關性的基石。虛擬機的另外一種特性是語言無關性,目前已經出現了一大批在 Java 虛擬機之上運行的語言,比如 Scala、Clojure、Groovy 等。實現語言無關特性的基礎仍然是虛擬機和字節碼存儲格式,Java 虛擬機不和包括 Java 在內的任何語言綁定,它只與 class 文件這種特定的二進制文件格式關聯。
Class 文件格式
Class 文件是一組以 8 位字節為基礎單位的二進制流,各個數據項目嚴格按照順序緊湊地排列在 Class 文件中,中間沒有添加任何分隔符。
Class 文件格式采用一種類似于 C 語言結構體的偽結構來存儲數據,這種偽結構只有兩種數據類型:無符號數和表。無符號數屬于基本的數據類型,以 u1、u2、u4、u8 分別代表 1 個字節、2 個字節、4 個字節、8 個字節的無符號數,可以用來描述數字、索引引用、數量值或者 UTF8 編碼構成的字符串。
表是由多個無符號數或其他表作為數據項構成的復合數據類型,所有表都習慣性地以 _info 結尾。表用于描述有層次關系的復合結構的數據,整個 Class 文件本質上就是一張表,它由以下數據項構成:
接下來我們一起看看這張表里各個數據項的具體含義。
魔數與版本號
Class 文件的頭 4 個字節稱為魔數,其唯一作用是確定該文件是否是一個能被虛擬機接受的 Class 文件,其值固定為 0xCAFEBABE。
緊接著魔數的 4 個字節是 Class 文件的版本號,第 5、6 字節是次版本號 minor version,第 7、8 字節是主版本號 major version。jdk7 的主版本號是 51。
常量池
緊接著主次版本號之后的是常量池,常量池可以認為是 Class 文件中的資源倉庫,它是 Class 文件中與其他項目關聯最多的數據類型。
常量池的入庫放置一個 u2 類型的數據,表示常量池容量計數值。與 Java 中語言習慣不同的是,這個容量計數從 1 而非從 0 開始。如果常量池容量為十六進制的 0x0016,即十進制的 22,那么表示常量池里有 21 項數據,索引值范圍是 1 到 21。設計者將第 0 項常量空出來,是由特殊考慮的,方便在特定情況下表達“不引用任何一個常量池項目”的含義。Class 文件結構中,只有常量池的容量計數從 1 開始,其他都是從 0 開始。
常量池中主要存放兩類數據:字面量和符號引用。字面量比較接近于 Java 語言層面的常量概念,如文本字符串、聲明為 final 類型的常量值等。而符號引用則屬于編譯原理方面的概念,包括以下三類常量:
常量池中的每一項常量都是一個表,JDK1.7 一共支持 14 種常量類型:
訪問標志
在常量池結束之后,緊接著的 2 個字節代表訪問標志 access flags,用于識別一些類或接口級別的訪問信息,比如:這個 Class 是類還是接口?是否 public?如果是類的話,是否被聲明為 final?具體標志見下表:
類索引、父類索引和接口索引的集合
Class 文件中由這三項數據來確定這個類的繼承關系。由于 Java 不允許多重繼承,因此父類索引也只有一個,類索引和父類索引使用兩個 u2 類型的索引值表示,各自執行一個類型為 CONSTANT_Class_info 的類描述符常量。對于接口索引集合,入口的第一項 u2 類型數據表示索引表的容量,也就是接口數量,其他 u2 類型數據同樣指向類描述符常量。
字段表集合
字段表 field_info 用于描述接口或類中聲明的變量,包括類級變量和實例級變量,但不包括方法內部聲明的局部變量。在 Java 中描述一個字段的信息有哪些呢?
字段表結構如下圖所示,第一項數據是 access_flags,與類中的訪問標志類似。緊隨其后的是兩個索引值:name_index、descriptor_index,分別表示字段的簡單名稱以及字段和方法的描述符。
這里解釋一下,什么是簡單名稱、描述符,以及前面提到的“全限定名”?全限定名和簡單名稱好理解,比如 com/apache/util/TestClass 是類的全限定名,就是把類全名里的 . 換成了 /,結尾加上分號 ; 用于分割多個連續的全限定名。簡單名稱是沒有類型和參數修飾的方法或字段名稱,比如 int m(),其簡單名稱就是 m。
相對于全限定名和簡單名稱來說,方法和字段的描述符更復雜些,因為是包含的信息更多:字段類型、方法參數和返回值類型。基本數據類型以及 Void 類型都用一個大寫字符標識,對象類型用 L 加對象的全限定名標識,對于數組每個維度使用一個 [ 來表示,二維數組就是 [[,一個整型數組 int[] 會被記錄為 [I。
當使用描述符來描述方法的時候,按照先參數列表后返回值的順序表示,參數列表按照順序放在 () 之內。比如 void inc(),描述符就是 ()V;java.lang.String toString(int v) 的描述符是 “(I)Ljava/lang/String;”。
字段表最后是屬性表集合,多數時候為空,如果自動是常量,則用來存放常量值信息。
方法表集合
class 文件中對方法的描述和對字段的描述格式一樣,依次包括:訪問標志、名稱索引、描述符索引、屬性表集合。方法里的代碼位于屬性表中 Code 屬性里,屬性表是 class 文件中最具擴展性的數據項。
屬性表集合
屬性表是一個很常見的數據項,在 class 文件、字段表、方法表里都可以攜帶自己的屬性表,用于描述某些場景專有的信息。
與 Class 文件中其他數據項目要求嚴格的順序、長度和內容不同,屬性表集合的限制稍微寬松些,不再要求各個屬性表具有嚴格順序。只要不與已有屬性名重復,任何人實現的編譯器都可以往屬性表中寫入自定義信息,Java 虛擬機運行時會忽略掉它不認識的屬性。
Java 虛擬機規范 (Java SE 7) 中定義了 21 種屬性,屬性表里包含三項數據:指向屬性名常量的索引值、屬性值長度、屬性值。下面講幾個最常見的屬性。
Code 屬性
Java 程序方法體中的代碼經過編譯器處理后,最終變為字節碼指令存儲在 Code 屬性內。Code 屬性出現在方法表的屬性集合中,其結構如下圖所示。
前兩項是屬性名和屬性值長度,max_stack 代表了操作數棧深度的最大值,max_locals 代表了局部變量表需要的存儲空間,其單位為 Slot。Slot 是虛擬機為局部變量分配內存的最小單位,對于 byte、int 等長度不超過 32 位的數據類型,占用一個 slot,對于 double、long 等 64 位數據,占用 2 個 slot。方法參數(包括實例中的隱藏參數 this)、顯式異常處理器的參數(catch 塊定義的異常)、方法體中定義的局部變量都需要使用局部變量表來存放。為了省空間,當代碼執行超過一個局部變量的作用域時,這個局部變量所占的 slot 可以被重用。
code_length 和 code 用于存儲 Java 程序編譯后生成的字節碼指令,每個指令名用一個 u1 類型數據表示,也就是說最多可以表達 256 條指令,目前 Java 虛擬機規范定義了約 200 條指令,詳細指令可以看文章指令大全。關于 code_length 需要說明,雖然它是一個 u4 類型的值,但是虛擬機規范明確限制了一個方法不允許超過 65535 條字節碼指令。
Code 屬性是 Class 文件中最重要的一個屬性,如果把 Java 程序中的信息分為代碼和元數據兩部分,那么在整個 class 文件中,Code 屬性用于描述代碼,所以其他數據項目用于描述元數據。
Exceptions 屬性
該屬性與 Code 屬性平級,和異常表不是一回事兒,存儲的是方法描述里 throws 關鍵字后面列舉的異常,其屬性表結構如下所示:
LineNumberTable 屬性
該屬性用于描述 Java 源碼行號和字節碼行號之間的對應關系,它并不是運行時必須屬性,但默認會生成到 class 文件中。在 javac 中分別使用 -g:none 和 -g:lines 選項來取消或要求生成這項信息。如果沒有 LineNumberTable 屬性,對程序運行的影響就是,當拋出異常時堆棧中不會顯示出錯的行號,調試時也無法按照源碼行來斷點。
屬性表結構里主要有兩個數據:長度字段 line_number_table_length、行號對照表 line_number_info,line_number_info 表包括了兩個 u2 類型的數據項:start_pc、line_number,前者是字節碼行號,后者是 Java 源碼行號。
LocalVariableTable 屬性
LocalVariableTable 屬性用于描述棧幀中局部變量表與 Java 源碼定義的變量之間的關系,它也不是運行時必須的屬性,但默認會生成到 class 文件中,使用編譯參數 -g:none 或 -g:vars 可以取消或要求生成這項信息。如果沒有生成這項信息,其他人在引用該方法時參數名稱會丟失,而且調試期間無法根據參數名稱來獲取參數值。
local_variable_info 描述了棧幀與源碼中局部變量的關聯,結構如下所示:
start_pc 和 length 分別代表了局部變量生命周期開始的字節碼偏移量及其作用范圍的覆蓋長度,兩者結合起來就是該局部變量在字節碼之中的作用域范圍。
name_index 和 descriptor_index 都是常量池中 CONSTANT_utf8_info 型常量的索引,分別代表了局部變量的名稱以及描述符。index 是局部變量在棧幀局部變量表 slot 中的位置,當代表的數據類型為 64 位時,占用的 slot 為 index 和 index+1 兩個。
在 JDK1.5 引入泛型后,多了一個姐妹屬性 LocalVariableTypeTable,和 LocalVariableTable 很像,僅僅是把字段描述符 descriptor_index 換成了特征簽名 signature。對于非泛型類型來說,描述符和特征簽名是一致的,由于描述符中泛型的參數類型被擦除,描述符不能準確描述泛型類型了,因此出現了 LocalVariableTypeTable。
SourceFile 屬性
用于記錄 Class 文件的源文件名稱,可以通過 javac 的 -g:none 和 -g:source 取消或開啟此項屬性。如果不生成此項屬性,拋出異常時,堆棧中不會顯示出錯代碼所屬文件名。
ConstantValue 屬性
ConstantValue 屬性的作用是通知虛擬機自動為靜態變量賦值,只有 static 關鍵字修飾的類變量才可以使用這些屬性。非 static 變量是在實例構造器方法中進行賦值的,而類變量既可以在類構造器中賦值,也可以使用 ConstantValue 屬性賦值。目前 sun javac 編譯器會把 final static 修飾的基本類型或 String 變量放在 ConstantValue 屬性里,其他使用類構造器賦值,不過 Java 虛擬機規范只是規定了 ConstantValue 屬性只能存放 static 變量,并不要求是 final 的。ConstantValue 屬性結構如下圖所示:
InnerClass 屬性
InnerClass 屬性用于記錄內部類與宿主類之間的關聯,其表結構如下所示:
數據項 number_of_classes 代表記錄了多少內部類信息,每一個內部類的信息都有一個 inner_class_info 表存儲,inner_class_info_index 和 outer_class_info_index 都是指向常量池中 CONSTANT_class_info 類型常量的索引,分別代表了內部類和宿主類的符號引用。inner_name_index 指向常量池中 CONSTANT_utf8_info 類型的常量的索引,代表內部類的名稱,如果是匿名內部類,這項值為 0。inner_class_access_flags 是內部類的訪問標志。
Deprecated 和 Synthetic 屬性
Deprecated 和 Synthetic 屬性屬于標志類型的 bool 屬性,只存在有和沒有的區別,沒有屬性值。Deprecated 用于表示某個類、字段或方法已經被程序作者定位不再推薦使用,它可以通過在代碼中使用 @deprecated 注釋進行設置。Synthetic 屬性表示某字段或方法不是 Java 源碼產生的,而是編譯時自行添加的。JDK 1.5 之后,要標記類、字段、方法是編譯器自動產生的,也可以設置訪問標志中的 ACC_SYNTHETIC 標志位。所有非用戶代碼產生的類、字段、方法都需要設置兩者中的一個,類構造器和實例構造器除外。
StackMapTable 屬性
該屬性是一個復雜的變長屬性,位于 Code 屬性的屬性表里。這個屬性在虛擬機類加載的字節碼驗證階段被新類型檢查驗證器使用,目的在于代替之前比較消耗性能的基于數據流分析的類型推導驗證器。新的驗證器在同樣保證 Class 文件合法性的前提下,省略了在運行期通過數據流分析去確認字節碼的行為邏輯合法性的步驟,而是在編譯器將一系列的驗證類型記錄在 Class 文件中,通過檢查這些驗證類型代替類型推導過程,從而大幅提升了字節碼驗證的性能。其屬性結構如下圖所示:
Signature 屬性
Signature 屬性是在 JDK1.5 中增加的,任何類、接口、初始化方法或成員的泛型簽名包含了類型變量或參數化類型,Signature 屬性就會為其記錄泛型簽名信息。之所以要使用這樣一個屬性去記錄泛型類型,是因為 Java 語言的泛型采用的是擦除法實現的偽泛型,在字節碼中,泛型信息編譯之后都被擦除。使用擦除法的好處是實現簡單、運行期節省內存,壞處是無法將 C# 等真泛型語言那樣將泛型類型和用戶定義的普通類型同等對待,例如運行期做反射時無法獲得泛型信息。Signature 屬性就是為了彌補這個缺陷而設的,其屬性結構如下所示:
其中 signature_index 項是一個指向常量池 CONSTANT_utf8_info 類型數據的索引,可以表示類簽名、方法簽名、字段類型簽名。
BootstrapMethods 屬性
該屬性在 JDK1.7 發布后增加到了 Class 文件規范中,它是一個復雜的變長屬性,位于類文件的屬性表中,用于保存 invokedynamic 指令引用的引導方法限定符。虛擬機規范規定,如果某個類文件結構的常量池中出現了 CONSTANT_InvokeDynamic_info 類型常量,那么屬性表中必須存在 BootstrapMethods 屬性。其屬性結構如下圖所示:
其中引用的 bootstrap_method 結構如下圖所示:
bootstrap_method_ref 是一個指向常量池 CONSTANT_MethodHandle_info 數據的索引值,緊隨其后的是數量字段,bootstrap_arguments 數組的每個成員必須是一個隊常量池的有效索引。
字節碼指令簡介
字節碼與數據類型
Java 虛擬機的指令由一個字節長度的代表某種特定含義的數字(稱作操作碼,OpsCode)以及緊隨其后的零到多個參數構成。由于 Java 指令是面向操作數棧,而非寄存器的架構,所以大多數指令都不包含操作數,只有一個操作碼。
在 Java 虛擬機指令集中,大多數指令都包含了操作對應的數據類型信息。例如,iload 是從局部變量表中加載 int 數據到操作數棧,而 fload 加載的是 float 類型的數據。對于大多數與數據類型相關的指令,它們的操作碼助記符里都有特殊的字符來表明專門為哪種數據類型服務:i 代表 int 類型數據,l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。
由于 Java 虛擬機的操作碼長度只有一個字節,所以包含了數據類型的操作碼就為指令設計帶來了巨大的壓力:如果每種與數據類型相關的指令,都支持所有數據類型的話,那么指令數量就會超出一個字節所能表示的范圍了。
下表列出了 Java 虛擬機所支持的指令集與數據類型之間的關系,從中可以看出大部分指令都沒有支持 byte、char、short,甚至沒有指令支持 boolean。編譯器會在編譯時,將 byte、short 類型的數據帶符號擴展為 int 類型數據,將 boolean、char 類型數據零位擴展至 int 類型數據。因此,大多數對于 byte、short、boolean、char 類型數據的操作,實際上都是使用 int 類型作為運算類型。
指令分類
Java 虛擬機中主要有以下幾種指令:
- 將局部變量加載到操作數棧:iload、iload_n、lload、lload_n、fload、fload_n、dload、dload_n、aload、aload_n
- 將數值從操作數棧存儲到局部變量表:istore、istore_n、lstore、lstore_n、fstore、fstore_n、dstore、dstore_n、astore、astore_n
- 將一個常量加載到操作數棧的指令包括有 bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_i、lconst_l、fconst_f、dconst_d
- 擴充局部變量表的訪問索引的指令:wide
- 加法指令:iadd、ladd、fadd、dadd
- 減法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 取反指令:ineg、lneg、fneg、dneg
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位與指令:iand、land
- 按位異或指令:ixor、lxor
- 局部變量自增指令:iinc
- 比較指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
- Java虛擬機對于寬化類型轉換直接支持,并不需要指令執行。
- 窄化類型轉換指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化類型轉換很可能會造成精度丟失。
- 創建類實例的指令:new
- 創建數組的指令:newarray,anewarray,multianewarray
- 訪問類字段(static字段,或者稱為類變量)和實例字段(非static字段,或者成為實例變量)的指令:getfield、putfield、getstatic、putstatic
- 把一個數組元素加載到操作數棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
- 將一個操作數棧的值儲存到數組元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
- 取數組長度的指令:arraylength
- 檢查類實例類型的指令:instanceof、checkcast
- 條件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt, if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
- 復合條件分支:tableswitch、lookupswitch
- 無條件分支:goto、goto_w、jsr、jsr_w、ret
- 將棧頂元素出棧:pop、pop2
- 賦值棧頂元素再重新壓入棧頂:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
- 棧頂兩個元素互換:swap
- invokevirtual指令用于調用對象的實例方法,根據對象的實際類型進行分派(虛方法分派),這也是Java語言中最常見的方法分派方式。
- invokeinterface指令用于調用接口方法,它會在運行時搜索一個實現了這個接口方法的對象,找出適合的方法進行調用。
- invokespecial指令用于調用一些需要特殊處理的實例方法,包括實例初始化方法、私有方法和父類方法。
- invokestatic指令用于調用類方法(static方法)
- invokedynamic指令用于調用以綁定了invokedynamic指令的調用點對象(call site object)作為目標的方法。調用點對象是一個特殊的語法結構,當一條invokedynamic指令首次被Java虛擬機執行前,Java虛擬機將會執行一個引導方法(bootstrap method)并以這個方法的運行結果作為調用點對象。因此,每條invokedynamic指令都有獨一無二的鏈接狀態,這是它與其他方法調用指令的一個差異。
- 方法返回指令則是根據返回值的類型區分的,包括:ireturn、lreturn、freturn、dreturn、areturn、return(返回 void)。
公有設計與私有實現
Java 虛擬機規范描繪了 Java 虛擬機應有的共同程序存儲格式:Class 文件格式以及字節碼 指令集。這些內容與硬件、操作系統以及具體的虛擬機實現之間是完全獨立的,Java 虛擬機實現必須能夠讀取 Class 文件并精確實現包含在其中的 Java 虛擬機代碼的語義。
一個優秀的 Java 虛擬機實現,在滿足虛擬機規范的約束下,對具體實現做出修改和優化也是可行的,并且虛擬機規范明確鼓勵實現者這樣做。虛擬機實現者可以利用這種伸縮性,來讓 Java 虛擬機獲得更高的性能、更低的內存消耗或更好的可移植性,選擇哪種特性取決于 Java 虛擬機實現的目標和關注點是什么。虛擬機實現方式主要有以下兩種:
總結
以上是生活随笔為你收集整理的【笔记】深入理解 Java 虚拟机:类文件结构的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 减小pdf大小 打印 低分辨率
- 下一篇: 智慧校园的关键技术:云计算+物联网+大数