一 基礎原理:第04講:動手實踐:從棧幀看字節碼是如何在 JVM 中進行流轉的

在上一課時我們掌握了 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; 數組類型,每一位使用一個前置的"["字符來描述
  • [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 在方法調用上的一些特點。

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