在說道 JVM 虛擬機的時候,很多人都會想到 Java 語言,誠然,Java 語言和 JVM 虛擬機息息相關,但是 .class 文件與 JVM 虛擬機的關系比 Java 語言和虛擬機的關系還要親密。為什么這樣說呢?因為 Java 語言經過編譯器編譯之后生成的 .class 文件才是真正運行在 JVM 虛擬機中的文件,而不是 .java 文件。
經過這么多年的發展,可以以 JVM 虛擬機為平臺運行的語言,不止 Java 語言,包括 Kotlin、Groovy、Scala 等語言現在都是運行在 JVM 虛擬機上的語言,而且這些語言都是通過編譯之后生成 .class 文件之后,再運行在 JVM 虛擬機上的,比起 .java、.groovy 等文件,.class 文件對于 JVM 虛擬機更加重要。如果足夠牛逼,寫一個編譯器將 .c 文件編譯成 .class 文件,運行在 JVM 虛擬機上也是可以的。其實這也就是所謂的 Java 虛擬機的 “語言無關性”
本篇文章將介紹 .class 文件的結構,通過一個簡單的例子認識 .class 文件。
一. 簡介
寫一個簡單的 Demo.java 程序如下所示
package com.lijiankun24.classpractice;
public class Demo {
private int m;
public int inc() {
return m + 1;
}
}
使用 javac 命令編譯 Demo.java 文件生成 Demo.class 文件
$ javac Demo.java
接著用文本編輯器打開生成的 Demo.class 文件,如下所示
cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4465 6d6f 2e6a 6176 610c
0007 0008 0c00 0500 0601 0004 4465 6d6f
0100 106a 6176 612f 6c61 6e67 2f4f 626a
6563 7400 2100 0300 0400 0000 0100 0200
0500 0600 0000 0200 0100 0700 0800 0100
0900 0000 1d00 0100 0100 0000 052a b700
01b1 0000 0001 000a 0000 0006 0001 0000
0001 0001 000b 000c 0001 0009 0000 001f
0002 0001 0000 0007 2ab4 0002 0460 ac00
0000 0100 0a00 0000 0600 0100 0000 0600
0100 0d00 0000 0200 0e
可以看到,該文件中是由十六進制符號組成的,這一段十六進制符號組成的長串是遵守 Java 虛擬機規范的
二. Java 虛擬機規范
在 Java 虛擬機規范中規定了 Java 虛擬機結構、Class 類文件結構、字節碼指令等內容,可以參考 GitHub 上的《Java 虛擬機規范》
2.1 Java 虛擬機
- 可以說 Java 虛擬機有兩大特性:平臺無關性和語言無關性,本篇文章主要介紹語言無關性的重要知識:.class 文件結構
- Java 虛擬機就是一個虛擬的計算機,與真實的計算機一樣,Java 虛擬機有自己完善的硬件體系,如處理器、堆棧、寄存器,還有相應的指令集系統。虛擬機與真實電腦的唯一區別就是:虛擬機的處理器、內存堆棧是用軟件虛擬出來的,而真實的電腦的處理器、內存則是真真實實存在的
- 在 Java 虛擬機規范中,介紹的 Java 虛擬機的整體架構、Java 虛擬機內存區域、垃圾回收、.class 文件結構、類加載機制和 Java 虛擬機指令集。在本篇文章中主要介紹 .class 文件結構,其他內容可以查閱相關書籍和 Java 虛擬機規范
2.2 class 類文件結構
.class 文件是一組以 8 位字節為基礎單位的二進制流,各數據項目嚴格按照順序緊湊地排列在 .class 文件中,中間沒有添加任何分隔符,這使得整個 .class 文件中存儲的內容幾乎全都是程序需要的數據,沒有空隙存在
.class 文件是以類似于 C 語言結構體的結構來存儲數據的,其中存儲的數據有兩種:無符號數和表
無符號數屬于最基本的數據類型,以 u1、u2、u4、u8 分別代碼 1 個字節、2 個字節、4 個字節和 8 個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照 UTF-8 編碼構成的字符串值
表是一種復合數據結構,由無符號數或其他表構成,所有表都習慣性地以 “info” 結尾
在 .class 中有一個集合的概念。集合表示同一類數據項的集合,一般是由一個前置的計數器加若干個連續的同樣類型的數據項組成,計數器表示此集合中數據項的個數,數據項是真正的數據內容
-
整個 .class 文件本質上就是一張表,由下表所示的數據項構成
class.png 上面的表其實可以劃分為以下七個部分,.class 字節碼文件包括:
- 魔數與class文件版本
- 常量池
- 訪問標志
- 類索引、父類索引、接口索引
- 字段表集合
- 方法表集合
- 屬性表集合
三. class 文件詳解
我們通過 Demo.class 為例講解 .class 文件的 7 個部分
3.1 魔數和 class 文件版本
3.1.1 概念介紹
在魔數和 class 文件版本中有如下四點需要介紹:
- 魔數(Magic Number):.class 文件的第 1 - 4 個字節,它唯一的作用就是確定這個文件是否是一個能被虛擬機接受的 class 文件,其固定值是:0xCAFEBABE(咖啡寶貝)。如果一個 class 文件的魔術不是 0xCAFEBABE,那么虛擬機將拒絕運行這個文件
- 次版本號(minor version):.class 文件的第 5 - 6 個字節,即編譯生成該 .class 文件的 JDK 次版本號
- 主版本號(major version):.class 文件的第 7 - 8個字節,即編譯生成該 .class 文件的 JDK 主版本號
-
Note:高版本的 JDK 能向下兼容低版本的 .class 文件,但不能運行新版本的 .class 文件。例如一個 .class 文件是使用 JDK 1.5 編譯的,那么我們可以用 JDK 1.7 虛擬機運行它,但不能用 JDK 1.4 虛擬機運行它。各個版本的 SDK 的次版本號和主版本號如下表所示
sdk.png
3.1.2 示例
在上面的 Demo.class 文件中,Magic Number:0xcafe babe,minor version:0x0000,major version:0x0034,可見我們是使用 JDK 1.8 編譯生成的 Demo.class 文件
3.2 常量池
3.2.1 概念介紹
緊接著版本號之后的是常量池的入口,常量池可以理解為 class 文件之中的資源倉庫,它是占用 class 文件空間最大的數據項之一。
常量池是一個集合,它由兩部分組成:常量池計數器和常量池
- 常量池計數器(constant_pool_count) 是一個 u2 的無符號數
- 常量池(constant_pool):緊跟在常量池計數器后面的內容就是該 .class 文件的常量池內容了,常量池中存放的數據一般分為兩種類型:字面量和符號引用
- 字面量:是指文本字符串、聲明為 final 的常量值等
- 符號引用: 是一個更偏向于編譯原理方面的概念,主要包括三類常量:1). 類和接口的全限定名,2).字段的名稱和描述符,3). 方法的名稱和描述符
-
在常量池中的常量共有 14 種類型,每個常量都是一個表,每一個表都有各自的組成結構。這 14 個常量有一個公共的特點,就是每個常量開始是一個用 u1 類型的無符號數表示的標志位(tag,取值見下表),表示此常量屬于哪種常量類型
cp_info.png
3.2.2 示例
在上面的 Demo.class 文件中,常量池開始的偏移地址是:0x0008。
- 首先是常量計數器(constant_pool_coun),數值是:0x0013,表示此 Demo.class 文件中共有 18 個常量
- cp_info_constant_pool[1]:偏移地址是 0x000A,內容是:0x0A0004000F。0x0A 標志位表示是一個 CONSTANT_Methodref_info 常量,0x0004 是一個索引,指向常量池中第 4 個常量所表示的信息;0x000F 是一個索引,指向常量池第 15 個常量所表示的信息。CONSTANT_Methodref_info 常量的結構如下所示:
tag index index u1 u2 u2 10 索引項:指向聲明方法的類描述符 CONSTANT_Class_info 索引項:指向名稱及類型描述符 CONSTANT_NameAndType - cp_info_constant_pool[2]:偏移地址是0x000F,內容是:0x0900030010,0x09 表示此常量是一個 CONSTANT_Fieldref_info 常量,0x0003 表示一個索引,指向常量池第 3 個常量所表示的信息;0x0010 是一個索引,表示指向常量池第 16 個常量所表示的信息。CONSTANT_Fieldref_info 常量的結構如下所示:
tag index index u1 u2 u2 9 索引項:指向聲明字段的類描述符 CONSTANT_Class_info 索引項:指向字段描述符 CONSTANT_NameAndType - cp_info_constant_pool[3]:偏移地址是0x0014,內容是:0x070011。0x07 標志位表示此常量是一個 CONSTANT_Class_info 常量,索引 0x0011 指向常量池中第 17 個常量。CONSTANT_Class_info 常量的結構如下所示:
tag index u1 u2 7 索引項:指向全限定名常量項的索引 - cp_info_constant_pool[4]:偏移地址是0x0017,內容是:0x070012。0x07 標志位表示此常量是一個 CONSTANT_Class_info 常量,索引 0x0012 指向常量池中第 18 個常量。
- cp_info_constant_pool[5]:偏移地址是0x001A,內容是:0x0100016D。0x01 表示此常量是一個 CONSTANT_Utf8_info 常量,0x0001 表示 UTF-8 編碼的字符串占用的字節數;0x6D 表示 長度為 1 的 UTF-8 編碼的字符串的內容: m。CONSTANT_Utf8_info 常量的結構如下所示:
tag length bytes u1 u2 u1 1 Utf-8 編碼的字符串占用的字節數 length 長度的 UTF-8 編碼的字符串內容 - cp_info_constant_pool[6]:偏移地址是0x001E,內容是:0x01000149。0x01 表示此常量是一個 CONSTANT_Utf8_info 常量,0x0001 表示 UTF-8 編碼的字符串占用的字節數;0x49 表示長度為 1 的 UTF-8 編碼的字符串的內容: I。
- cp_info_constant_pool[7]:偏移地址是0x0022,內容是:0x0100063C696E69743E。0x01 表示此常量是一個 CONSTANT_Utf8_info 常量,0x0006 表示字符串長度為 6,0x3C696E69743E 表示長度為 6 的 UTF-8 編碼的字符串的內容: <init>。
上面分析了 7 個常量,其余的常量也是類似的方法。根據第一個 u1 的標志位,就知道這個常量的類型和表結構,就可以知道這個常量的長度大小和代表的含義了。我們也可以通過 “javap -verbose” 命令查看 .class 文件的內容,如下圖所示:
3.3 訪問標志
3.3.1 概念介紹
常量池之后是 u2 類型的訪問標志位(access_flags),這個訪問標志位用于標識類或者接口層次的訪問信息,包括:這個 Class 是類還是接口、是否定義為public類型、是否定義為abstract類型,如果是類的話,是否被 final 關鍵字修飾。具體的標志位以及標志的含義見下表
3.3.2 示例
在 Demo.class 文件中訪問標志位是:0x0021。在上表中,我們并沒有發現 00 21 的訪問標志,這是因為在字節碼文件中的訪問標志,可以通過上表中多個訪問標志通過或運算組成真正的訪問標志。通過上表中的 ACC_SUPER 和 ACC_PUBLIC 就可以組合中 00 21 的訪問標志了,也就是說該類的訪問標志是 public 且允許使用 invokespecial 字節碼指令的新語義的
3.4 類索引、父類索引、接口索引
3.4.1 概念介紹
在 .class 文件中由這三項數據來確定這個類的繼承關系。
- 類索引:u2 數據類型,用于確定這個類的全限定名。
- 父類索引:u2 數據類型,用于確定這個類的父類的全限定名。
- 接口索引:u2 數據類型的集合,用于描述類實現了哪些接口,這些被實現的接口將按照 implements 語句后的順序從左至右排列在接口索引集合中。接口索引集合分為兩部分,第一部分表示接口計數器(interfaces_count),是一個 u2 類型的數據,第二部分是接口索引表表示接口信息,緊跟在接口計數器之后。若一個類實現的接口為 0,則接口計數器的值為 0,接口索引表不占用任何字節。
3.4.2 示例
在此 Demo.class 文件中,類索引、父類索引、接口索引分別如下:
- 類索引:偏移地址是 0x00B3,內容是 0x0003,表示其指向了常量池中第 3 個常量 CONSTANT_Class_info,第 3 個常量索引指向第 17 個常量,第 17 個常量是一個 UTF-8 編碼的字符串,其值是:com/lijiankun24/classpractice/Demo,表示此類的全限定名
- 父類索引:偏移地址是 0x00B5,內容是 0x0004,其指向了常量池中第 4 個常量 CONSTANT_Class_info,第 4 個常量索引指向第 18 個常量,第 18 個常量的值是:java/lang/Object,表示父類的全限定名
- 接口索引:偏移地址是 0x00B7,內容是 0x0000。因為 Demo 類沒有實現任何接口,所以接口索引的計數器是 0,表示沒有接口索引。
3.5 字段表集合
3.5.1 概念介紹
字段表集合用于描述接口或類中聲明的變量。這里說的字段包括類級變量(static 修飾)和對象級變量(沒有用 static 修飾),但不包括方法中聲明的局部變量。
字段表集合包括兩部分:字段計數器和字段表,字段計數器表示有多少個字段,字段表的每個字段用一個名為 field_info 的表來表示,field_info 表的數據結構如下所示:
字段表都包含的固定數據項目到 descriptor_index 為止就結束了,不過在 descriptor_index 之后跟隨著一個屬性表集合用于存儲一些額外的信息,字段都可以在屬性表中描述零至多項的額外信息。在字段描述符之后,一般會有該字段的屬性表集合,屬性表集合有兩部分,第一部分是屬性計數器,第二部分是屬性表。
在字段表集合中不會列出父類的字段,但是有可能會有一些 Java 代碼中沒有聲明的字段,比如在內部類中為了保持對外部類的訪問性,會自動添加指向外部類實例的字段
3.5.2 示例
在 Demo.class 文件中的字段表集合的偏移地址是:0x00B9,內容是:0x 0001 0002 0005 0006 0000.
- 0x0001 表示字段計數器是 1,表示只有 1 個字段
- field_info_fields[0]:偏移地址是 0x00BB,內容是 0x0002 0005 0006 0000,根據字段表的結構來分析這段數據
- 0x0002 表示該字段的訪問標識,0002 表示是 private 的
- 0x0005 表示該字段的名稱索引項,指向常量池中的第 5 個常量,第 5 個常量是一個 UTF-8 的字符串 m
- 0x0006 表示該字段的描述符索引項,指向常量池中的第 6 個常量,第 6 個常量是一個 UTF-8 的字符串 I,I 描述符表示是一個 int 類型的字段
- 0x0000 表示 m 字段的屬性表集合,屬性表集合計數器是 0,表示此字段沒有額外的屬性信息。
3.6 方法表集合
3.6.1 概念介紹
在字段表之后緊跟著方法表集合,方法表表示類或接口中的方法信息。
方法表集合和上述的字段表集合幾乎完全一樣,最開始的 2 個字節表示一個方法計數器,在方法計數器之后,才是真正的方法數據項。方法表中的每個方法都用一個 method_info 表示,其數據結構如下:
在方法表結構中,我們可以看到方法的訪問標志位、名稱索引、描述符索引、屬性表集合,方法中的代碼在編譯之后,放到方法屬性表集合中的一個名為 “code” 的屬性里面
3.6.2 示例
- 在 Demo.class 中方法表集合的偏移地址是:0x00C3,方法表集合計數器是 0x0002, 表示此方法表集合中有兩個方法表數據項。可能有人會有疑問,Demo.java 中我們只寫了一個方法,為什么在方法表中會有兩個方法呢?因為編譯器會自動添加實例構造器 <init> 方法
- method_info_methods[0]:偏移地址是:0x00C5,內容是:0x00 0100 0700 0800 0100 0900 0000 1d00 0100 0100 0000 052a b700 01b1 0000 0001 000a 0000 0006 0001 0000 0003。
0x0001:access_flags 表示ACC_PUBLIC,即表示該方法是 public 的
0x0007:name_index 表示方法名稱索引,指向常量池中的第 7 個常量,第 7 個常量是一個 UTF-8 字符串,值是:<init>
0x0008:descriptor_index 表方法描述符索引項,指向常量池中的第 8 個常量,是一個 UTF-8 字符串,值是:()V
0x0001:表示此方法的屬性表集合計數器,有 1 個屬性
-
0x0009:表示此屬性的 attribute_name_index,指向常量池中的第 9 UTF-8 常量:Code,說明此屬性是方法的字節碼描述 Code 屬性
Code.png
那么我們就依次按照上表的結構分析此實例構造器的字節碼內容,至于具體的字節碼含義會在后面的文章中分析介紹
0x0009:上面已經介紹過,表示此 Code 屬性的名稱索引,其值就是 “Code” 字符串
0x0000 001d:attribute_length 表示屬性長度為 29 個字節
0x0001:max_stack 表示操作數棧的最大深度是 1
0x0001:max_locals 表示局部變量表的最大長度是 1
0x0000 0005:code_length 表示字節碼指令長度是 5,共有 5 個字節碼指令
0x2ab7 0001 b1:這 5 個 u1 數據,表示 3 個字節碼指令,0x2a = aload_0,0xb7 = invokespecial,0x0001 = 表示一個指向常量池的索引,是 invokespecial 指令的參數,0xb1 = return 表示從當前方法返回
0x0000 exception_table_length=0,異常表集合長度為 0
0x0001:attributes_count=1(Code屬性表內部還含有1個屬性表)
-
0x000a:指向常量池中的第十個常量:LineNumberTable,LineNumberTable 屬性結構如下圖所示,內容是:0000 0006 0001 0000 0003
LineNumberTable.jpeg 0x0000 0006:attribute_length 表示屬性長度為 6
0x0001:line_number_table_length,表示后面的 line_number_info 表有 1 個,line_number_info表包括了 start_pc 和 line_number 兩個 u2 類型的數據項,前者是字節碼行號,后者是 java 源碼行號:start_pc:00 00,end_pc:00 03
-
method_info_methods[1]:上面我們分析了第一個方法:實例構造器方法,分析流程就是上面這樣,方法表有固定的結構,其中包含一些固定的信息,包括操作數棧最大深度、局部變量表最大長度、以及很重要的 Code 屬性,在 Code 屬性中包含 java 方法編譯生成的字節碼指令,如果想快速的瀏覽方法表集合的內容,也可以使用 "javap -verbose Demo.class" 指令查看,如下圖所示
Demo.png
3.7 屬性表集合
3.7.1 概念介紹
- 在 class 文件、字段表、方法表都可以攜帶自己的屬性表集合,用以描述某些場景專有的信息。
- 屬性表的格式是相對固定的,包括三部分內容:
- 一個 u2 的 attribute_name_index 指向常量池中的一個 UTF-8 字符串常量表示一個屬性名稱
- 一個 u4 的數據類型表示 attribute_length 表示該屬性值的字節長度
-
該長度的屬性值信息,結構如下圖所示:
attribute.png
- 對于屬性表的限制來說相對較寬松,任何人實現的編譯器都可以向屬性表中寫入自定義的屬性值信息,Java 虛擬機對于它自己不認識的屬性值則會忽略掉。
- 在 Java 7 虛擬機規范中已經預定義了 21 項屬性
3.7.2 示例
- Demo.class 中屬性表的偏移地址是:0x011D,內容是 0x00 0100
0D00 0000 0200 0E - 0x0001 表示此屬性表集合的計數器是1,有 1 個屬性
- attribute_info_attributes[0]:偏移地址是:0x011F,內容是 0x00 0D00 0000 0200 0E
-
0x000D:指向常量池中的第 13 個 Utf-8 常量:SourceFile,SourceFile 屬性用于記錄生成這個 Class 文件的源碼文件名稱,其結構如下圖所示:
SourceFile.png 0x0000 0002:attribute_length 屬性長度是 2
00 0E:sourcefile_index 指向常量池中第 14 個常量 Demo.java
-
3.8 010 Editor
分析 .class 文件結構是比較枯燥無聊的,但是如果可以看懂 .class 文件結構的內容,并且理解其中的含義,知道 .class 文件結構中 Code 屬性中字節碼指令的執行過程,對我們的 Java 能力提升還是比較大的。
分析 .class 文件結構,我們可以使用 "javap -verbose Demo.class" 指令查看,我們也可以使用 010 Editor 軟件分析,可以方便的查看各個數據項的地址偏移量、數據項內容。比如,我們想查看第 4 個常量池的內容,如下圖所示
寫在最后,這篇文章分析了 .class 文件的結構,知道了其本質是以 8 位字節為單位存儲的二進制流文件,那虛擬機是如何加載并分析執行其中的內容的呢?這些內容我將在后面的文章中介紹,敬請期待。