深入理解Java虛擬機(類文件結構)
我們所編寫的每一行代碼,要在機器上運行最終都需要編譯成二進制的機器碼 CPU 才能識別。但是由於虛擬機的存在,屏蔽了操作系統與 CPU 指令集的差異性,類似於 Java 這種建立在虛擬機之上的編程語言通常會編譯成一種中間格式的文件Class文件來進行存儲。
一. 語言無關性
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 文件中存儲的字節嚴格按照上表中的順序緊湊的排列在一起。哪個字節代表什么含義,長度是多少,先后順序如何都是被嚴格限制的,不允許有任何改變。
Test類作為Class文件解讀參考
public class Test {
private int m;
public int inc(){
return m + 1;
}
}
查看二進制信息:對Test.java使用javac編譯后,使用vim查看Test.class文件,此時顯示文件為亂碼信息,輸入:%!xxd
即可顯示二進制信息
0000000: cafe babe 0000 0034 0013 0a00 0400 0f09 .......4........
0000010: 0003 0010 0700 1107 0012 0100 016d 0100 .............m..
0000020: 0149 0100 063c 696e 6974 3e01 0003 2829 .I...<init>...()
0000030: 5601 0004 436f 6465 0100 0f4c 696e 654e V...Code...LineN
0000040: 756d 6265 7254 6162 6c65 0100 0369 6e63 umberTable...inc
0000050: 0100 0328 2949 0100 0a53 6f75 7263 6546 ...()I...SourceF
0000060: 696c 6501 000e 5465 7374 436c 6173 732e ile...TestClass.
0000070: 6a61 7661 0c00 0700 080c 0005 0006 0100 java............
0000080: 0954 6573 7443 6c61 7373 0100 106a 6176 .TestClass...jav
0000090: 612f 6c61 6e67 2f4f 626a 6563 7400 2100 a/lang/Object.!.
00000a0: 0300 0400 0000 0100 0200 0500 0600 0000 ................
00000b0: 0200 0100 0700 0800 0100 0900 0000 1d00 ................
00000c0: 0100 0100 0000 052a b700 01b1 0000 0001 .......*........
00000d0: 000a 0000 0006 0001 0000 0001 0001 000b ................
00000e0: 000c 0001 0009 0000 001f 0002 0001 0000 ................
00000f0: 0007 2ab4 0002 0460 ac00 0000 0100 0a00 ..*....`........
0000100: 0000 0600 0100 0000 0600 0100 0d00 0000 ................
0000110: 0200 0e0a
查看字節碼信息:使用javap -verbose工具可以參看當前Class文件的字節碼信息
D:\>javap -verbose Test.class
Classfile /D:/Test.class
Last modified 2020-8-26; size 265 bytes
MD5 checksum 0d5efc4b65ae7eb6d64f84136ce58ff9
Compiled from "Test.java"
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#16 // Test.m:I
#3 = Class #17 // Test
#4 = Class #18 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 inc
#12 = Utf8 ()I
#13 = Utf8 SourceFile
#14 = Utf8 Test.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = NameAndType #5:#6 // m:I
#17 = Utf8 Test
#18 = Utf8 java/lang/Object
{
public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 6: 0
}
SourceFile: "Test.java"
2.1 魔數與 Class 文件版本
每個 Class 文件的頭 4 個字節稱為魔數(Magic Number),它的唯一作用是確定這個文件是否為一個能被虛擬機接收的 Calss 文件。之所以使用魔數而不是文件后綴名來進行識別主要是基於安全性的考慮,因為文件后綴名是可以隨意更改的。Class 文件的魔數值為「0xCAFEBABE」。之所以是CAFEBABE是因為有一位開發者喜歡著名咖啡品牌 Peet`s Coffee,此后出現的Java商標也是一杯咖啡……
緊接着魔數的 4 個字節存儲的是 Class 文件的版本號:第 5 和第 6 兩個字節是次版本號(Minor Version),第 7 和第 8 個字節是主版本號(Major Version)。高版本的 JDK 能夠向下兼容低版本的 Class 文件,虛擬機會拒絕執行超過其版本號的 Class 文件。
# 魔數 次版本號 主版本號
0000000: cafe babe 0000 0034 0013 0a00 0400 0f09 .......4........
根據以上信息可以獲得主版本號信息 0x0034 的十進制52,52對應的JDK版本是JDK8,可以向下兼容45-51的JDK版本。JDK版本是從45開始的,JDK1.0-1.1 使用了45.0-45.3的版本號。
次版本號在JDK12之前都沒有使用過,全為0。
字節碼文件內容:
public class Test
minor version: 0
major version: 52
2.2 常量池
主版本號之后是常量池入口,常量池可以理解為 Class 文件之中的資源倉庫,它是 Class 文件結構中與其他項目關聯最多的數據類型,也是占用 Class 文件空間最大的數據項目之一,同是它還是 Class 文件中第一個出現的表類型數據項目。
因為常量池中常量的數量是不固定的,所以在常量池入口需要放置一個 u2 類型的數據來表示常量池的容量「constant_pool_count」,和計算機科學中計數的方法不一樣,這個容量是從 1 開始而不是從 0 開始計數
。之所以將第 0 項常量空出來是為了滿足后面某些指向常量池的索引值的數據在特定情況下需要表達「不引用任何一個常量池項目」的含義,這種情況可以把索引值置為 0 來表示。
Class 文件結構中只有常量池的容量計數是從 1 開始的,其它集合類型,包括接口索引集合、字段表集合、方法表集合等容量計數都是從 0 開始。
常量池中主要存放兩大類常量:字面量和符號引用。
- 字面量比較接近 Java 語言層面的常量概念,如字符串、聲明為 final 的常量值等。
- 符號引用屬於編譯原理方面的概念,包括了以下三類常量:
- 類和接口的全限定名
- 字段的名稱和描述符
- 方法的名稱和描述符
經過javac編譯后的Class文件不會保存方法、字段最終在內存中的布局信息,而是保存其具體地址的符號引用。當虛擬機做類加載時,將會從常量池獲得對應的符號引用,再在類創建時或運行時解析翻譯到具體的內存地址中。
# 常量池大小
0000000: cafe babe 0000 0034 0013 0a 00 0400 0f09 .......4........
根據以上信息可以得出,常量池容量是十六進制0x0013,十進制為19,因此常量池中有18項常量,索引范圍在1-18。
常量池常量分析
常量池中17種(缺5種…將就這用…)數據類型的結構表
常量池容量大小0x0013之后有18個常量,每個常量所占占用的字節大小都不相同,以第一個常量 0x0a(十進制為10)為例,查常量池項目類型表可知(常量類型表的結構一般為 tag(u1) 和 index(u2)),10為類中方法的符號引用CONSTANT_Methodref_info,該引用 有一個tag (u1類型占用一個字節 = 0x0a = 10)、兩個index(u2類型占用兩個字節 0x0004和0x000f), 第一個index = 4
指向聲明方法描述符CONSTANT_Class_info的索引項,第二個index = 15
指向名稱及類型描述符CONSTANT_NameAndType的索引項。
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#4 = Class #18 // java/lang/Object
#5 = Utf8 m
#7 = Utf8 <init>
#8 = Utf8 ()V
#15 = NameAndType #7:#8 // "<init>":()V
#18 = Utf8 java/lang/Object
根據第一個index索引值0x0004 代表第四個常量,第四個常量的 tag 是0x07為CONSTANT_Class_info,結構為tag和index(指向全限定名常量項的索引),則tag = 7(0x07),index = 18(0x0012)索引為18表示為最后一個常量,查常量池知,第18個常量為 0x01是CONSTANT_Utf8_info常量,其長度為0x0010 = 16個字節 ,由上面的Constant pool
知,這16個字節表示 java/lang/Object。依次類推即可查詢所有的常量信息
# 標記常量順序 1 2
0000000: cafe babe 0000 0034 0013 0a 0004 000f 09
# 3 4 5
0000010: 0003 0010 07 0011 07 0012 01 0001 6d01 00
0000020: 0149 0100 063c 696e 6974 3e01 0003 2829
0000030: 5601 0004 436f 6465 0100 0f4c 696e 654e
0000040: 756d 6265 7254 6162 6c65 0100 0369 6e63
0000050: 0100 0328 2949 0100 0a53 6f75 7263 6546
0000060: 696c 6501 000e 5465 7374 436c 6173 732e
0000070: 6a61 7661 0c00 0700 080c 0005 0006 0100
# 18 length 后面的字符到0x21之前表示java/lang/Object
0000080: 0954 6573 7443 6c61 7373 01 0010 6a61 76
0000090: 612f 6c61 6e67 2f4f 626a 6563 74 0021 00
常量池的所有內容:
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#16 // Test.m:I
#3 = Class #17 // Test
#4 = Class #18 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 inc
#12 = Utf8 ()I
#13 = Utf8 SourceFile
#14 = Utf8 Test.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = NameAndType #5:#6 // m:I
#17 = Utf8 Test
#18 = Utf8 java/lang/Object
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 個標志位可以使用,當前只定義了其中的 9個,沒有使用到的標志位要求一律為 0。
以Test.Class為例,他是一個普通Java類,不是接口、枚舉、注解等,被public修飾但沒有被聲明為final或abstract,使用了JDK1.2之后的編譯器進行編譯 ,因此他的ACC_SUPER和ACC_PUBLIC為真,其余為假。因此access_flag的值為:0x0001|0x0020 = 0x0021
# 訪問標志
0000090: 612f 6c61 6e67 2f4f 626a 6563 74 0021 00
2.4 類索引、父類索引與接口索引集合
類索引(this_class)和父類索引(super_class)都是一個 u2 類型的數據,而接口索引集合(interfaces)是一組 u2 類型的數據集合,Class 文件中由這三項數據來確定這個類的繼承關系。
- 類索引用於確定這個類的全限定名
- 父類索引用於確定這個類的父類的全限定名
- 接口索引集合用於描述這個類實現了哪些接口
類索引、父類索引、接口索引都排在訪問標志之后。由於所有的類都是java.lang.Object類的子類,因此除了Object類之外所有類的父類索引都不為0。
類索引和父類索引各自指向CONSTANT_Class_info的類描述常量,通過CONSTANT_Class_info的類型常量中的索引可以找到CONSTANT_Utf8_info類型的常量中的全限定名字符串。從而獲取到該類的全限定名
0000090: 612f 6c61 6e67 2f4f 626a 6563 7400 2100 a/lang/Object.!.
# this_class
00000a0: 0300 0400 0000 0100 0200 0500 0600 0000 ................
根據上述字節碼文件:0x0021之后就是類索引(this_class)0x0003,即常量池索引為3的第三個常量0x07(CONSTANT_Class_info類型常量),后面的0x0011指向全限定名常量項的索引,即第17個常量,該常量一定是一個CONSTANT_Utf8_info類型的常量,該常量除了tag索引值外,后面的u2為length表示UTF-8編碼的字符串長度(以字節為單位),length后面的16進制字節碼就是相應的字符串。
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#3 = Class #17 // Test
#17 = Utf8 Test
類索引之后為父類索引(super_class) = 0x0004,十進制表示第4個常量
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#4 = Class #18 // java/lang/Object
#18 = Utf8 java/lang/Object
父類索引之后為接口索引(interfaces) = 0x0000,因為沒有實現任何接口,因此為全0.
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 |
Test.java類中聲明的變量有 private int m;
查看下列字節碼標注和字段修飾符表知access_flag = 0x0002= ACC_PRIVATE
# fields_count access_flag name_index descriptor_index attributes_count
00000a0: 03 0004 0000 0001 0002 0005 0006 0000
name_index = 0x0005 = 5 ,查看常量池表第五個常量是CONSTANT_Utf8_info,其值為 m。而 name_index = 0x0006=6,也是CONSTANT_Utf8_info常量,值為 I
常量池信息:
#5 = Utf8 m
#6 = Utf8 I
attributes_count
表示屬性表計數器為0,也就是沒有需要額外描述的信息。但是如果將 字段 m 的聲明改為 private int m = 123
,則會存在一項名稱為ConstantVaule的屬性,其值指向常量123。
2.6 方法表集合
Class 文件中對方法的描述和對字段的描述是完全一致的,方法表中的結構和字段表的結構一樣。
因為 volatile 關鍵字和 transient 關鍵字不能修飾方法,所以方法表的訪問標志中沒有 ACC_VOLATILE 和 ACC_TRANSIENT。與之相對的,synchronizes、native、strictfp 和 abstract 關鍵字可以修飾方法,所以方法表的訪問標志中增加了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 標志。
對於方法里的代碼,經過編譯器編譯成字節碼指令后,存放在方法屬性表中一個名為「Code」的屬性里面。
方法表的結構
類型 | 名稱 | 數量 |
---|---|---|
u2 | access_flag | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
標志符有:
標志名稱 | 標志值 | 含義 |
---|---|---|
ACC_PUBLIC | 0x0001 | 方法是否為 public |
ACC_PRIVATE | 0x0002 | 方法是否為 private |
ACC_PROTECTED | 0x0004 | 方法是否為 protected |
ACC_STATIC | 0x0008 | 方法是否為 static |
ACC_FINAL | 0x0010 | 方法是否為 final |
ACC_SYNCHRONIZED | 0x0020 | 方法是否為synchronized |
ACC_BRIDGE | 0x0040 | 方法是不是有編譯器產生的橋接方法 |
ACC_VARARGS | 0x0080 | 方法是否接受不定參數 |
ACC_NATIVE | 0x0100 | 方法是否為native |
ACC_ABSTRACT | 0x0400 | 方法是否為 abstract |
ACC_STRICT | 0x0800 | 方法是否為strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否由編譯器自動生成 |
根據methods_count = 2
知Test.java類中有兩個方法,除了inc()方法外,還有一個編譯器添加的實例構造方法<init>。
第一個方法的訪問標志值為access_flag = 0x0001即ACC_PUBLIC方法,name_index = 0x0007 查常量池知名為<init>()的方法,描述索引值descriptor_index = 0x0008,查字節碼常量池知 代表“()V”的常量。屬性表計數器attributes_count = 0x0001表名此方法的屬性表集合有1項屬性,屬性名稱的索引值為0x0009,對應的常量為“Code”,說明此屬性是方法的字節碼描述。
# methods_count access_flag name_index descriptor_index attributes_count
00000b0: 0002 0001 0007 0008 0001
# attributes_name_index
0009 00 00 00 1d 00
常量池信息:
Constant pool:
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
如果父類方法沒有被子類重寫,方法表集合中就不會出現來自父類的方法信息。否則編譯器會自動添加方法,最常見的添加的方法便是類構造器<clinit>()和實例構造器<init>()。
2.7 屬性表集合
在 Class 文件、字段表、方法表中都可以攜帶自己的屬性表(attribute_info)集合,用於描述某些場景專有的信息。
屬性表集合不像 Class 文件中的其它數據項要求這么嚴格,不強制要求各屬性表的順序,並且只要不與已有屬性名重復,任何人實現的編譯器都可以向屬性表中寫入自己定義的屬性信息,Java 虛擬機在運行時會略掉它不認識的屬性。