在上一課時我們掌握了 JVM 的內存區域劃分,以及 .class 文件的加載機制。也了解到很多初始化動作是在不同的階段發生的。
但你可能仍對以下這些問題有疑問:
* 怎么查看字節碼文件?
* 字節碼文件長什么樣子?
* 對象初始化之后,具體的字節碼又是怎么執行的?
帶著這些疑問,我們進入本課時的學習,本課時將帶你動手實踐,詳細分析一個 Java 文件產生的字節碼,并從棧幀層面看一下字節碼的具體執行過程。
### 工具介紹
工欲善其事,必先利其器。在開始本課時的內容之前,先給你介紹兩個分析字節碼的小工具。
#### javap
第一個小工具是 javap,javap 是 JDK 自帶的反解析工具。它的作用是將 .class 字節碼文件解析成可讀的文件格式。我們在第一課時,就是用的它輸出了 HelloWorld 的內容。
在使用 javap 時我一般會添加 -v 參數,盡量多打印一些信息。同時,我也會使用 -p 參數,打印一些私有的字段和方法。使用起來大概是這樣:
```
javap -p -v HelloWorld
```
在 Stack Overflow 上有一個非常有意思的問題:我在某個類中增加一行注釋之后,為什么兩次生成的 .class 文件,它們的 MD5 是不一樣的?
這是因為在 javac 中可以指定一些額外的內容輸出到字節碼。經常用的有
* javac -g:lines 強制生成 LineNumberTable。
* javac -g:vars 強制生成 LocalVariableTable。
* javac -g 生成所有的 debug 信息。
為了觀察字節碼的流轉,我們本課時就會使用到這些參數。
#### jclasslib
如果你不太習慣使用命令行的操作,還可以使用 jclasslib,jclasslib 是一個圖形化的工具,能夠更加直觀的查看字節碼中的內容。它還分門別類的對類中的各個部分進行了整理,非常的人性化。同時,它還提供了 Idea 的插件,你可以從 plugins 中搜索到它。
如果你在其中看不到一些諸如 LocalVariableTable 的信息,記得在編譯代碼的時候加上我們上面提到的這些參數。
jclasslib 的下載地址:https://github.com/ingokegel/jclasslib
### 類加載和對象創建的時機
接下來,我們來看一個稍微復雜的例子,來具體看一下類加載和對象創建的過程。
首先,我們寫一個最簡單的 Java 程序 A.java。它有一個公共方法 test,還有一個靜態成員變量和動態成員變量。
```
class B {
private int a = 1234;
static long C = 1111;
public long test(long num) {
long ret = this.a + num + C;
return ret;
}
}
public class A {
private B b = new B();
public static void main(String[] args) {
A a = new A();
long num = 4321 ;
long ret = a.b.test(num);
System.out.println(ret);
}
}
```
前面我們提到,類的初始化發生在類加載階段,那對象都有哪些創建方式呢?除了我們常用的 new,還有下面這些方式:
* 使用 Class 的 newInstance 方法。
* 使用 Constructor 類的 newInstance 方法。
* 反序列化。
* 使用 Object 的 clone 方法。
其中,后面兩種方式沒有調用到構造函數。
當虛擬機遇到一條 new 指令時,首先會檢查這個指令的參數能否在常量池中定位一個符號引用。然后檢查這個符號引用的類字節碼是否加載、解析和初始化。如果沒有,將執行對應的類加載過程。
拿我們上面的代碼來說,執行 A 代碼,在調用 private B b = new B() 時,就會觸發 B 類的加載。

讓我們結合上圖回顧一下前面章節的內容。A 和 B 會被加載到元空間的方法區,進入 main 方法后,將會交給執行引擎執行。這個執行過程是在棧上完成的,其中有幾個重要的區域,包括虛擬機棧、程序計數器等。接下來我們詳細看一下虛擬機棧上的執行過程。
### 查看字節碼
#### 命令行查看字節碼
使用下面的命令編譯源代碼 A.java。如果你用的是 Idea,可以直接將參數追加在 VM options 里面。
```
javac -g:lines -g:vars A.java
```
這將強制生成 LineNumberTable 和 LocalVariableTable。
然后使用 javap 命令查看 A 和 B 的字節碼。
```
javap -p -v A
javap -p -v B
```
這個命令,不僅會輸出行號、本地變量表信息、反編譯匯編代碼,還會輸出當前類用到的常量池等信息。由于內容很長,這里就不具體展示了,你可以使用上面的命令實際操作一下就可以了。
注意 javap 中的如下字樣。
<1>
```
1: invokespecial #1 // Method java/lang/Object."<init>":()V
```
可以看到對象的初始化,首先是調用了 Object 類的初始化方法。注意這里是 `<init>` 而不是 `<cinit>`。
<2>
```
#2 = Fieldref #6.#27 // B.a:I
```
它其實直接拼接了 #13 和 #14 的內容。
```
#6 = Class #29 // B
#27 = NameAndType #8:#9 // a:I
...
#8 = Utf8 a
#9 = Utf8 I
```
<3>
你會注意到 :I 這樣特殊的字符。它們也是有意義的,如果你經常使用 jmap 這種命令,應該不會陌生。大體包括:
* B 基本類型 byte
* C 基本類型 char
* D 基本類型 double
* F 基本類型 float
* I 基本類型 int
* J 基本類型 long
* S 基本類型 short
* Z 基本類型 boolean
* V 特殊類型 void
* L 對象類型,以分號結尾,如 Ljava/lang/Object;
* [Ljava/lang/String; 數組類型,每一位使用一個前置的"["字符來描述
我們注意到 code 區域,有非常多的二進制指令。如果你接觸過匯編語言,會發現它們之間其實有一定的相似性。但這些二進制指令,并不是操作系統能夠認識的,它們是提供給 JVM 運行的源材料。
#### 可視化查看字節碼
接下來,我們就可以使用更加直觀的工具 jclasslib,來查看字節碼中的具體內容了。
我們以 B.class 文件為例,來查看它的內容。
<1>
首先,我們能夠看到 Constant Pool(常量池),這些內容,就存放于我們的 Metaspace 區域,屬于非堆。

常量池包含 .class 文件常量池、運行時常量池、String 常量池等部分,大多是一些靜態內容。
<2>
接下來,可以看到兩個默認的 <init> 和 <cinit> 方法。以下截圖是 test 方法的 code 區域,比命令行版的更加直觀。

<3>
繼續往下看,我們看到了 LocalVariableTable 的三個變量。其中,slot 0 指向的是 this 關鍵字。該屬性的作用是描述幀棧中局部變量與源碼中定義的變量之間的關系。如果沒有這些信息,那么在 IDE 中引用這個方法時,將無法獲取到方法名,取而代之的則是 arg0 這樣的變量名。

本地變量表的 slot 是可以復用的。注意一個有意思的地方,index 的最大值為 3,證明了本地變量表同時最多能夠存放 4 個變量。
另外,我們觀察到還有 LineNumberTable 等選項。該屬性的作用是描述源碼行號與字節碼行號(字節碼偏移量)之間的對應關系,有了這些信息,在 debug 時,就能夠獲取到發生異常的源代碼行號。
### test 函數執行過程
#### Code 區域介紹
test 函數同時使用了成員變量 a、靜態變量 C,以及輸入參數 num。我們此時說的函數執行,內存其實就是在虛擬機棧上分配的。下面這些內容,就是 test 方法的字節碼。
```
public long test(long);
descriptor: (J)J
flags: ACC_PUBLIC
Code:
stack=4, locals=5, args_size=2
0: aload_0
1: getfield #2 // Field a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3 // Field C:J
10: ladd
11: lstore_3
12: lload_3
13: lreturn
LineNumberTable:
line 13: 0
line 14: 12
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this LB;
0 14 1 num J
12 2 3 ret J
```
我們介紹一下比較重要的 3 三個數值。
<1>
首先,注意 stack 字樣,它此時的數值為 4,表明了 test 方法的最大操作數棧深度為 4。JVM 運行時,會根據這個數值,來分配棧幀中操作棧的深度。
<2>
相對應的,locals 變量存儲了局部變量的存儲空間。它的單位是 Slot(槽),可以被重用。其中存放的內容,包括:
* this
* 方法參數
* 異常處理器的參數
* 方法體中定義的局部變量
<3>
args_size 就比較好理解。它指的是方法的參數個數,因為每個方法都有一個隱藏參數 this,所以這里的數字是 2。
#### 字節碼執行過程
我們稍微回顧一下 JVM 運行時的相關內容。main 線程會擁有兩個主要的運行時區域:Java 虛擬機棧和程序計數器。其中,虛擬機棧中的每一項內容叫作棧幀,棧幀中包含四項內容:局部變量報表、操作數棧、動態鏈接和完成出口。
我們的字節碼指令,就是靠操作這些數據結構運行的。下面我們看一下具體的字節碼指令。

(1)0: aload_0
把第 1 個引用型局部變量推到操作數棧,這里的意思是把 this 裝載到了操作數棧中。
對于 static 方法,aload_0 表示對方法的第一個參數的操作。

(2)1: getfield #2
將棧頂的指定的對象的第 2 個實例域(Field)的值,壓入棧頂。#2 就是指的我們的成員變量 a。
```
#2 = Fieldref #6.#27 // B.a:I
...
#6 = Class #29 // B
#27 = NameAndType #8:#9 // a:I
```

(3)i2l
將棧頂 int 類型的數據轉化為 long 類型,這里就涉及我們的隱式類型轉換了。圖中的信息沒有變動,不再詳解介紹。
(4)lload_1
將第一個局部變量入棧。也就是我們的參數 num。這里的 l 表示 long,同樣用于局部變量裝載。你會看到這個位置的局部變量,一開始就已經有值了。

(5)ladd
把棧頂兩個 long 型數值出棧后相加,并將結果入棧。

(6)getstatic #3
根據偏移獲取靜態屬性的值,并把這個值 push 到操作數棧上。

(7)ladd
再次執行 ladd。

(8)lstore_3
把棧頂 long 型數值存入第 4 個局部變量。
還記得我們上面的圖么?slot 為 4,索引為 3 的就是 ret 變量。

(9)lload_3
正好與上面相反。上面是變量存入,我們現在要做的,就是把這個變量 ret,壓入虛擬機棧中。

(10)lreturn
從當前方法返回 long。
到此為止,我們的函數就完成了相加動作,執行成功了。JVM 為我們提供了非常豐富的字節碼指令。詳細的字節碼指令列表,可以參考以下網址:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html
#### 注意點
注意上面的第 8 步,我們首先把變量存放到了變量報表,然后又拿出這個值,把它入棧。為什么會有這種多此一舉的操作?原因就在于我們定義了 ret 變量。JVM 不知道后面還會不會用到這個變量,所以只好傻瓜式的順序執行。
為了看到這些差異。大家可以把我們的程序稍微改動一下,直接返回這個值。
```
public long test(long num) {
return this.a + num + C;
}
```
再次看下,對應的字節碼指令是不是簡單了很多?
```
0: aload_0
1: getfield #2 // Field a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3 // Field C:J
10: ladd
11: lreturn
```
那我們以后編寫程序時,是不是要盡量少的定義成員變量?
這是沒有必要的。棧的操作復雜度是 O(1),對我們的程序性能幾乎沒有影響。平常的代碼編寫,還是以可讀性作為首要任務。
### 小結
本課時,我們學會了使用 javap 和 jclasslib 兩個工具。平常工作中,掌握第一個就夠了,后者主要為我們提供更加直觀的展示。
我們從實際分析一段代碼開始,詳細介紹了幾個字節碼指令對程序計數器、局部變量表、操作數棧等內容的影響,初步接觸了 Java 的字節碼文件格式。
希望你能夠建立起一個運行時的脈絡,在看到相關的 opcode 時,能夠舉一反三的思考背后對這些數據結構的操作。這樣理解的字節碼指令,根本不會忘。
你還可以嘗試著對 A 類的代碼進行分析,我們這里先留下一個懸念。課程后面會詳細介紹 JVM 在方法調用上的一些特點。
### 課后問答
* 1、程序計數器在main方法調用test方法時,執行main方法時的程序計數器是存儲起來嗎?
答案:程序計數器是針對于字節碼指令的,主要線程切換的時候使用。你說的這種函數調用情況,是虛擬機棧里棧幀的的范疇。
* 2、“本地變量表的 slot 是可以復用的。注意一個有意思的地方,index 的最大值為 3,證明了本地變量表同時最多能夠存放 4 個變量。”LocalVariableTable:SignatureJIIIJ這是我自己javap的一個類,為啥可以到6呢?
答案:這里說的是示例代碼的最大值。如果你創建了上千個變量,最大值達到1k都有可能。
* 3、首先,我們能夠看到 Constant Pool(常量池),這些內容,就存放于我們的 Metaspace 區域,屬于非堆”“常量池包含 .class 文件常量池、運行時常量池、String 常量池等部分”您好,請問jdk1.8之后String對象是在堆中還是在Metaspace?
答案:你應該說的是常量池,從1.7就在堆,之后也一直在堆。
* 4、opcode 前面的偏移地址為什么不是連續的呢。
答案:偏移量是字節單位,不是指令的個數,它包含操作數等額外數量。
- 前言
- 開篇詞
- 基礎原理
- 第01講:一探究竟:為什么需要 JVM?它處在什么位置?
- 第02講:大廠面試題:你不得不掌握的 JVM 內存管理
- 第03講:大廠面試題:從覆蓋 JDK 的類開始掌握類的加載機制
- 第04講:動手實踐:從棧幀看字節碼是如何在 JVM 中進行流轉的
- 垃圾回收
- 第05講:大廠面試題:得心應手應對 OOM 的疑難雜癥
- 第06講:深入剖析:垃圾回收你真的了解嗎?(上)
- 第06講:深入剖析:垃圾回收你真的了解嗎?(下)
- 第07講:大廠面試題:有了 G1 還需要其他垃圾回收器嗎?
- 第08講:案例實戰:億級流量高并發下如何進行估算和調優
- 實戰部分
- 第09講:案例實戰:面對突如其來的 GC 問題如何下手解決
- 第10講:動手實踐:自己模擬 JVM 內存溢出場景
- 第11講:動手實踐:遇到問題不要慌,輕松搞定內存泄漏
- 第12講:工具進階:如何利用 MAT 找到問題發生的根本原因
- 第13講:動手實踐:讓面試官刮目相看的堆外內存排查
- 第14講:預警與解決:深入淺出 GC 監控與調優
- 第15講:案例分析:一個高死亡率的報表系統的優化之路
- 第16講:案例分析:分庫分表后,我的應用崩潰了
- 進階部分
- 第17講:動手實踐:從字節碼看方法調用的底層實現
- 第18講:大廠面試題:不要搞混 JMM 與 JVM
- 第19講:動手實踐:從字節碼看并發編程的底層實現
- 第20講:動手實踐:不為人熟知的字節碼指令
- 第21講:深入剖析:如何使用 Java Agent 技術對字節碼進行修改
- 第22講:動手實踐:JIT 參數配置如何影響程序運行?
- 第23講:案例分析:大型項目如何進行性能瓶頸調優?
- 彩蛋
- 第24講:未來:JVM 的歷史與展望
- 第25講:福利:常見 JVM 面試題補充