深入探索編譯插樁技術(三、解密 JVM 字節碼)

前言

成爲一名優秀的Android開發,需要一份完備的 知識體系,在這裏,讓我們一起成長爲自己所想的那樣~。

本篇是 《深入探索編譯插樁技術》系列文章 的第三篇,相比前兩篇文章來說,難度上升了不止一個檔次,所以含金量比較高。並且,擁有紮實的 JVM 字節碼基礎能讓我們更好地掌握 ASM 這個強大的編譯插樁工具,而靈活地運用 ASM 能讓我們的個人以及項目團隊的生產力有質的提升,這一點,無論是在中小型公司,還是在一二線的大公司來說,都是能極大地提升自身的生產力以及個人與團隊的價值。因此,掌握 JVM 字節碼便是一件迫不及待的事情了。

下面👇,讓我們揚帆起航開始我們的 JVM 字節碼探索之旅 吧。

本文吸取了市面上絕大部分經典 JVM 著作與優秀博文的優勢之處,將其中易於理解的部分重新編排並精心處理,相比於僅僅閱讀一本 JVM 書籍來說,能讓我們在更短的時間內去理解更多對我們重要的知識。注意:標 🔥 的章節爲重點章節,建議多多複習,加深對其的理解。

思維導圖大綱

目錄

一、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_version2 個字節長,表示當前 Class 文件的次版號
  • 3)、major_version2 個字節長,表示當前 Class 文件的主版本號。(Java 的版本號是從 45 開始 的,JDK 1.1 之後的每個 JDK 大版本發佈會在主版本號向上加 1(JDK 1.0~1.1 使用了 45.0~45.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 信息,並且每一個 cpu_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)、lengthlength 表示 bytes 的長度,比如 length = 10,則表示接下來的數據是 10 個連續的 u1 類型數據
  • 3)、bytesu1 類型數組,保存有真正的常量數據

常量項 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_Float_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"
  • 2、引用數據類型
    • ClassName => L + 全路徑類名(其中的 “.” 替換爲 “/”,最後加分號),例如 String => Ljava/lang/String
  • 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)、SignatureJDK 1.5 中新增的屬性,用於支持泛型情況下的方法簽名,由於 Java 的泛型採用擦除法實現,在爲了避免類型信息被擦除後導致簽名混亂,需要這個屬性記錄泛型中的相關信息
  • 9)、SourceFile包含一個指向 Utf8 常量項的索引,即 Class 對應的源碼文件名
  • 10)、SourceDebugExtension:用於存儲額外的調試信息。
  • 11)、LineNumberTableJava 源碼的行號與字節碼指令的對應關係
  • 12)、LocalVariableTable局部變量數組/本地變量表,用於保存變量名,變量定義所在行
  • 13)、LocalVariableTypeTableJDK 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_lengthattribute_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,對應十進制的 0~255,也就是一共可以表達 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_pclength 這兩個參數
決定了一個局部變量在 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
  • longlload
  • floatfload
  • doubledload
  • referenceaload

2)、將操作數棧中的計算結果存儲在局部變量區中

  • int(boolean、byte、char、short)istore
  • longlstore
  • floatfstore
  • doubledstore
  • referenceastore

這裏需要注意的是,局部變量的加載與存儲指令都需要指明所加載單元的下標,例如: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

這裏我們解釋下上面的幾處字節碼的含義,如下所示:

  • CodeJVM 字節碼
  • stack表示該方法需要的操作數棧空間爲 2
  • locals表示該方法需要的局部變量區空間爲 1
  • args_size表示方法的參數大小爲 1

最後,我們來看看 每條指令執行前後局部變量區和操作數棧的變化情況,如下圖所示:

瞭解了指令在操作數棧與局部變量區之間的轉換規律,我們下面再回過頭來系統地瞭解下以下 九類按用途分類的字節碼指令

4、字節碼指令用途分類彙總

1)、加載和存儲指令

加載和存儲指令用於 將數據在棧幀中的局部變量表和操作數棧之間來回傳輸,其指令如下所示:

  • 1)、將一個局部變量加載到操作棧iload、iload_<n>、lload、lload_<n>、fload、fload_ <n>、dload、dload_<n>、aload、aload_<n>
  • 2)、將一個數值從操作數棧存儲到局部變量表istore、istore_<n>、lstore、lstore_<n>、 fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
  • 3)、將一個常量加載到操作數棧bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、 iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
  • 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)、方法返回指令

返回指令是區分類型的,如下所示,爲不同返回類型對應的返回指令:

  • voidreturn
  • int(boolean、byte、char、short)ireturn
  • longlreturn
  • floatfreturn
  • doubledreturn
  • referenceareturn

方法調用指令與數據類型無關,而 方法返回指令是根據返回值的類型區分的,包括 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 字節碼,能夠賦予我們通過表象透析本質的能力,而這,也正是極客們真正所追求的一通百通的靈魂之力

參考鏈接:


Contanct Me

● 微信:

歡迎關注我的微信:bcce5360

● 微信羣:

由於微信羣已超過 200 人,麻煩大家想進微信羣的朋友們,加我微信拉你進羣。

● QQ羣:

2千人QQ羣,Awesome-Android學習交流羣,QQ羣號:959936182, 歡迎大家加入~

About me

很感謝您閱讀這篇文章,希望您能將它分享給您的朋友或技術羣,這對我意義重大。

希望我們能成爲朋友,在 Github掘金上一起分享知識。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章