JVM 內部原理(六)— Java 字節碼基礎之一


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

結束


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM