JVM 內部原理(六)— Java 位元組碼基礎之一

Richaaaard發表於2016-12-23

JVM 內部原理(六)— Java 位元組碼基礎之一

介紹

版本:Java SE 7

為什麼需要了解 Java 位元組碼?

無論你是一名 Java 開發者、架構師、CxO 還是智慧手機的普通使用者,Java 位元組碼都在你面前,它是 Java 虛擬機器的基礎。

總監、管理者和非技術人員可以放輕鬆點:他們所要知道的就是開發團隊在正在進行下一版的開發,Java 位元組碼默默的在 JVM 平臺上執行。

簡單地說,Java 位元組碼是 Java 程式碼(如,class 檔案)的中間表現形式,它在 JVM 內部執行,那麼為什麼你需要關心它?因為如果沒有 Java 位元組碼,Java 程式就無法執行,因為它定義了 Java 開發者編寫程式碼的方式。

從技術角度看,JVM 在執行時將 Java 位元組碼以 JIT 的編譯方式將它們轉換成原生程式碼。如果沒有 Java 位元組碼在背後執行,JVM 就無法進行編譯並對映到原生程式碼上。

很多 IT 的專業技術人員可能沒有時間去學習彙編程式或者機器碼,可以將 Java 位元組碼看成是某種與底層程式碼相似的程式碼。但當出問題的時候,理解 JVM 的基本執行原理對解決問題非常有幫助。

在本篇文章中,你會知道如何閱讀與編寫 JVM 位元組碼,更好的理解執行時的工作原理,以及結構某些關鍵庫的能力。

本篇文章會包括一下話題:

  • 如何獲得位元組碼列表
  • 如何閱讀位元組碼
  • 語言結構是如何被編譯器對映的:區域性變數,方法呼叫,條件邏輯
  • ASM 簡介
  • 位元組碼在其他 JVM 語言(如,Groovy 和 Kotlin)中是如何工作的

目錄

  • 為什麼需要了解 Java 位元組碼?
  • 第一部分:Java 位元組碼簡介
    • 基礎
    • 基本特性
    • JVM 棧模型
      • 方法體裡面是什麼?
      • 區域性棧詳解
      • 區域性變數詳解
      • 流程控制
      • 算術運算及轉換
      • new & &
      • 方法呼叫及引數傳遞
  • 第二部分:ASM
    • ASM 與工具
  • 第三部分:Javassist
  • 總結

Java 位元組碼簡介

Java 位元組碼是 JVM 裡指令執行的形式。Java 程式設計師通常不需要知道 Java 位元組碼是如何工作的。不過了解平臺底層的細節可以讓我們成為更好的程式設計師(我們都想成為更好的程式設計師,難道不是嗎?)

理解位元組碼以及 Java 編譯器是如何生成位元組碼所帶來的幫助,與 C 或 C++ 程式設計師具有組合語言的知識一樣。

瞭解位元組碼對於編寫程式工具和程式分析至關重要,應用程式可以根據不同的領域修改位元組碼,調整應用程式的行為。效能分析工具,mocking 框架,AOP,要想編寫這些工具,程式設計師就需要透徹理解 Java 位元組碼。

基礎

讓我們用一個非常基礎的例子來帶大家瞭解 Java 位元組碼是如何執行的。看看這個簡單的表示式,1 + 2,用逆波蘭式表示為 1 2 + 。這裡使用逆波蘭式標記有什麼好處呢?因為這種表示式可以很容易的用棧來計算:

在執行完 “add” 指令後,結果 3 處於棧頂位置。

image

Java 位元組碼的計算模型是一個面向棧的程式語言。以上例子用 Java 位元組碼指令表示是一樣的,唯一的不同是操作碼有一些特定的語法:

image

操作碼 iconst_1iconst_2 將常量 1 和 2 分別進行入棧操作。指令 iadd 對兩個整數進行求和操作,並將結果入棧到棧頂。

基本特性

就如名字裡暗示的那樣,Java 位元組碼 包括一個位元組的指令,所以操作碼有 256 種可能。真實的指令比允許的數量略少,大概使用的操作碼有 200 個,有些操作碼是為偵錯程式(debugger)操作保留的。

指令是由一個型別字首和操作名組成。例如,“i” 字首表示 “integer”(整形),因此 iadd 指令表示求和操作是針對整數的。

根據指令的性質,我們可以將它們分為四類:

  • 棧操作指令,包括本地變數的迭代
  • 流程控制指令
  • 物件操作,包括方法呼叫
  • 算術和型別轉換

也有指令是為一些特別的任務使用的,比如同步和丟擲異常。

javap

為了得到編譯好的類檔案的指令列表,可以使用 javap 工具,這個標準的 Java 類檔案反編譯器是與 JDK 一起釋出的。

讓我們用一個應用程式(移動平均值計算器)作為示例:

public class Main {
    public static void main(String[] args){
        MovingAverage app = new MovingAverage();
    }
}

在類檔案被編譯後,為了得到以上位元組碼列表可以執行一下命令:

javap -c Main

結果如下:

Compiled from "Main.java"
public class algo.Main {
  public algo.Main();
    Code:
    0: aload_0       
    1: invokespecial #1         // Method java/lang/Object."":()V
    4: return        

  public static void main(java.lang.String[]);
    Code:
    0: new           #2         // class algo/MovingAverage
    3: dup           
    4: invokespecial #3             // Method algo/MovingAverage."":()V
    7: astore_1      
    8: return        
}

可以發現有預設的構造器和一個主方法。Java 程式設計師可能都知道,如果沒有為類指定任何構造器,仍然會有一個預設的構造器,但是可能並沒有意識到它到底在哪。對,就在這裡!這個預設的構造器就存在於被編譯好的類中,所以它是 Java 編譯器生成的。

構造器體是空的,但仍然會生成一些指令。為什麼呢?每個構造器都會呼叫 super() ,對嗎?這並不是自然而然生成的,這也是位元組碼指令生成預設構造器的原因。基本上這就是 super() 的呼叫。

主方法建立了 MovingAverage 類的一個例項,然後返回。

可能你已經注意到有些指令引用 #1、#2、#3 這些數字引數。這些都是指向常量池的引用。那麼我們如何找到這些常量?又如何檢視列表中的常量池呢?可以通過使用帶 -verbose 引數的 javap 對類進行反編譯:

$ javap -c -verbose HelloWorld

以下列印出的部分有些地方比較有趣:

Classfile /Users/anton/work-src/demox/out/production/demox/algo/Main.class
  Last modified Nov 20, 2012; size 446 bytes
  MD5 checksum ae15693cf1a16a702075e468b8aaba74
  Compiled from "Main.java"
public class algo.Main
  SourceFile: "Main.java"
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#21         //  java/lang/Object."":()V
   #2 = Class              #22            //  algo/MovingAverage
   #3 = Methodref          #2.#21         //  algo/MovingAverage."":()V
   #4 = Class              #23            //  algo/Main
   #5 = Class              #24            //  java/lang/Object

這裡有關於類的很多資訊:它是何時編譯的,MD5 校驗值是什麼?它是由哪個 *.java 檔案編譯而成的,它遵從 Java 的版本是什麼,等等。

我們也可以看到訪問標識(accessor flags):ACC_PUBLIC 和 ACC_SUPER 。ACC_PUBLIC 標識從直觀上比較容易理解:我們類是公有的,因此訪問標識表明它是公有的。但 ACC_SUPER 有什麼作用呢?ACC_SUPER 的引入是為了解決通過 invokespecial 指令呼叫 super 方法的問題。可以將它理解成 Java 1.0 的一個缺陷補丁,只有通過這樣它才能正確找到 super 類方法。從 Java 1.1 開始,編譯器始終會在位元組碼中生成 ACC_SUPER 訪問標識。

也可以在常量池找到所表示的常量定義:

 #1 = Methodref          #5.#21         //java/lang/Object."":()V

常量的定義是可組成的,也就是說常量也可以由引用到相同表的其他常量組成。

當使用 javap -verbose 引數時,也可以發現其他的一些細節。方法可以輸出更多資訊:

public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
  

訪問標識也會在方法中生成,同時也可以看到一個方法執行所需要的棧深度是多少,接收多少引數,以及本地變數表需要為本地變數保留多少個引數。

JVM 棧模型

為了更詳細的理解位元組碼,我們對位元組碼的執行模型有概念。JVM 是一個基於堆疊模式的虛擬機器。每個執行緒都有一個 JVM 棧用來儲存棧楨資訊。每次方法被呼叫時都有楨被建立。楨內包括運算元棧,本地變數列表,以及當前類當前方法的執行時常量池的引用。這些都可以在開始的反編譯的 Main 類中看到。

本地變數陣列也被稱為本地變數列表,它包括方法的引數,同時也用來保持本地變數的值。本地變數列表的大小是在編譯時決定的,取決於數字和本地變數的大小和方法的引數。

image

運算元棧是一個後進先出(LIFO)棧,用來對值進行入棧和出棧的操作。它的大小也是在編譯時決定的。有些操作碼指令將值入棧到運算元棧;有些進行出棧操作,對它們進行計算,並將結果入棧。運算元棧也用來接收方法的返回值。

在除錯工具中,我們可以進行逐楨回退,但欄位的狀態並不會回退到之前狀態。

image

方法體裡面是什麼?

在檢視 HelloWorld 例子中的位元組碼列表時,可能會想知道,每條指令前的數字表示什麼?為什麼數字之間的間隔不相等?

0: new           #2       // class algo/MovingAverage
3: dup           
4: invokespecial #3       // Method algo/MovingAverage."":()V
7: astore_1      
8: return

原因:有些操作碼有引數需要佔用位元組碼列表空間。例如,new 佔用了列表中的三個位置:一個位置是留給它自己的,另外兩個是留給輸入引數的。因此,下一個指令 dup 處於下標索引 3 的位置。

以下如圖所示,我們將方法體看成一個陣列:

image

每條指令都有自己的十六進位制表示形式,我們可以得到方法體以十六進位制字串來表示如下:

image

用十六進位制編輯器開啟類檔案可以找到一下字串:

image

也可以通過十六進位制編輯器來修改位元組碼,儘管這麼做比較易錯。除此之外還有一些更簡單的方式,可以使用位元組碼操作工具比如 ASM 或 Javassist 。

目前還和這個知識點沒有太大關係,不過現在你已經知道這些數字的來源是什麼。

區域性棧詳解

操作棧的方式有多種多樣。我們已經提到過一些基本棧操作指令:對值進行入棧或出棧操作。swap 指令可以將棧頂的兩個值進行交換。

這裡有些對棧內值進行操作的指令的示例。有些基本指令:dup 和 pop 。dup 指令將棧頂的值重複並再次入棧。pop 指令移除棧頂的值。

也有一些更復雜的指令如:swapdup_x1dup2_x1 。swap 指令和它名稱預示的一樣,將棧頂的兩個值進行交換,如 A 和 B 交換位置;dup_x1 將棧頂處的值複製並插入到棧的底部(如 5)。dup2_x1 將棧頂處的兩個值複製並插入到棧的底部(如 6)。

image

dup_x1dup2_x1 指令看上去有點難懂 - 為什麼會有人需要這種行為 - 複製棧頂的值並插入到棧底部?這裡有一些更實際的例子:如何交換兩個 double 型別的值?這裡的問題是 double 型別需要佔用棧中的兩個位置,這也就意味著如果我們有兩個 double 值,那麼在棧中就會佔四個位置。為了交換兩個 double 值我們可能會想到使用 swap 指令,但問題是它只能操作一個字的指令,也就是說它無法操作 double ,指令 swap2 也不存在。替代方案可以使用 dup2_x2 指令複製棧頂的兩個值,並將它們插入到棧底,然後我們可以使用 pop2 指令。這樣,就能成功交換兩個 double 值。

image

區域性變數詳解

棧是用來執行的,本地變數是用來儲存中間結果的,直接與棧發生互動。

現在讓我們在之前的示例中增加一些程式碼:

public static void main(String[] args) {
  MovingAverage ma = new MovingAverage();

  int num1 = 1;
  int num2 = 2;

  ma.submit(num1);
  ma.submit(num2);

  double avg = ma.getAvg();
}

我為 MovingAverage 類提供兩個值,並讓他計算當前值的平均值。得到的 bytecode 如下:

Code:
    0: new           #2          // class algo/MovingAverage
    3: dup          
    4: invokespecial #3          // Method algo/MovingAverage."":()V
    7: astore_1     

    8: iconst_1     
    9: istore_2   

    10: iconst_2     
    11: istore_3     

    12: aload_1      
    13: iload_2      
    14: i2d          
    15: invokevirtual #4         // Method algo/MovingAverage.submit:(D)V

    18: aload_1      
    19: iload_3      
    20: i2d          
    21: invokevirtual #4         // Method algo/MovingAverage.submit:(D)V

    24: aload_1      
    25: invokevirtual #5         // Method algo/MovingAverage.getAvg:()D
    28: dstore      4
    LocalVariableTable:
    Start  Length  Slot  Name   Signature
            0   31  0  args   [Ljava/lang/String;
            8   23  1  ma     Lalgo/MovingAverage;
            10      21  2  num1   I
            12      19  3  num2   I
            30      1   4  avg    D

在建立好 MovingAverage 型別的本地變數後將值儲存到本地變數 ma 中,用 astore_1 指令:1 是 ma 在本地變數表(LocalVariableTable)中的序號位置。

接著,指令 iconst_1iconst_2 用來載入常量 1 和 2 將其入棧,然後通過 istore_2istore_3 指令將它們存入 LocalVariableTable 的 2 和 3 的位置。

注意呼叫類 store 的指令實際上是進行出棧操作,這也是為什麼為了再次使用變數值的時候,我們需要再次將其載入棧中。例如,在上述列表中,在呼叫 submit 方法之前,我們需要將引數的值再次載入棧中:

12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method algo/MovingAverage.submit:(D)V

在呼叫 getAvg() 方法後返回的結果會入棧並存再次入本地變數中,使用 dstore 指令是因為目標變數的型別是 double 。

24: aload_1
25: invokevirtual #5 // Method algo/MovingAverage.getAvg:()D
28: dstore 4

更有趣的事情是本地變數列表(LocalVariableTable)第一個位置是由方法引數所佔的。在我們當前的示例中,它是一個靜態方法,在表中沒有 this 的引用指向 0 位置。但是,對於非靜態方法,this 會指向 0 位置。

image

將這部分放在一邊,一旦你想為本地變數賦值,這也意味著你想用相應的指令將其儲存起來(store),例如,astore_1 。store 指令總是對棧頂的值進行出棧操作。相應的 load 指令會將值從本地變數列表取出並寫入棧中,不過這個值不會從本地變數刪除。

流程控制

流程控制指令會根據不同情況組織執行順序。If-Then-Else,三元操作碼,各種迴圈,甚至各種錯誤處理操作碼(opcodes)也屬於 Java 位元組碼 流程控制。現在這些概念都變成了 jumps 和 gotos 。

現在我們對示例做一些更改,讓它可以處理任意數目的數字傳入到 MovingAverage 類的 submit 方法中:

MovingAverage ma = new MovingAverage();
for (int number : numbers) {
    ma.submit(number);
}

假設變數 numbers 是同一個類的靜態欄位。與在 numbers 上迴圈迭代對應的位元組碼如下:

0: new #2 // class algo/MovingAverage
3: dup
4: invokespecial #3 // Method algo/MovingAverage."":()V
7: astore_1
8: getstatic #4 // Field numbers:[I
11: astore_2
12: aload_2
13: arraylength
14: istore_3
15: iconst_0
16: istore 4
18: iload 4
20: iload_3
21: if_icmpge 43
24: aload_2
25: iload 4
27: iaload
28: istore 5
30: aload_1
31: iload 5
      33: i2d          
      34: invokevirtual #5       // Method algo/MovingAverage.submit:(D)V
      37: iinc          4, 1
      40: goto          18
      43: return
    LocalVariableTable:
    Start  Length  Slot  Name   Signature
         30       7     5  number I
         12      31     2  arr$   [I
         15      28     3  len$   I
         18      25     4  i$     I
          0      49     0  args   [Ljava/lang/String;
          8      41     1  ma     Lalgo/MovingAverage;
         48     1   2  avg    D

在 8 和 16 位置的指令是用來組織迴圈控制的。可以看到在本地變數列表( LocalVariableTable )中有三個變數,它們沒有在原始碼中體現: arr$len$i$ ,這些都是迴圈變數。變數 arr$ 儲存的 numbers 欄位,迴圈的長度 len$ 來自於陣列長度指令 arraylength 。迴圈計數器, i$ 在每個迴圈後用 iinc 指令增加。

迴圈體的第一個指令是用來比較迴圈計數器與陣列長度的:

18: iload 4
20: iload_3
21: if_icmpge 43

我們載入 i$len$ 到棧中並呼叫 if_icmpge 來比較值的大小。 if_icmpge 指令的意思是如果一個值大於或等於另外一個值,在本例中就是如果 i$ 大於或等於 len$ ,那麼執行會從被標記為 43 的語句執行。如果沒有滿足條件,則迴圈繼續執行下一個迭代。

在迴圈結束時,迴圈計數器增加 1 迴圈跳回到迴圈條件開始的位置再次校驗:

37: iinc          4, 1       // increment i$
40: goto        18         // jump back to the beginning of the loop

算術運算及轉換

正如所見的那樣,在 Java 位元組碼中,有一系列的指令可以進行算術運算。事實上,有很大一部分的指令集是用來表示算術運算的。有針對於各種整型、長整型、雙精度、浮點數的加、減、乘、除、取負指令。除此之外,還有很多指令用來在不同型別間進行轉換。

算術操作碼及其型別

型別轉換髮生在比如當我們想將整型值(integer)賦值到長整型(long)變數時。

image

Type conversion opcodes

在我們的例子中,整型值作為引數傳入實際接收雙精度的 submit() 方法,可以看到在方法真實呼叫之前,會應用到型別轉換操作碼:

image

31: iload       5   
33: i2d          
34: invokevirtual #5     // Method algo/MovingAverage.submit:(D)V

這表示我們將本地變數值以 integer 型別進行入棧操作,然後用 i2d 指令將其轉換成 double 從而可以將其作為引數傳入。

唯一不要求值在棧中的指令就是增量指令,iinc,它可以直接操作本地變數表(LocalVariableTable)上的值。其他所有的操作都是使用棧的。

new & <init> & <clinit>

在 Java 中有關鍵字 new ,在位元組碼指令中也有 new 的指令。當我們建立 MovingAverage 類例項時:

MovingAverage ma = new MovingAverage();

編譯器生成一系列如下形式的操作碼:

0: new #2 // class algo/MovingAverage
3: dup
4: invokespecial #3 // Method algo/MovingAverage."":()V

當你看到 newdupinvokespecial 指令時,這時通常就代表著類例項的建立!

你可能會問,為什麼是三條指令而不是一條?new 指令建立物件,但它並沒有呼叫構造器,不過會呼叫 invokespecial 指令:它呼叫了一個特別的方法,它其實是構造器。因為構造器呼叫不返回值,在物件呼叫這個方法後,物件會被初始化,但此時棧是空的,在物件初始化之後,我們無法做任何事情。這正是為什麼我們需要提前在堆疊中複製引用,在構造器返回後可以將物件例項賦值到本地變數或欄位。因此,下一條指令通常是以下指令中的一條:

  • astore {N}astore_{N} – 給本地變數賦值,{N} 是變數在本地變數表的位置。
  • putfield – 為例項欄位賦值
  • putstatic – 為靜態變數賦值

在呼叫構造器之前,有另外一個類似的方法在此之前被呼叫。它是這個類的靜態初始器。類的靜態初始器並不是直接被呼叫的,而是由以下指令觸發:new、getstatic、putstatic 或 invokestatic 。也就是說,如果你建立了類的一個例項,訪問一個靜態欄位或呼叫一個靜態方法,靜態的初始器會被觸發。

事實上,想要觸發靜態初始器的方式有很多,參見 The Java® Language Specification - Java SE 7 Edition

方法呼叫及引數傳遞

在類例項化的內容中,我們簡單介紹了方法的呼叫:通過 invokespecial 指令呼叫的方法會呼叫構造器。但是,還有一些指令也用作於方法呼叫:

  • invokestatic 正如名稱所示,它呼叫類的靜態方法。這裡它是方法呼叫最快的指令。
  • invokespecial 如我們知道的那樣,指令用來呼叫構造器。但它也用來呼叫同一類的私有方法,以及父類可訪問的方法。
  • invokevirtual 用來呼叫公有,受保護的以及包私有方法,如果方法的目標物件是具體型別。
  • invokeinterface 用來呼叫屬於介面的方法。

那麼 invokevirtualinvokeinterface 的區別是什麼呢?

這確實是個好問題。為什麼我們同時需要 invokevirtualinvokeinterface ,為什麼不在所有地方使用 invokevirtual ?介面方法也還是公有方法啊!好,這都是為了方法呼叫的優化。首先,方法被解析,然後呼叫它。例如,有了 invokestatic 我們知道具體那個方法被呼叫了:它是靜態的,只屬於一個類。有了 invokespecial 我們的可選項是一個有限的列表,更容易選擇解析策略,意味著執行時能更快找到需要的方法。

invokevirtualinvokeinterface 的區別並不是那麼明顯。我們對兩個指令的區別提供一個非常簡單的解釋。試想類定義包括一個方法定義的列表,所有的方法都是按位置進行編號的。這裡有個例子:類 A 有方法 method1 和 method2 以及一個子類 B ,子類 B 繼承了 method1 覆寫了 method2,並宣告瞭方法 method3 。注意到 method1 和 method2 在類 A 和類 B 中處於同一索引下標位置。

class A
    1: method1
    2: method2

class B extends A
    1: method1
    2: method2
    3: method3

這意味著如果執行時想要呼叫方法 method2 ,它始終會在位置 2 被找到。現在,解釋 invokevirtualinvokeinterface 之前,讓類 B 擴充套件介面 X 定義一個新的方法 methodX :

class B extends A implements X
    1: method1
    2: method2
    3: method3
    4: methodX

新方法在下標 4 的位置而且看上去和 method3 沒有兩樣。但是,如果有另外一個類 C ,也實現了介面,但是和 A 和 B 的結構不太一樣:

class C implements  X 
    1: methodC
    2: methodX

介面方法的位置和類 B 中的位置不太一樣,這也是為什麼 invokeinterface 在執行時更加嚴格,也就是說它在方法解析過程中要比 invokeinterface 做更少的推斷假設。

參考

參考來源:

The Java® Language Specification - Java SE 7 Edition

The Java® Language Specification - Chapter 6. The Java Virtual Machine Instruction Set

2015.01 A Java Programmer’s Guide to Byte Code

2012.11 Mastering Java Bytecode at the Core of the JVM

2011.01 Java Bytecode Fundamentals

2001.07 Java bytecode: Understanding bytecode makes you a better programmer

Wiki: Java bytecode

Wiki: Java bytecode instruction listings

結束

相關文章