成為一名優秀的Android開發,需要一份完備的知識體系,在這里,讓我們一起成長為自己所想的那樣~。
一、Class 文件結構初識
“與平臺無關” 的理想最終實現在操作系統的應用層面上:眾多虛擬機廠商發布了許多可以運行在各種不同平臺上的虛擬機,而這些虛擬機都可以載入和執行同一種與平臺無關的字節碼,從而實現了程序的 “一次編寫,到處運行”。
而 字節碼(ByteCode)正是構成其平臺無關性的基石。Java 虛擬機不和包括 Java 在內的任何語言綁定,它 只與 “Class文件” 這種特定的二進制文件格式所關聯,Class 文件中包含 了 Java 虛擬機指令集和符號表以及若干其他輔助信息。
虛擬機并不關心 Class 的來源是何種語言,有了字節碼,也解除了 Java 虛擬機和 Java 語言之間的耦合。
Java 語言中的各種變量、關鍵字和運算符號的語義最終都是由多條字節碼命令組合而成的,因此,字節碼命令所能提供的語義描述能力肯定會比 Java 語言本身更加強大。所以,有一些 Java 語言本身無法有效支持的語言特性不代表字節碼本身就無法有效地支持,這也為其他語言實現一些有別于 Java 的語言特性提供了基礎。
字節碼文件是由 十六進制值組成 的,對于 JVM 來說,在讀取數據的時候,它會 以兩個十六進制值為一組,即 一個字節 進行讀取。在 Java 中,我們通常會采用 javac 命令將源代碼編譯成字節碼文件,下面這幅 Java 官方圖展示了一個 .java 文件從編譯到運行的過程,如下所示:
Class 文件是一組以 8 位字節為基礎單位的二進制流,各個數據項目嚴格按照順序緊湊地排列在 Class 文件之中,中間沒有添加任何分隔符,這使得整個 Class 文件中存儲的內容幾乎 全部是程序運行的必要數據,沒有空隙存在。
當遇到需要占用 8 位字節以上空間的數據項 時,則會按照高位在前的方式分割成若干個 8 位字節進行存儲。(高位在前指 ”Big-Endian",即指最高位字節在地址最低位,最低位字節在地址最高位的順序來存儲數據,而 X86 等處理器則是使用了相反的 “Little-Endian” 順序來存儲數據)
根據 JVM 規范的規定,Class 文件格式采用了一種類似于 C 語言結構體的偽結構來存 儲數據,而這種偽結構中有且只有兩種數據類型:無符號數和表。
1、無符號數
無符號數屬于基本的數據類型,以 u1、u2、u4、u8 來分別代表 1 個字節、2 個字節、4 個字節和 8 個字節的無符號數,無符號數可以用來 描述數字、索引引用、數量值或者按照UTF-8 碼構成字符串值。
2、表
表是 由多個無符號數或者其他表作為數據項構成的復合數據類型,所有表都習慣性地以 “_info”
結尾。表用于 描述有層次關系的復合結構的數據,而整個 Class 文件其本質上就是一張表。
對比 Linux、Windows 上的可執行文件(例如 ELF)而言,Class 文件可以看做是 JVM 的可執行文件。其 表格式 如下所示:
u4:表示能夠保存4個字節的無符號整數,u2同理。
ClassFile {
u4 magic; // 魔法數字,表明當前文件是.class文件,固定0xCAFEBABE
u2 minor_version; // 分別為Class文件的副版本和主版本
u2 major_version;
u2 constant_pool_count; // 常量池計數
cp_info constant_pool[constant_pool_count-1]; // 常量池內容
u2 access_flags; // 類訪問標識
u2 this_class; // 當前類
u2 super_class; // 父類
u2 interfaces_count; // 實現的接口數
u2 interfaces[interfaces_count]; // 實現接口信息
u2 fields_count; // 字段數量
field_info fields[fields_count]; // 包含的字段信息
u2 methods_count; // 方法數量
method_info methods[methods_count]; // 包含的方法信息
u2 attributes_count; // 屬性數量
attribute_info attributes[attributes_count]; // 各種屬性
}
復制代碼
對于 Class 表結構而言,其 前 8 個字節 依次是如下 三個元素:
- 1)、
magic
:每個 Class 文件的頭 4 個字節被稱為魔數(Magic Number),它的唯一作用是確定這個文件是否為一個能被虛擬機所接受的 Class 文件。很多文件存儲標準中都使用魔數來進行身份識別, 譬如圖片格式,如 gif 或者 jpeg 等在文件頭中都存有魔數。使用魔數而不是擴展名來進行識別主要是基于安全方面的考慮,因為文件擴展名可以隨意地改動。并且,Class 文件的魔數獲得很有 “浪漫氣息”,值為:0xCAFEBABE(咖啡寶貝)。 - 2)、
minor_version
:2 個字節長,表示當前 Class 文件的次版號。 - 3)、
major_version
:2 個字節長,表示當前 Class 文件的主版本號。(Java 的版本號是從 45 開始 的,JDK 1.1 之后的每個 JDK 大版本發布會在主版本號向上加 1(JDK 1.01.1 使用了 45.045.3 的版本號),例如 JDK 1.8 就是 52.0)。需要注意的是,虛擬機會拒絕執行超過其版本號的 Class 文件。
然后,我們再來簡單地了解下 其它元素 的含義:
- 4)、
constant_pool_count
:常量池數組元素個數。 - 5)、
constant_pool
:常量池,是一個存儲了 cp_info 信息的數組,每一個 Class 文件都有一個與之對應的常量池。(注意:cp_info 數組的索引從 1 開始) - 6)、
access_flags
:表示當前類的訪問權限,例如:public、private。 - 7)、
this_class 和 super_class
:存儲了指向常量池數組元素的索引,this_class 中索引指向的內容為當前類名,而 super_class 中索引則指向其父類類名。 - 8)、
interfaces_count 和 interfaces
:同上,它們存儲的也只是指向常量池數組元素的索引。其內容分別表示當前類實現了多少個接口和對應的接口類類名。 - 9)、
fields_count 和 fields
:表示成員變量的數量和其信息,信息由 field_info 結構體表示。 - 10)、
methods_count 和 methods
:表示成員函數的數量和它們的信息,信息由 method_info 結構體表示。 - 11)、
attributes_count 和 attributes
:表示當前類的屬性信息,每一個屬性都有一個與之對應的 attribute_info 結構。常見的屬性信息如調試信息,它需要記錄某句代碼對應源代碼的哪一行,此外,如函數對應的 JVM 字節碼、注解信息也是屬性信息。
需要注意的是,Class 表的結構不像 XML 等描述語言,由于它沒有任何分隔符號,所以在上面中的這些數據項,無論是順序還是數量,甚至于數據存儲的字節序(Byte Ordering,Class 文件中字節序為 Big-Endian)這樣的細節,都是被嚴格限定的。
對于上面的各個屬性來說,有不少屬性是我們需要重點掌握的,而 常量池可以被認為是 Class 表結構中的重中之重。下面??,我們就先來了解下常量池。
二、常量池
常量池可以理解為 Class 文件之中的資源倉庫,其它的幾種結構或多或少都會最終指向到這個資源倉庫之中。
此外,常量池是 Class 文件結構中與其他項 關聯最多 的數據類型,也是 占用 Class 文件空間最大 的數據項之一,同時它還是 在 Class 文件中第一個出現的表類型數據項。因此,如果沒有充分了地解常量池,后面其它的 Class 表類型數據項的學習會變得舉步維艱。
假設一個常量池的容量(偏移地址:0x00000008)為十六進制數 0x0016,即十進制的 22,這就代表常量池中有 21 項常量,索引值范圍為 1~21。在 Class 文件格式規范制定之時,設計者將第 0 項常量空出來是有特殊考慮的,這樣做的目的在 于滿足后面某些指向常量池的索引值的數據在特定情況下需要表達 “不引用任何一個常量池項”的含義。
而常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)
。
1、字面量(Literal)
字面量比較接近于 Java 語言層面的常量概念,如文本字符串、聲明為 final 的常量值等。
2、符號引用(Symbolic References)(??)
而 符號引用 則屬于編譯原理方面的概念,包括了 三類常量,如下所示:
- 1)、類和接口的全限定名(Fully Qualified Name)
- 2)、字段的名稱和描述符(Descriptor))
- 3)、方法的名稱和描述符
此外,在虛擬機加載 Class 文件的時候會進行動態鏈接,因為其字段、方法的符號引用不經過運行期轉換的話就無法得到真正的內存入口地址,也就無法直接被虛擬機使用。當虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建或運行時進行解析,并翻譯到具體的內存地址之中。
connstant_pool 中存儲了一個一個的 cp_info 信息,并且每一個 cp_info 的第一個字節(即一個 u1 類型的標志位)標識了當前常量項的類型,其后才是具體的常量項內容。
下面??,我們看看有哪些具體的 常量項的類型,如下表所示:
類型 | 標志 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | 用于存儲UTF-8編碼的字符串,它真正包含了字符串的內容。 |
CONSTANT_Integer_info | 3 | 表示int型數據的信息 |
CONSTANT_Float_info | 4 | 表示float型數據的信息 |
CONSTANT_Long_info | 5 | 表示long型數據的信息 |
CONSTANT_Double_info | 6 | 表示double型數據的信息 |
CONSTANT_Class_info | 7 | 表示類或接口的信息 |
CONSTANT_String_info | 8 | 表示字符串,但該常量項本身不存儲字符串的內容,它僅僅只存儲了一個索引值 |
CONSTANT_Fieldref_info | 9 | 字段的符號引用 |
CONSTANT_Methodref_info | 10 | 類中方法的符號引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符號引用 |
CONSTANT_NameAndType_info | 12 | 描述類的成員域或成員方法相關的信息 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄信息,其和反射相關 |
CONSTANT_MethodType_info | 16 | 標識方法類型,僅包含方法的參數類型和返回值類型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一個動態方法調用點,用于 invokeDynamic 指令,Java 7引入 |
然后,我們需要了解其中涉及到的重點常量項類型。這里我們需要先明白 CONSTANT_String 和 CONSTANT_Utf8 的區別。
CONSTANT_String 和 CONSTANT_Utf8 的區別
-
CONSTANT_Utf8
:真正存儲了字符串的內容,其對應的數據結構中有一個字節數組,字符串便醞釀其中。 -
CONSTANT_String
:本身不包含字符串的內容,但其具有一個指向 CONSTANT_Utf8 常量項的索引。
我們必須要了解的是,在所有常見的常量項之中,只要是需要表示字符串的地方其實際都會包含有一個指向 CONSTANT_Utf8_info 元素的索引。而一個字符串最大長度即 u2 所能代表的最大值為 65536,但是需要使用 2 個字節來保存 null 值,所以一個字符串的最大長度為 65534。
對于常見的常量項來說一般可以細分為如下 三個維度。
常量項 Utf8
常量項 Utf8 的數據結構如下所示:
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
復制代碼
其元素含義如下所示:
- 1)、
tag
:值為 1,表示是 CONSTANT_Utf8_info 類型表。 - 2)、
length
:length 表示 bytes 的長度,比如 length = 10,則表示接下來的數據是 10 個連續的 u1 類型數據。 - 3)、
bytes
:u1 類型數組,保存有真正的常量數據。
常量項 Class、Filed、Method、Interface、String
常量項 Class、Filed、Method、Interface、String 的數據結構分別如下所示:
CONSATNT_Class_info {
u1 tag;
u2 name_index;
}
復制代碼
CONSTANT_Fieldref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
復制代碼
CONSTANT_MethodType_info {
u1 tag;
u2 descriptor_index;
}
復制代碼
CONSTANT_InterfaceMethodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
復制代碼
CONSTANT_String_info {
u1 tag;
u2 string_index;
}
復制代碼
CONSATNT_NameAndType_info {
u1 tag;
u2 name_index;
u2 descriptor_index
}
復制代碼
其元素含義如下所示:
name_index
:指向常量池中索引為 name_index 的常量表。比如 name_index = 6,表明它指向常量池中第 6 個常量。class_index
:指向當前方法、字段等的所屬類的引用。name_and_type_index
:指向當前方法、字段等的名字和類型的引用。name_index
:指向某字段或方法等的名稱字符串的引用。descriptor_index
:指向某字段或方法等的類型字符串的引用。
常量項 Integer、Long、Float、Double
常量項 Integer、Long、Float、Double 對應的數據結構如下所示:
CONSATNT_Integer_info {
u1 tag;
u4 bytes;
}
復制代碼
CONSTANT_Long_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
復制代碼
CONSTANT_Float_info {
u1 tag;
u4 bytes;
}
復制代碼
CONSTANT_Double_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
復制代碼
可以看到,在每一個非基本類型的常量項之中,除了其 tag 之外,最終包含的內容都是字符串。正是因為這種互相引用的模式,才能有效地節省 Class 文件的空間。(ps:利用索引來減少空間占用是一種行之有效的方式)
三、信息描述規則
對于 JVM 來說,其 采用了字符串的形式來描述數據類型、成員變量及成員函數 這三類。因此,在討論接下來各個的 Class 表項之前,我們需要了解下 JVM 中的信息描述規則。下面,我們來一一對此進行探討。
1、數據類型
數據類型通常包含有 原始數據類型、引用類型(數組),它們的描述規則分別如下所示:
- 1)、原始數據類型:
- Java 類型的
byte、char、double、float、int、long、short、boolean
=>"B"、"C"、"D"、"F"、"I"、"J"、"S"、"Z"
。
- Java 類型的
- 2)、引用數據類型:
-
ClassName => L + 全路徑類名(其中的 "." 替換為 "/",最后加分號),例如
String => Ljava/lang/String;
。
-
ClassName => L + 全路徑類名(其中的 "." 替換為 "/",最后加分號),例如
- 3)、數組(引用類型):
-
不同類型的數組 => "[該類型對應的描述名",例如
int 數組 => "[I",String 數組 => "[Ljava/lang/Sting;",二維 int 數組 => "[[I"
。
-
不同類型的數組 => "[該類型對應的描述名",例如
2、成員變量
在 JVM 規范之中,成員變量即 Field Descriptor 的描述規則如下所示:
FiledDescriptor:
# 1、僅包含 FieldType 一種信息
FieldType
FiledType:
# 2、FiledType 的可選類型
BaseType | ObjectType | ArrayType
BaseType:
B | C | D | F | I | J | S | Z
ObjectType:
L + 全路徑ClassName;
ArrayType:
[ComponentType:
# 3、與 FiledType 的可選類型一樣
ComponentType:
FiledType
復制代碼
在注釋1處,FiledDescriptor 僅僅包含了 FieldType 一種信息;注釋2處,可以看到,FiledType 的可選類型為3中:BaseType、ObjectType、ArrayType,對于每一個類型的規則描述,我們在 數據類型 這一小節已詳細分析過了。而在注釋3處,這里 ComponentType
是一種 JVM 規范中新定義的類型,不過它是 由 FiledType 構成,其可選類型也包含 BaseType、ObjectType、ArrayType 這三種。此外,對于字節碼來講,如果兩個字段的描述符不一致, 那字段重名就是合法的。
3、成員函數描述規則
在 JVM 規范之中,成員函數即 Method Descriptor 的描述規則如下所示:
MethodDescriptor:
# 1、括號內的是參數的數據類型描述,* 表示有 0 至多個 ParameterDescriptor,最后是返回值類型描述
( ParameterDescriptor* ) ReturnDescriptor
ParameterDescriptor:
FieldType
ReturnDescriptor:
FieldType | VoidDescriptor
VoidDescriptor:
// 2、void 的描述規則為 "V"
V
復制代碼
在注釋1處,MethodDescriptor 由兩個部分組成,括號內的是參數的數據類型描述,表示有 0 至多個 ParameterDescriptor,最后是返回值類型描述。注釋2處,要注意 void 的描述規則為 "V"。例如,一個 void hello(String str)
的函數 => (Ljava/lang/String;)V
。
了解了信息的描述規則之后,我們就可以來看看 Class 表中的其它重要的表項:filed_info 與 method_info。
四、filed_info 與 method_info
字段表(field_info)用于描述接口或者類中聲明的變量。字段(field)包括類級變量以及實例級變量,但 不包括在方法內部聲明的局部變量。
filed_info 與 method_info 數據結構的偽代碼分別如下所示:
field_info {
u2 access_flags;
u2 name
u2 descriptor_index
u2 attributes_count
attribute_info attributes[attributes_count]
}
復制代碼
method_info {
u2 access_flags;
u2 name
u2 descriptor_index
u2 attributes_count
attribute_info attributes[attributes_count]
}
復制代碼
可以看到,filed_info 與 method_info 都包含有 訪問標志、名字引用、描述信息、屬性數量與存儲屬性 的數據結構。對于 method_info 所描述的成員函數來說,它的內容經過編譯之后得到的 Java 字節碼會保存在屬性之中。
注意:類構造器為 “< clinit >” 方法,而實例構造器為 “< init >” 方法。
下面,我們就來了解下 access_flags 的相關知識。
五、access_flags
access_flag 的取值類型在 Class、Filed、Method 之中都是不同的,我們分別來看看。
1、Class 的 access_flags 取值類型
access_flags 中一共有 16 個標志位可以使用,當前只定義了其中 8 個(JDK 1.5 增加了后面 3 種),沒有使用到的標志位要求一律為 0。Class 的 access_flags 取值類型如下表示:
標志名 | 標志值 | 標志含義 |
---|---|---|
ACC_PUBLIC | 0x0001 | public類型 |
ACC_FINAL | 0x0010 | final類型 |
ACC_SUPER | 0x0020 | 使用新的invokespecial語義 |
ACC_INTERFACE | 0x0200 | 接口類型 |
ACC_ABSTRACT | 0x0400 | 抽象類型 |
ACC_SYNTHETIC | 0x1000 | 該類不由用戶代碼生成 |
ACC_ANNOTATION | 0x2000 | 注解類型 |
ACC_ENUM | 0x4000 | 枚舉類型 |
例如一個 “public Class JsonChao” 的類所對應的 access_flags 為 0021(0X0001 和 0X0020 相結合)。下面的 Filed 與 Method 的計算也是同理。
2、Filed 的 access_flag 取值類型
接口之中的字段必須有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL 標志,這些都是由 Java 本身的語言規則所決定的。Filed 的 access_flag 取值類型如下表所示:
名稱 | 值 | 描述 |
---|---|---|
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,字段為枚舉類型 |
3、Method 的 access_flag 取值
Method 的 access_flag 取值如下表所示:
名稱 | 值 | 描述 |
---|---|---|
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 | bridge,方法由編譯器產生 |
ACC_VARARGS | 0x0080 | 該方法帶有變長參數 |
ACC_NATIVE | 0x0100 | native |
ACC_ABSTRACT | 0x0400 | abstract |
ACC_STRICT | 0x0800 | strictfp |
ACC_SYNTHETIC | 0x1000 | 方法由編譯器生成 |
需要注意的是,當 Method 的 access_flags 的取值為 ACC_SYNTHETIC
時,該 Method 通常被稱之為 合成函數。此外,當內部類訪問外部類的私有成員時,在 Class 文件中也會生成一個 ACC_SYNTHETIC 修飾的函數。
六、屬性
只要不與已有屬性名重復,任何人 實現的編譯器都可以向屬性表中寫入自己定義的屬性信息,Java 虛擬機運行時會忽略掉它所不認識的屬性。
attribute_info 的數據結構偽代碼如下所示:
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
復制代碼
attribute_info 中的各個元素的含義如下所示:
-
attribute_name_index
:為 CONSTANT_Utf8 類型常量項的索引,表示屬性的名稱。 -
attribute_length
:屬性的長度。 -
info
:屬性具體的內容。
1、attribute_name_index
attribute_name_index 所指向的 Utf8 字符串即為屬性的名稱,而 屬性的名稱是被用來區分屬性的。所有的屬性名稱如下所示(其中下面?? 標紅的為重要屬性):
- 1)、
ConstantValue
:僅出現在 filed_info 中,描述常量成員域的值,通知虛擬機自動為靜態變量賦值。對于非 static 類型的變量(也就是實例變量)的賦值是在實例構造器方法中進行的;而對 于類變量,則有兩種方式可以選擇:在類構造器方法中或者使用 ConstantValue 屬性。如果變量沒有被 final 修飾,或者并非基本類型及字 符串,則將會選擇在方法中進行初始化。 - 2)、
Code
:僅出現 method_info 中,描述函數內容,即該函數內容編譯后得到的虛擬機指令,try/catch 語句對應的異常處理表等等。 - 3)、
StackMapTable
:在 JDK 1.6 發布后增加到了 Class 文件規范中,它是一個復雜的變長屬性。這個屬性會在虛擬機類加載的字節碼驗證階段被新類型檢查驗證器(Type Checker)使用,目的在于代替以前比較消耗性能的基于數據流 分析的類型推導驗證器。它省略了在運行期通過數據流分析去確認字節碼的行為邏輯合法性的步驟,而是在編譯階 段將一系列的驗證類型(Verification Types)直接記錄在 Class 文件之中,通過檢查這些驗證類型代替了類型推導過程,從而大幅提升了字節碼驗證的性能。這個驗證器在 JDK 1.6 中首次提供,并在 JDK 1.7 中強制代替原本基于類型推斷的字節碼驗證器。StackMapTable 屬性中包含零至多個棧映射幀(Stack Map Frames),其中的類型檢查驗證器會通過檢查目標方法的局部變量和操作數棧所需要的類型來確定一段字節碼指令是否符合邏輯約束。 - 4)、
Exceptions
:當函數拋出異常或錯誤時,method_info 將會保存此屬性。 - 5)、InnerClasses:用于記錄內部類與宿主類之間的關聯。
- 6)、EnclosingMethod
- 7)、Synthetic:標識方法或字段為編譯器自動生成的。
- 8)、
Signature
:JDK 1.5 中新增的屬性,用于支持泛型情況下的方法簽名,由于 Java 的泛型采用擦除法實現,在為了避免類型信息被擦除后導致簽名混亂,需要這個屬性記錄泛型中的相關信息。 - 9)、
SourceFile
:包含一個指向 Utf8 常量項的索引,即 Class 對應的源碼文件名。 - 10)、SourceDebugExtension:用于存儲額外的調試信息。
- 11)、
LineNumberTable
:Java 源碼的行號與字節碼指令的對應關系。 - 12)、
LocalVariableTable
:局部變量數組/本地變量表,用于保存變量名,變量定義所在行。 - 13)、
LocalVariableTypeTable
:JDK 1.5 中新增的屬性,它使用特征簽名代替描述符,是為了引入泛型語法之后能描述泛型參數化類型而添加。 - 14)、Deprecated
- 15)、RuntimeVisibleAnnotations
- 16)、RuntimeInvisibleAnnotations
- 17)、RuntimeVisibleParameterAnnotations
- 18)、RuntimeInvisibleParameterAnnotations
- 19)、AnnotationDefault
- 20)、BootstrapMethods:JDK 1.7中新增的屬性,用于保存 invokedynamic 指令引用的引導方法限定符。切記,類文件的屬性表中最多也只能有一個 BootstrapMethods 屬性。
在上述表格中,我們可以發現,不同類型的屬性可能會出現在 ClassFile 中不同的成員里,當 JVM 在解析 Class 文件時會校驗 Class 成員應該禁止攜帶有哪些類型的屬性。此外,屬性也可以包含子屬性,例如:"Code" 屬性中包含有 "LocalVariableTable"。
2、Code_attribute(??)
首先,要注意 并非所有的方法表都必須存在這個屬性,例如接口或者抽象類中的方法就不存在 Code 屬性。
Code_attribute 的數據結構偽代碼如下所示:
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{
u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
復制代碼
Code_attribute 中的各個元素的含義如下所示:
-
attribute_name_index、attribute_length
:attribute_length 的值為整個 Code 屬性減去 attribute_name_index 和 attribute_length 的長度。 -
max_stack
:為當前方法執行時的最大棧深度,所以 JVM 在執行方法時,線程棧的棧幀(操作數棧,operand satck)大小是可以提前知道的。每一個函數執行的時候都會分配一個操作數棧和局部變量數組,而 Code_attribure 需要包含它們,以便 JVM 在執行函數前就可以分配相應的空間。 -
max_locals
:**為當前方法分配的局部變量個數,包括調用方式時傳遞的參數。long 和 double 類型計數為 2,其他為 1。max_locals 的單位是 Slot,Slot 是
虛擬機為局部變量分配內存所使用的最小單位。局部變量表中的 Slot 可以重用,當代碼執行超出一個局部變量的作用域時,這個局部變量 所占的 Slot 可以被其他局部變量所使用,Javac 編譯器會根據變量的作用域來分配 Slot 給各個 變量使用,然后計算出 max_locals 的大小**。
-
code_length
:為方法編譯后的字節碼的長度。 -
code:用于存儲字節碼指令的一系列字節流。既然叫字節碼指令,那么每個指令就是一個 u1 類型的單字節。一個 u1 數據類型的取值范圍為 0x00
0xFF,對應十進制的 0255,也就是一共可以表達 256 條指令。 -
exception_table_length
:表示 exception_table 的長度。 -
exception_table
:每個成員為一個 ExceptionHandler,并且一個函數可以包含多個 try/catch 語句,一個 try/catch 語句對應 exception_table 數組中的一項。 -
start_pc、end_pc
:為異常處理字節碼在 code[] 的索引值。當程序計數器在 [start_pc, end_pc) 內時,表示異常會被該 ExceptionHandler 捕獲。 -
handler_pc
:表示 ExceptionHandler 的起點,為 code[] 的索引值。 -
catch_type
:為 CONSTANT_Class 類型常量項的索引,表示處理的異常類型。如果該值為 0,則該 ExceptionHandler 會在所有異常拋出時會被執行,可以用來實現 finally 代碼。當 catch_type 的值為 0 時,代表任意異常情況都需要轉向到 handler_pc 處進行處理。此外,編譯器使用異常表而不是簡單的跳轉命令來實現 Java 異常及 finally 處理機制。 -
attributes_count 和 attributes
:表示該 exception_table 擁有的 attribute 數量與數據。
在 Code_attribute 攜帶的屬性中,"LineNumberTable"
與 "LocalVariableTable"
對我們 Android 開發者來說比較重要,所以,這里我們將再單獨來講解一下它們。
1)、LineNumberTable 屬性
LineNumberTable 屬性 用于 Java 的調試,可指明某條指令對應于源碼哪一行。
LineNumberTable 屬性的結構如下所示:
LineNumberTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{ u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
復制代碼
其中最重要的是 line_number_table
數組,該數組元素包含如下 兩個成員變量:
- 1、
start_pc
:為 code[] 數組元素的索引,用于指向 Code_attribute 中 code 數組某處指令。 - 2、
line_number
:為 start_pc 對應源文件代碼的行號。需要注意的是,多個 line_number_table 元素可以指向同一行代碼,因為一行 Java 代碼很可能被編譯成多條指令。
2、LocalVariableTable 屬性
LocalVariableTable 屬性用于 描述棧幀中局部變量表中的變量與 Java 源碼中定義的變量之間的關系,它也不是運行時必需的屬性,但默認會生成到 Class 文件之中。
LocalVariableTable 的數據結構如下所示:
LocalVariableTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{
u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}
復制代碼
其中最重要的元素是 local_variable_table
數組,其中的 start_pc
與 length
這兩個參數 決定了一個局部變量在 code 數組中的有效范圍。
需要注意的是,每個非 static 函數都會自動創建一個叫做 this 的本地變量,代表當前是在哪個對象上調用此函數。并且,this 對象是位于局部變量數組第1個位置(即 Slot = 0),它的作用范圍是貫穿整個函數的。
此外,在 JDK 1.5 引入泛型之后,LocalVariableTable 屬性增加了一個 “姐妹屬性”: LocalVariableTypeTable,這個新增的屬性結構與 LocalVariableTable 非常相似,僅僅是把記錄 的字段描述符的 descriptor_index 替換成了字段的特征簽名(Signature),對于非泛型類型來 說,描述符和特征簽名能描述的信息是基本一致的,但是泛型引入之后,由于描述符中泛型的參數化類型被擦除掉,描述符就不能準確地描述泛型類型了,因此出現了 LocalVariableTypeTable。
Slot 是什么?
JVM 在調用一個函數的時候,會創建一個局部變量數組(即 LocalVariableTable),而 Slot 則表示當前變量在數組中的位置。
七、JVM 指令碼(??)
在上面,我們了解了 常量池、屬性、field_info、method_info 等等一系列的源碼文件組成結構,它們是僅僅是一種靜態的內容,這些信息并不能驅使 JVM 執行我們在源碼中編寫的函數。
從前可知,Code_attribute 中的 code 數組存儲了一個函數源碼經過編譯后得到的 JVM 字節碼,其中僅包含如下 兩種 類型的信息:
- 1)、
JVM 指令碼
:用于指示 JVM 執行的動作,例如加操作/減操作/new 對象。其長度為 1 個字節,所以 JVM 指令碼的個數不會超過 255 個(0xFF)。 - 2)、
JVM 指令碼后的零至多個操作數
:操作數可以存儲在 code 數組中,也可以存儲在操作數棧(Operand stack)中。
一個 Code 數組里指令和參數的組織格式 如下所示:
1字節指令碼 0或多個參數(N字節,N>=0)
可以看到,Java 虛擬機的指令由一個字節長度的、代表著某種特定操作含義的數字(稱為操作 碼,Opcode)以及跟隨其后的零至多個代表此操作所需參數(稱為操作數,Operands)而構成。此外,大多數的指令都不包含操作數,只有一個操作碼。
字節碼指令集是一種具有鮮明特點、優劣勢都很突出的指令集架構,由于限制了 Java 虛擬機操作碼的長度為一個字節(即 0~255),這意味著指令集的操作碼總數不可能超過 256 條。
如果不考慮異常處理的話,那么 Java 虛擬機的解釋器可以使用下面這個偽代碼當做 最基本的執行模型 來理解,如下所示:
do {
自動計算PC寄存器的值加1;
根據PC寄存器的指示位置,從字節碼流中取出操作碼;
if(字節碼存在操作數)從字節碼流中取出操作數;
執行操作碼所定義的操作;
} while (字節碼流長度>0);
復制代碼
由于 Java 虛擬機的操作碼長度只有一個字節,所以,Java 虛擬機的指令集 對于特定的操作只提供了有限的類型相關指令去支持它。例如 在 JVM 中,大部分的指令都沒有支持整數類型 byte、char 和 short,甚至沒有任何指令支持 boolean 類型。因此,我們在處理 boolean、byte、short 和 char 類型的數組時,需要轉換為與之對應的 int 類型的字節碼指令來處理。
眾所周知,JVM 是基于棧而非寄存器的計算模型,并且,基于棧的實現能夠帶來很好的跨平臺特性,因為寄存器指令往往和硬件掛鉤。但是,由于棧只是一個 FILO 的結構,需要頻繁地壓棧與出棧,因此,對于同樣的操作,基于棧的實現需要更多指令才能完成。此外,由于 JVM 需要實現跨平臺的特性,因此棧是在內存實現的,而寄存器則位于 CPU 的高速緩存區,因此,基于棧的實現其速度速度相比寄存器的實現要慢很多。要深入了解 JVM 的指令集,我們就必須先從 JVM 運行時的棧幀講起。
1、運行時的棧幀
棧幀(Stack Frame)是用于支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬 機運行時數據區中的虛擬機棧(Virtual Machine Stack)的棧元素。
棧幀中存儲了方法的 局部變量表、操作數棧、動態連接和方法返回地址、幀數據區 等信息。每一個方法從調用開始至執行完成的過程,都對應著一個棧幀在虛擬機棧里面從入棧到出棧的過程。
一個線程中的方法調用鏈可能會很長,很多方法都同時處于執行狀態。對于 JVM 的執行引擎來 說,在活動線程中,只有位于棧頂的棧幀才是有效的,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱為當前方法(Current Method)。執行引擎運行的所有 字節碼指令都只針對當前棧幀進行操作,而 棧幀的結構 如下圖所示:
Java 中當一個方法被調用時會產生一個棧幀(Stack Frame),而此方法便位于棧幀之內。而Java方法棧幀 主要包括三個部分,如下所示:
- 1)、局部變量區
- 2)、操作數棧區
- 3)、幀數據區(常量池引用)
幀數據區,即常量池引用在前面我們已經深入地了解過了,但是還有兩個重要部分我們需要了解,一個是操作數棧,另一個則是局部變量區。通常來說,程序需要將局部變量區的元素加載到操作數棧中,計算完成之后,然后再存儲回局部變量區。
查看字節碼的工具
我們可以使用 jclasslib 這個字節碼工具去查看字節碼,使用效果如下圖所示,代碼編譯后在菜單欄 ”View” 中選擇 ”Show Bytecode With jclasslib”,可以很直觀地看到當前字節碼文件的類信息、常量池、方法區等信息。
下面??,我們就先來看看操作數棧是怎么運轉的。
2、操作數棧
操作數棧是為了 存放計算的操作數和返回結果。在執行每一條指令前,JVM 要求該指令的操作數已經被壓入到操作數棧中,并且,在執行指令時,JVM 會將指令所需的操作數彈出,并將計算結果壓入操作數棧中。
對于操作數棧相關的操作指令有如下 三類:
1)、直接作用于操作數據棧的指令:
-
dup
:復制棧頂元素,常用于復制 new 指令所生成的未初始化的引用。 -
pop
:舍棄棧頂元素,常用于舍棄調用指令的返回結果。 -
wap
:交換棧頂的兩個元素的值。
需要注意的是,當值為 long 或 double 類型時,需要占用兩個棧單元,此時需要使用 dup2/pop2 指令替代 dup/pop 指令。
2)、直接將常量加載到操作數棧的指令:
對于 int(boolean、byte、char、short)
類型來說,有如下三類常用指令:
-
iconst
:用于加載 [-1 ,5] 的 int 值。 -
biconst
:用于加載一個字節(byte)所能代表的 int 值即 [-128-127]。 -
sipush
:用于加載兩個字節(short)所能代表的 int 值即 [-32768-32767]。
而對于 long、float、double、reference
類型來說,各個類型都僅有一類,其實就是類似于 iconst 指令,即 lconst、fconst、dconst、aconst
。
3)、加載常量池中的常量值的指令:
-
ldc
:用于加載常量池中的常量值,如 int、long、float、double、String、Class 類型的常量。例如 ldc #35 將加載常量池中的第 35 項常量值。
正常情況下,操作數棧的壓入彈出都是一條條指令完成。唯一的例外是在拋異常時,JVM 會清除操作數棧的所有內容,然后將異常實例壓入操作數棧中。
3、局部變量區
局部變量區一般用來 緩存計算的結果。實際上,JVM 會把局部變量區當成一個 數組,里面會依次緩存 this 指針(非靜態方法)、參數、局部變量。
需要注意的是,同操作數棧一樣,long 和 double 類型的值將占據兩個單元,而其它的類型僅僅占據一個單元。
而對于局部變量區來說,它常用的操作指令有 三種,如下所示:
1)、將局部變量區的值加載到操作數棧中
-
int(boolean、byte、char、short)
:iload -
long
:lload -
float
:fload -
double
:dload -
reference
:aload
2)、將操作數棧中的計算結果存儲在局部變量區中
-
int(boolean、byte、char、short)
:istore -
long
:lstore -
float
:fstore -
double
:dstore -
reference
:astore
這里需要注意的是,局部變量的加載與存儲指令都需要指明所加載單元的下標,例如:iload_0 就是加載普通方法局部變量區中的 this 指針。
3)、增值指令之 iinc
可以看到,上面兩種類型的指令操作都需要操作局部變量區和操作數棧,那么,有沒有 僅僅只作用在局部變量區的指令呢?
它就是 iinc M N(M為負整數,N為整數),它會將局部變量數組中的第 M 個單元中的 int 值增加 N,常用于 for 循環中自增量的更新,如 i++/i--。
了解了以上 JVM 的基礎指令之后,我們來看一個具體的栗子??,代碼和其對應的 JVM 指令如下所示:
public static int bar(int i) {
return ((i + 1) - 2) * 3 / 4;
}
// 對應的字節碼如下:
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: iconst_1
2: iadd
3: iconst_2
4: isub
5: iconst_3
6: imul
7: iconst_4
8: idiv
9: ireturn
復制代碼
這里我們解釋下上面的幾處字節碼的含義,如下所示:
-
Code
:JVM 字節碼。 -
stack
:表示該方法需要的操作數棧空間為 2。 -
locals
:表示該方法需要的局部變量區空間為 1。 -
args_size
:表示方法的參數大小為 1。
最后,我們來看看 每條指令執行前后局部變量區和操作數棧的變化情況,如下圖所示:
了解了指令在操作數棧與局部變量區之間的轉換規律,我們下面再回過頭來系統地了解下以下 九類按用途分類的字節碼指令。
4、字節碼指令用途分類匯總
1)、加載和存儲指令
加載和存儲指令用于 將數據在棧幀中的局部變量表和操作數棧之間來回傳輸,其指令如下所示:
- 1)、將一個局部變量加載到操作棧:`iload、iload_、lload、lload_、fload、fload_
、dload、dload_、aload、aload_`。
- 2)、將一個數值從操作數棧存儲到局部變量表:`istore、istore_、lstore、lstore_、
fstore、fstore_、dstore、dstore_、astore、astore_`。
- 3)、將一個常量加載到操作數棧:`bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、
iconst_m1、iconst_、lconst_、fconst_、dconst_`。
** 4)、擴充局部變量表的訪問索引的指令:wide
。
類似于 iload_,它代表了 iload_0、iload_1、iload_2 和 iload_3 這幾條指令。這幾組指令都是某個帶有一個操作數的通用指令(例如iload,iload_0 的語義與操作數為 0 時的 iload 指令語義完全一致)。
2)、運算指令
運算或算術指令用于 對兩個操作數棧上的值進行某種特定運算,并把結果重新存入到操 作棧頂。大體上算術指令可以分為 兩種:對整型數據進行運算的指令與對浮點型數據進行運算的指令。其指令如下所示:
- 1)、加法指令:
iadd、ladd、fadd、dadd
。 - 2)、減法指令:
isub、lsub、fsub、dsub
。 - 3)、乘法指令:
imul、lmul、fmul、dmul
。 - 4)、除法指令:
idiv、ldiv、fdiv、ddiv
。 - 5)、求余指令:
irem、lrem、frem、drem
。 - 6)、取反指令:
ineg、lneg、fneg、dneg
。 - 7)、位移指令:
ishl、ishr、iushr、lshl、lshr、lushr
。 - 8)、按位或指令:
ior、lor
。 - 9)、按位與指令:
iand、land
。 - 10)、按位異或指令:
ixor、lxor
。 - 11)、局部變量自增指令:
iinc
。 - 12)、比較指令:
dcmpg、dcmpl、fcmpg、fcmpl、lcmp
。
3)、類型轉換指令
類型轉換指令可以 將兩種不同的數值類型進行相互轉換,例如我們可以將小范圍類型向大范圍類型的安全轉換,其指令如下所示:
-1)、i2b、i2c、i2s
-2)、l2i
-3)、f2i、f2l
-4)、d2i、d2l、d2f
4)、對象創建與訪問指令
其指令如下所示:
- 1)、創建類實例的指令:
new
。 - 2)、創建數組的指令:
newarray、anewarray、multianewarray
。 - 3)、訪問類字段(static字段,或者稱為類變量)和實例字段(非 static 字段,或者稱為實例變量)的指令:
getfield、putfield、getstatic、putstatic
。 - 4)、把一個數組元素加載到操作數棧的指令:
baload、caload、saload、iaload、laload、 faload、daload、aaload
。 - 5)、將一個操作數棧的值存儲到數組元素中的指令:
bastore、castore、sastore、iastore、 fastore、dastore、aastore
。 - 6)、取數組長度的指令:
arraylength
。 - 7)、檢查類實例類型的指令:
instanceof、checkcast
。
5)、操作數棧管理指令
用于 直接操作操作數棧 的指令,如下所示:
- 1)、將操作數棧的棧頂一個或兩個元素出棧:
pop、pop2(用于操作 Long、Double)
。 - 2)、復制棧頂一個或兩個數值并將復制值或雙份的復制值重新壓入棧頂:
dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
。 - 3)、將棧最頂端的兩個數值互換:
swap
。
6)、控制轉移指令
控制轉移指令就是 在有條件或無條件地修改 PC 寄存器的值。其指令如下所示:
- 1)、條件分支:
ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、 if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne
。 - 2)、復合條件分支:
tableswitch、lookupswitch
。 - 3)、無條件分支:
goto、goto_w、jsr、jsr_w、ret
。
其中的 tableswitch 與 lookupswitch 含義如下:
-
tableswitch
:條件跳轉指令,針對密集的 case。 -
lookupswitch
:條件跳轉指令,針對稀疏的 case。
可以看到,Java 虛擬機提供的 int 類型的條件分支指令是最為豐富和強大的。
7)、方法調用指令
常用的有 5條 用于方法調用的指令。 如下所示:
- 1)、
invokevirtual
:用于調用對象的實例方法,根據對象的實際類型進行分派(虛方法分派),這也是 Java 語言中最常見的方法分派方式。 - 2)、
invokeinterface
:用于調用接口方法,它會在運行時搜索一個實現了這個接口方法的對象,找出適合的方法進行調用。 - 3)、
invokespecial
:用于調用一些需要特殊處理的實例方法,包括實例初始化方法、私有方法和父類方法。 - 4)、
invokestatic
:用于調用類方法(static方法)。 - 5)、
invokedynamic
:用于在運行時動態解析出調用點限定符所引用的方法,并執行該方法,前面 4 條調用指令的分派邏輯都固化在 Java 虛擬機內部,而 invokedynamic 指令的分派邏輯是由用戶所設定的引導方法決定的。
這里我們需要著重注意 invokespecial
指令,它用于 調用構造器與方法,當調用方法時,會將返回值仍然壓入操作數棧中,如果當前方法沒有返回值則需要使用 pop 指令彈出。
除了 invokespecial 之外,其它方法調用指令所消耗的操作數棧元素是根據調用類型以及目標方法描述符來確定的。
8)、方法返回指令
返回指令是區分類型的,如下所示,為不同返回類型對應的返回指令:
-
void
:return -
int(boolean、byte、char、short)
:ireturn -
long
:lreturn -
float
:freturn -
double
:dreturn -
reference
:areturn
方法調用指令與數據類型無關,而 方法返回指令是根據返回值的類型區分的,包括 ireturn(當返回值是 boolean、byte、char、short 和 int 類型時使用)、lreturn、freturn、dreturn 和 areturn,另外還有一條 return 指令供聲明為 void 的方法、實例初始化方法以及類和接口的類初始化方法使用。
9)、異常處理指令
在 Java 程序中顯式拋出異常的操作(throw語句)都由 athrow 指令來實現,在 Java 虛擬機中,處理異常是采用異常表來完成的。
10)、同步指令
Java 虛擬機可以 支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor)來支持的。
方法級的同步是隱式的,即無須通過字節碼指令來控制,它實現在方法調用和返回操作 之中。虛擬機可以從方法常量池的方法表結構中的 ACC_SYNCHRONIZED 訪問標志得知一個方法是否聲明為同步方法。
當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標志是否被設置,如果設置了,執行線程就要求先成功持有管程,然后才能執行方法,最后當方法完成(無論是正常完成還是非正常完成)時會釋放管程。
同步一段指令集序列 通常是由 Java 語言中的 synchronized 語句塊 來表示的,Java 虛擬機的指令集中有 monitorenter 和 monitorexit 兩條指令來支持 synchronized 關鍵字的語義,而正確實現 synchronized 關鍵字需要 Javac 編譯器與 Java 虛擬機兩者共同協作支持。
編譯器必須確保無論方法通過何種方式完成,方法中調用過的每條 monitorenter 指令都必須執行其對應的 monitorexit 指令,而無論這個方法是正常結束還是異常結束。并且,它會自動產生一個異常處理器,這個異常處理器被聲明可處理所有的異常,它的目的就是用來執行 monitorexit 指令。
八、總結
深入學習 JVM 字節碼無疑會對我們的整體實力有 質的提升,如果對 JVM 字節碼了解較深,那么,我們在學習 Groovy、Kotlin 等這些基于 JVM 的語言時就能夠 在較短的學習時間內進階到語言的高級層面。此外,深入了解 JVM 字節碼,能夠賦予我們通過表象透析本質的能力,而這,也正是極客們真正所追求的一通百通的靈魂之力。*
作者:jsonchao
鏈接:https://juejin.cn/post/6844904116603486222
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。