天天看點

Java 位元組碼

1.1 什麼是位元組碼?

Java 在剛剛誕生之時曾經提出過一個非常著名的口号: “一次編寫,到處運作(write once,run anywhere)”,這句話充分表達了軟體開發人員對沖破平台界限的渴求。“與平台無關”的理想最終實作在作業系統的運用層上: 虛拟機提供商開發了許多可以運作在不同平台上的虛拟機,這些虛拟機都可以載入和執行同一種平台無關的位元組碼,進而實作了程式的“一次編寫到處運作”。

各種不同平台的虛拟機與所有平台都統一使用的程式存儲格式 — 位元組碼(ByteCode),是以,可以看出位元組碼對 Java 生态的重要性。之是以被稱為位元組碼,是因為位元組碼是由十六進制組成的,而 JVM(Java Virtual Machine)以兩個十六進制為一組,即以位元組為機關進行讀取。在 Java 中使用 javac 指令把源代碼編譯成位元組碼檔案,一個 .java 源檔案從編譯成 .class 位元組碼檔案的示例如圖 1 所示:

Java 位元組碼

圖 1

對于從事基于 JVM 的語言的開發人員來說,比如: Java,了解位元組碼可以更準确、更直覺的了解 Java 語言中更深層次的東西,比如通過位元組碼,可以很直覺的看到 volatile 關鍵字如何在位元組碼上生效。另外,位元組碼增強技術在各種 ORM 架構、Spring AOP、熱部署等一些應用中經常使用,深入了解其原理對于我們來說大有裨益。由于 JVM 規範的存在,隻要最終生成了符合 JVM 位元組碼規範的檔案都可以在 JVM 上運作,是以,這個也給其它各種運作在 JVM 上的語言(如: Scala、Grovvy、Kotlin)提供了一個機會,可以擴充 Java 沒有實作的特性或者實作一些文法糖。

接下來就讓我們就一起看看這個位元組碼檔案結構到底是什麼樣的。

1.2 Java 位元組碼結構

Java 源檔案通過用 javac 指令編譯後就會得到 .class 結尾的位元組碼檔案,比如一個簡單的 JavaCodeCompilerDemo 類如圖 2 所示:null

Java 位元組碼

圖 2

編譯後生成的 .class 位元組碼檔案,打開後是一堆十六進制數,如圖 3 所示:

Java 位元組碼

圖 3

在上節提過,JVM 對于位元組碼規範是有要求的,打開編譯後的位元組碼檔案看似混亂無章,其實它是符合一定的結構規範的,JVM 規範要求每一個位元組碼檔案都要由十部分固定的順序組成的,接下來我們将一一介紹這部分,整體的組成結構如圖 4 所示:圖檔

Java 位元組碼

圖 4

(1)魔數(Magic Number) 每個位元組碼檔案的頭 4 個位元組稱為魔數,它的唯一作用是确定這個檔案是否為一個能被虛拟機接受的 Class 檔案。很多檔案存儲标準中都使用魔數來進行身份識别,譬如圖檔格式,如 gif 或者 jpg 等在檔案頭中都存有魔數。使用魔數而不是擴充名來進行識别主要是基于安全方面的考慮,因為檔案擴充名可以随意改動。魔數的固定值為: 0xCAFEBABE,魔數放在檔案頭,JVM 可以根據檔案的開頭來判斷這個檔案是否可能是一個位元組碼檔案,如果是,才會進行之後的操作。

有趣的是,魔數的固定值是 Java 之父 James Gosling 制定的,為 CafeBabe(咖啡寶貝),而 Java 的圖示為一杯咖啡。

(2)版本号(Version) 版本号為魔數之後的 4 個位元組,前兩個位元組表示次版本号(Minor Version),後兩個位元組表示主版本号(Major Version),上圖 3 中版本号為: “00 00 00 34”,次版本号轉化為十進制為 0,主版本号轉化為十進制 52(3 16^1 + 4 16^0 = 52),在 Oracle 官網中查詢序号 52 對應的 JDK 版本為 1.8,是以編譯該源代碼檔案的 Java 版本為 1.8.0。

(3)常量池(Constant Pool) 緊接着主版本号之後的位元組是常量池入口。常量池中存儲兩種類型常量: 字面量和符号運用。字面量為代碼中聲明為 final 的常量值,符号引用如類和接口的全局限定名、字段的名稱和描述符、方法的名稱和描述符。常量池整體上分為兩部分: 常量池計數器和常量池資料區,如圖 5 所示:

Java 位元組碼

圖 5

常量池計數器(constant_pool_count): 由于常量池的數量不固定,是以需要先放置兩個位元組來表示常量池容量計數值,圖 2 示例代碼的位元組碼的前十個位元組如下圖 6 所示,将十六進制的 17 轉為十進制的值為 33 (1 16^1 + 7 16^0 = 33),排除下标 0,也就是說這個類檔案有 32 個常量。

Java 位元組碼

圖 6

常量池資料區: 資料區是由(constant_pool_count - 1)個 cp_info 結構組成,一個 cp_info 的結構對應一個常量。在位元組碼中共有 14 種類型的 cp_info ,每種類型的結構都是固定的,如圖 7 所示

Java 位元組碼

圖 7

以 CONSTANT_Utf8_info 為例,它的結構如表 1 所示:

Java 位元組碼

表 1

首先第一個位元組 tag,它的取值對應圖 7 中的 Tag,由于它的類型是 CONSTANT_Utf8_info,是以值為 01(十六進制)。接下來兩個位元組辨別該字元串的長度 length,然後 length 個位元組為這個字元串具體的值。從圖 3 的位元組碼中摘取一個 cp_info 結構,将它翻譯過來後,其含義為: 該常量為 utf8 字元串,長度為 7 位元組,資料為: numberA,如圖 8 所示:

Java 位元組碼

圖 8

其它類型的 cp_info 結構在本文不在細說,和 CONSTANT_Utf8_info 的結構大同小異,都是先通過 tag 來辨別類型,然後後續的 n 個位元組來描述長度和資料。等我們對這些結構比較了解了之後,我們可以通過: javap -verbose JavaCodeCompilerDemo 指令檢視 JVM 反編譯後的完整常量池,可以看到反編譯結果可以将每一個 cp_info 結構的類型和值都很明确的呈現出來,如圖 9 所示

Java 位元組碼

圖 9

(4)通路标志(access_flag) 常量池結束之後的兩個位元組,描述該 Class 是類還是接口,以及是否被 Public、Abstract、Final 等修飾符修飾。JVM 規範規定了如下表 2 所示的 9 種通路标志。需要注意的是,JVM 并沒有窮舉所有的通路标志,而是使用按位或操作來進行描述的,比如某個類的修飾符為 public final,則對應的通路修飾符的值為 ACC_PUBLIC | ACC_FINAL,即 0x0001 | 0x0010 = 0x0011。

Java 位元組碼

表 2

(5)目前類名(this_class) 通路标志後的兩個位元組,描述的是目前類的全限定名。這兩個位元組儲存的值為常量池中的索引值,根據索引值就能在常量池中找到這個類的全限定名。

(6)父類名稱(super_class) 目前類名的後兩個位元組,描述父類的全限定名。這兩個位元組儲存的值也是在常量池中的索引值,根據索引值就能在常量池中找到這個類的父類的全限定名。

(7)接口資訊(interfaces) 父類名稱後的兩個位元組,描述這個類的接口計數器,即: 目前類或父類實作的接口數量。緊接着的 n 個位元組是所有的接口名稱的字元串常量在常量池的索引值。

(8)字段表(field_table) 字段表用于描述類和接口中聲明的變量,包含類級别的變量以及執行個體變量,但是不包含方法内部聲明的局部變量。字段表也分為兩部分,第一部分是兩個位元組,描述字段個數,第二部分是每個字段的詳細資訊 field_info。字段表結構如圖 10 所示:

Java 位元組碼

圖 10

以圖 3 中的位元組碼字段表為例,如下圖 11 所示。其中字段的通路标志查表 2,002 對應為 Private,通過索引下标在圖 9 中常量池分别得到字段名為: numberA,描述符為: I(在JVM 中的I代表 Java 中的 int)。綜上,就可以唯一确定出類 JavaCodeCompilerDemo 中聲明的變量為: private int numberA 。

Java 位元組碼

圖 11

(9)方法表(method_table) 字段表結束後為方法表,方法表也是由兩部分組成,第一部分為兩個位元組描述方法的個數,第二個部分為每個方法的詳細資訊。方法的詳細資訊包括:方法的通路标志、方法名、方法的描述符以及方法的屬性,如圖 12 所示:

Java 位元組碼
Java 位元組碼

方法的權限修飾符依然可以通過圖 9 的值查詢到,方法名和方法的描述符都是常量池的索引值,可以通過索引值在常量池中查詢得到。而方法屬性這個部分比較複雜,我們可以借助 javap -verbose 将其反編譯為人們可讀的資訊進行解讀。如圖 13 所示。我們可以看到屬性中包含三個部分:

1.Code 區: 源代碼對應的 JVM 指令操作碼,我們在位元組碼增強的時候重點操作的就是這個部分。

2.LineNumberTable: 行号表,将 Code 區的操作碼和源代碼的行号對應,Debug 時會起到作用(即: 當源代碼向下走一行,相應的需要走幾個 JVM 指令操作碼)。

3.LocalVariableTable: 本地變量表,包含 this 和局部變量,之是以可以在每一個非 static 的方法内部都可以調用到 this,是因為 JVM 将 this 作為每個方法的第一個參數隐式進行傳入。

Java 位元組碼

圖 13

(10)附加屬性表(additional_attribute_table) 位元組碼的最後一部分,存放了在檔案中類或接口所定義的屬性的基本資訊。

1.3 Java 位元組碼操作集合

在圖 13 中,Code 區的編号是 0 ~ 10,就是 .java 源檔案的方法源代碼編譯後讓 JVM 真正執行的操作碼。為了幫助人們了解,反編譯後看到的是十六進制操作碼所對應的助記符,十六進制值操作碼和助記符的對應關系,以及每個操作碼的具體作用可以檢視 Oracle 官網,在需要的時候查閱即可。比如上圖 13 的助記符為 iconst_2,對應圖 3 中的位元組碼 0x05,作用是将 int 值 2 壓入操作數棧中。以此類推,對 0 ~ 10 的助記符了解後就是整個 sum() 方法的操作數位實作。

1.4 檢視位元組碼工具

如果我們每次反編譯都要使用 javap 指令的話,确實比較繁瑣,這裡我推薦大家一個 IDEA 插件: jclasslib。使用效果如圖 14 所示: 代碼編譯後在菜單欄: View —> Show Bytecode With jclasslib,可以很直覺地看到目前位元組碼檔案的類資訊、常量池、方法區等資訊,非常友善。

Java 位元組碼

圖 14

1.5 總結

Java 中位元組碼檔案是 JVM 執行引擎的資料入口,也是 Java 技術體系的基礎構成之一。了解位元組碼檔案的組成結構對後面進一步了解虛拟機和深入學習 Java 有很重要的意義。本文較為詳細的講解了位元組碼檔案結構的各個組成部分,以及每個部分的定義、資料結構和使用方法。強烈建議自己動手分析一下,會了解得更加深入。