天天看點

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

作者:一個即将被退役的碼農

參考文檔:

深入了解JVM虛拟機——JVM運作時棧結構和方法調用

深入了解JVM虛拟機——JVM是怎麼實作invokedynamic的

深入了解JVM虛拟機——類的加載機制

深入了解JVM虛拟機——JIT編譯器

開篇

Java 是一門面向對象,靜态類型的語言,具有跨平台的特點,與 C,C++ 這些需要手動管理記憶體,編譯型的語言不同,它是解釋型的,具有跨平台和自動垃圾回收的特點,那麼它的跨平台到底是怎麼實作的呢?

我們知道計算機隻能識别二進制代碼表示的機器語言,是以不管用的什麼進階語言,最終都得翻譯成機器語言才能被 CPU 識别并執行,對于 C++這些編譯型語言來說是直接一步到位轉為相應平台的可執行檔案(即機器語言指令),而對 Java 來說,則首先由編譯器将源檔案編譯成位元組碼,再在運作時由虛拟機(JVM)解釋成機器指令來執行,我們可以看下下圖

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

也就是說 Java 的跨平台其實是通過先生成位元組碼,再由針對各個平台實作的 JVM 來解釋執行實作的,JVM 屏蔽了 OS 的差異,我們知道 Java 工程都是以 Jar 包分發(一堆 class 檔案的集合體)部署的,這就意味着 jar 包可以在各個平台上運作(由相應平台的 JVM 解釋執行即可),這就是 Java 能實作跨平台的原因所在

這也是為什麼 JVM 能運作 Scala、Groovy、Kotlin 這些語言的原因,并不是 JVM 直接來執行這些語言,而是這些語言最終都會生成符合 JVM 規範的位元組碼再由 JVM 執行,不知你是否注意到,使用位元組碼也利用了計算機科學中的分層理念,通過加入位元組碼這樣的中間層,有效屏蔽了與上層的互動差異。

JVM 是怎麼執行位元組碼的

在此之前我們先來看下 JVM 的整體記憶體結構,對其有一個宏觀的認識,然後再來看 JVM 是如何執行位元組碼的

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

JVM 記憶體結構

JVM 在記憶體中主要分為「棧」,「堆」,「非堆」以及 JVM 自身,堆主要用來配置設定類執行個體和數組,非堆包括「方法區」、「JVM内部處理或優化所需的記憶體(如JIT編譯後的代碼緩存)」、每個類結構(如運作時常數池、字段和方法資料)以及方法和構造方法的代碼

我們主要關注棧,我們知道線程是 cpu 排程的最小機關,在 JVM 中一旦建立一個線程,就會為其配置設定一個線程棧,線程會調用一個個方法,每個方法都會對應一個個的棧幀壓到線程棧裡,JVM 中的棧記憶體結構如下

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

JVM 棧記憶體結構

至此我們總算接近 JVM 執行的真相了,JVM 是以棧幀為機關執行的,棧幀由以下四個部分組成

  • 傳回值
  • 局部變量表(Local Variables):存儲方法用到的本地變量
  • 動态連結:在位元組碼中,所有的變量和方法都是以符号引用的形式儲存在 class 檔案的常量池中的,比如一個方法調用另外的方法,是通過常量池中指向方法的符号引用來表示的,動态連結的作用就是為了将這些符号引用轉換為調用方法的直接引用,這麼說可能有人還是不了解,是以我們先執行一下 javap -verbose Demo.class指令來檢視一下位元組碼中的常量池是咋樣的
深入了解JVM虛拟機——java位元組碼技術及其指令剖析

注意:以上隻列出了常量池中的部分符号引用

可以看到 Object 的 init 方法是由 #4.#16 表示的,而 #4 又指向了 #19,#19 表示 Object,#16 又指向了 #7.#8,#7 指向了方法名,#8 指向了 ()V(表示方法的傳回值為 void,且無方法參數),位元組碼加載後,會把類資訊加載到元空間(Java 8 以後)中的方法區中,動态連結會把這些符号引用替換為調用方法的直接引用,如下圖示

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

那為什麼要提供動态連結呢,通過上面這種方式繞了好幾個彎才定位到具體的執行方法,效率不是低了很多嗎,其實主要是為了支援 Java 的多态,比如我們聲明一個 Father f = new Son()這樣的變量,但執行 f.method() 的時候會綁定到 son 的 method(如果有的話),這就是用到了動态連結的技術,在運作時才能定位到具體該調用哪個方法,動态連結也稱為後期綁定,與之相對的是靜态連結(也稱為前期綁定),即在編譯期和運作期對象的方法都保持不變,靜态連結發生在編譯期,也就是說在程式執行前方法就已經被綁定,java 當中的方法隻有final、static、private和構造方法是前期綁定的。而動态連結發生在運作時,幾乎所有的方法都是運作時綁定的

舉個例子來看看兩者的差別,一目了然

class Animal{
  public void eat(){
  	System.out.println("動物進食");
	}
}

class Cat extends Animal{
    @Override
    public void eat() {
   			 super.eat();//表現為早期綁定(靜态連結)
    		 System.out.println("貓進食");
    }
}

public class AnimalTest {
    public void showAnimal(Animal animal){
   				animal.eat();//表現為晚期綁定(動态連結)
		}
 }           
  • 操作數棧(Operand Stack):程式主要由指令和操作數組成,指令用來說明這條操作做什麼,比如是做加法還是乘法,操作數就是指令要執行的資料,那麼指令怎麼擷取資料呢,指令集的架構模型分為基于棧的指令集架構和基于寄存器的指令集架構兩種,JVM 中的指令集屬于前者,也就是說任何操作都是用棧來管理,基于棧指令可以更好地實作跨平台,棧都是是在記憶體中配置設定的,而寄存器往往和硬體挂鈎,不同的硬體架構是不一樣的,不利于跨平台,當然基于棧的指令集架構缺點也很明顯,基于棧的實作需要更多指令才能完成(因為棧隻是一個FILO結構,需要頻繁壓棧出棧),而寄存器是在CPU的高速緩存區,相較而言,基于棧的速度要慢不少,這也是為了跨平台而做出的一點性能犧牲,畢竟魚和熊掌不可兼得。

1 Java 位元組碼簡介

注意線程中還有一個「PC 程式計數器」,是每個線程獨有的,記錄着目前線程所執行的位元組碼的行号訓示器,也就是指向下一條指令的位址,也就是将執行的指令代碼。由執行引擎讀取下一條指令。我們先來看下看一下位元組碼長啥樣。假設我們有以下 Java 代碼

package com.mahai;public class Demo {

    private int a = 1;

    public static void foo() {

        int a = 1;

        int b = 2;

        int c = (a + b) * 5;

   }
}           

執行 javac Demo.java 後可以看到其位元組碼如下

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

位元組碼是給 JVM 看的,是以我們需要将其翻譯成人能看懂的代碼,好在 JDK 提供了反解析工具 javap ,可以根據位元組碼反解析出 code 區(彙編指令)、本地變量表、異常表和代碼行偏移量映射表、常量池等資訊。我們執行以下指令來看下根據位元組碼反解析的檔案長啥樣(更詳細的資訊可以執行 javap -verbose 指令,在本例中我們重點關注 Code 區是如何執行的,是以使用了 javap -c 來執行

javap -c Demo.class

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

轉換成這種形式可讀性強了很多,那麼aload_0,invokespecial 這些表示什麼含義呢, javap 是怎麼根據位元組碼來解析出這些指令出來的呢

首先我們需要明白什麼是指令,指令=操作碼+操作數,操作碼表示這條指令要做什麼,比如加減乘除,操作數即操作碼操作的數,比如 1+ 2 這條指令,操作碼其實是加法,1,2 為操作數,在 Java 中每個操作碼都由一個位元組表示,每個操作碼都有對應類似 aload_0,invokespecial,iconst_1 這樣的助記符,有些操作碼本來就包含着操作數,比如位元組碼 0x04 對應的助記符為 iconst_1, 表示 将 int 型 1 推送至棧頂,這些操作碼就相當于指令,而有些操作碼需要配合操作數才能形成指令,如位元組碼 0x10 表示 bipush,後面需要跟着一個操作數,表示 将單位元組的常量值(-128~127)推送至棧頂。以下為列出的幾個位元組碼與助記符示例

位元組碼 助記符 表示含義
0x04 iconst_1 将int型1推送至棧頂
0xb7 invokespecial 調用超類建構方法, 執行個體初始化方法, 私有方法
0x1a iload_0 将第一個int型本地變量推送至棧頂
0x10 bipush 将單位元組的常量值(-128~127)推送至棧頂

至此我們不難明白 javap 的作用了,它主要就是找到位元組碼對應的的助記符然後再展示在我們面前的,我們簡單看下上述的預設構造方法是如何根據位元組碼映射成助記符并最終呈現在我們面前的:

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

最左邊的數字是 Code 區中每個位元組的偏移量,這個是儲存在 PC 的程式計數中的,比如如果目前指令指向 1,下一條就指向 4

另外大家不難發現,在源碼中其實我們并沒有定義預設構造函數,但在位元組碼中卻生成了,而且你會發現我們在源碼中定義了private int a = 1;但這個變量指派的操作卻是在構造方法中執行的(下文會分析到),這就是了解位元組碼的意義:它可以反映 JVM 執行程式的真正邏輯,而源碼隻是表象,要深入分析還得看位元組碼!

接下來我們就來瞧一瞧構造方法對應的指令是如何執行的,首先我們來看一下在 JVM 中指令是怎麼執行的。

  1. 首先 JVM 會為每個方法配置設定對應的局部變量表,可以認為它是一個數組,每個坑位(我們稱為 slot)為方法中配置設定的變量,如果是執行個體方法,這些局部變量可以是 this, 方法參數,方法裡配置設定的局部變量,這些局部變量的類型即我們熟知的 int,long 等八大基本,還有引用,傳回位址,每個 slot 為 4 個位元組,是以像 Long , Double 這種 8 個位元組的要占用 2 個 slot, 如果這個方法為執行個體方法,則第一個 slot 為 this 指針, 如果是靜态方法則沒有 this 指針
  2. 配置設定好局部變量表後,方法裡如果涉及到指派,加減乘除等操作,那麼這些指令的運算就需要依賴于操作數棧了,将這些指令對應的操作數通過壓棧,彈棧來完成指令的執行

比如有 int i = 69 這樣的指令,對應的字碼節指令如下

0:  bipush 69
2:  istore_0           

其在記憶體中的操作過程如下

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

可以看到主要分兩步:第一步首先把 69 這個 int 值壓棧,然後再彈棧,把 69 彈出放到局部變量表 i 對應的位置,istore_0 表示彈棧,将其從操作數棧中彈出整型數字存儲到本地變量中,0 表示本地變量在局部變量表的第 0 個 slot

了解了上面這個操作,我們再來看一下預設構造函數對應的位元組碼指令是如何執行的

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

首先我們需要先來了解一下上面幾個指令

  • aload_0:從局部變量表中加載第 0 個 slot 中的對象引用到操作數棧的棧頂,這裡的 0 表示第 0 個位置,也就是 this
  • invokespecial:用來調用構造函數,但也可以用于調用同一個類中的 private 方法, 以及 可見的超類方法,在此例中表示調用父類的構造器(因為 #1 符号引用指向對應的 init 方法)
  • iconst_1:将 int 型 1推送至棧頂
  • putfield:它接受一個操作數,這個操作數引用的是運作時常量池裡的一個字段,在這裡這個字段是 a。賦給這個字段的值,以及包含這個字段的對象引用,在執行這條指令的時候,都會從操作數棧頂上 pop 出來。前面的 aload_0 指令已經把包含這個字段的對象(this)壓到操作數棧上了,而後面的 iconst_1 又把 1 壓到棧裡。最後 putfield 指令會将這兩個值從棧頂彈出。執行完的結果就是這個對象的 a 這個字段的值更新成了 1。

接下來我們來詳細解釋以上以上助記符代表的含義

  • 第一條指令 aload_0,表示從局部變量表中加載第 0 個 slot 中的對象引用到操作數棧的棧頂,也就是将 this 加載到棧頂,如下
深入了解JVM虛拟機——java位元組碼技術及其指令剖析
  • 第二步 invokespecial #1,表示彈棧并且執行 #1 對應的方法,#1 代表的含義可以從旁邊的解釋(# Method java/lang/Object."":()V)看出,即調用父類的初始化方法,這也印證了那句話:子類初始化時會從初始化父類
  • 之後的指令 aload_0,iconst_1,putfied #2 圖解如下
深入了解JVM虛拟機——java位元組碼技術及其指令剖析

可能有人有些奇怪,上述 6: putfield #2指令中的 #2 怎麼就代表 Demo 的私有成員 a 了,這就涉及到位元組碼中的常量池概念了,我們執行 javap -verbose path/Demo.class 可以看到這些字面量代表的含義,#1,#2 這種數字形式的表示形式也被稱為符号引用,程式運作期會将符号引用轉換為直接引用

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

由此可知 #2 代表 Demo 類的 a 屬性,如下

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

從最終的葉子節點可以看出 #2 最終代表的是 Demo 類中類型為 int(I 代表 int 代表 int 類型),名稱為 a 的變量

我們再來用動圖看一下 foo 的執行流程,相信你現在能了解其含義了

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

唯一需要注意的此例中的 foo 是個靜态方法,是以局部變量區是沒有 this 的。

相信你不難發現 JVM 執行位元組碼的流程與 CPU 執行機器碼步驟如出一轍,都經曆了「取指令」,「譯碼」,「執行」,「存儲計算結果」這四步,首先程式計數器指向下一條要執行的指令,然後 JVM 擷取指令,由本地執行引擎将位元組碼操作數轉成機器碼(譯碼)執行,執行後将值存儲到局部變量區(存儲計算結果)中

最後關于位元組碼我推薦兩款工具

  • 一個是 Hex Fiend,一款很好的十六進制編輯器,可以用來檢視編輯位元組碼
  • 一款是 Intellij Idea 的插件 jclasslib Bytecode viewer,能為你展示 javap -verbose 指令對應的常量池,接口, Code 等資料,非常的直覺,對于分析位元組碼非常有幫忙,如下
深入了解JVM虛拟機——java位元組碼技術及其指令剖析

2. 擷取位元組碼指令清單

可以用 javap 工具來擷取 class 檔案中的指令清單。 javap 是标準 JDK 内置的一款工具, 專門用于反編譯 class 檔案。

讓我們從頭開始, 先建立一個簡單的類,後面再慢慢擴充。

public class HelloByteCode {

    public static void main(String[] args) {

        HelloByteCode obj = new HelloByteCode();

    }

}           

代碼很簡單, main 方法中 new 了一個對象而已。然後我們編譯這個類:

javac demo/jvm0104/HelloByteCode.java

使用 javac 編譯 ,或者在 IDEA 或者 Eclipse 等內建開發工具自動編譯,基本上是等效的。隻要能找到對應的 class 即可。

javac 不指定 -d 參數編譯後生成的 .class 檔案預設和源代碼在同一個目錄。

注意: javac 工具預設開啟了優化功能, 生成的位元組碼中沒有局部變量表(LocalVariableTable),相當于局部變量名稱被擦除。如果需要這些調試資訊, 在編譯時請加上 -g 選項。有興趣的同學可以試試兩種方式的差別,并對比結果。

JDK 自帶工具的詳細用法, 請使用: javac -help 或者 javap -help 來檢視; 其他類似。

然後使用 javap 工具來執行反編譯, 擷取位元組碼清單:

javap -c demo.jvm0104.HelloByteCode
# 或者: 
javap -c demo/jvm0104/HelloByteCode
javap -c demo/jvm0104/HelloByteCode.class

           

javap 還是比較聰明的, 使用包名或者相對路徑都可以反編譯成功, 反編譯後的結果如下所示:

Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode {
  public demo.jvm0104.HelloByteCode();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

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

           

OK,我們成功擷取到了位元組碼清單, 下面進行簡單的解讀。

3 解讀位元組碼清單

可以看到,反編譯後的代碼清單中, 有一個預設的構造函數 public demo.jvm0104.HelloByteCode(), 以及 main 方法。

剛學 Java 時我們就知道, 如果不定義任何構造函數,就會有一個預設的無參構造函數,這裡再次驗證了這個知識點。好吧,這比較容易了解!我們通過檢視編譯後的 class 檔案證明了其中存在預設構造函數,是以這是 Java 編譯器生成的, 而不是運作時JVM自動生成的。

自動生成的構造函數,其方法體應該是空的,但這裡看到裡面有一些指令。為什麼呢?

再次回顧 Java 知識, 每個構造函數中都會先調用 super 類的構造函數對吧? 但這不是 JVM 自動執行的, 而是由程式指令控制,是以預設構造函數中也就有一些位元組碼指令來幹這個事情。

基本上,這幾條指令就是執行 super() 調用;

public demo.jvm0104.HelloByteCode();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

           

至于其中解析的 java/lang/Object 不用說, 預設繼承了 Object 類。這裡再次驗證了這個知識點,而且這是在編譯期間就确定了的。

繼續往下看 c,

public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class demo/jvm0104/HelloByteCode
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: return

           

main 方法中建立了該類的一個執行個體, 然後就 return 了, 關于裡面的幾個指令, 稍後講解。

4 檢視 class 檔案中的常量池資訊

常量池 大家應該都聽說過, 英文是 Constant pool。這裡做一個強調: 大多數時候指的是 運作時常量池。但運作時常量池裡面的常量是從哪裡來的呢? 主要就是由 class 檔案中的 常量池結構體 組成的。

要檢視常量池資訊, 我們得加一點魔法參數:

javap -c -verbose demo.jvm0104.HelloByteCode

           

在反編譯 class 時,指定 -verbose 選項, 則會 輸出附加資訊。

結果如下所示:

Classfile /XXXXXXX/demo/jvm0104/HelloByteCode.class
  Last modified 2019-11-28; size 301 bytes
  MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308
  Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref #4.#13 // java/lang/Object."<init>":()V
   #2 = Class #14 // demo/jvm0104/HelloByteCode
   #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
   #4 = Class #15 // java/lang/Object
   #5 = Utf8 <init>
   #6 = Utf8 ()V
   #7 = Utf8 Code
   #8 = Utf8 LineNumberTable
   #9 = Utf8 main
  #10 = Utf8 ([Ljava/lang/String;)V
  #11 = Utf8 SourceFile
  #12 = Utf8 HelloByteCode.java
  #13 = NameAndType #5:#6 // "<init>":()V
  #14 = Utf8 demo/jvm0104/HelloByteCode
  #15 = Utf8 java/lang/Object
{
  public demo.jvm0104.HelloByteCode();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1 // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new #2 // class demo/jvm0104/HelloByteCode
         3: dup
         4: invokespecial #3 // Method "<init>":()V
         7: astore_1
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
}
SourceFile: "HelloByteCode.java"

           

其中顯示了很多關于 class 檔案資訊: 編譯時間, MD5 校驗和, 從哪個 .java 源檔案編譯得來,符合哪個版本的 Java 語言規範等等。

還可以看到 ACC_PUBLIC 和 ACC_SUPER 通路标志符。 ACC_PUBLIC 标志很容易了解:這個類是 public 類,是以用這個标志來表示。

但 ACC_SUPER 标志是怎麼回事呢? 這就是曆史原因, JDK 1.0 的 BUG 修正中引入 ACC_SUPER 标志來修正 invokespecial 指令調用 super 類方法的問題,從 Java 1.1 開始, 編譯器一般都會自動生成ACC_SUPER 标志。

有些同學可能注意到了, 好多指令後面使用了 #1, #2, #3 這樣的編号。

這就是對常量池的引用。 那常量池裡面有些什麼呢?

Constant pool:
   #1 = Methodref #4.#13 // java/lang/Object."<init>":()V
   #2 = Class #14 // demo/jvm0104/HelloByteCode
   #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
   #4 = Class #15 // java/lang/Object
   #5 = Utf8 <init>
......

           

這是摘取的一部分内容, 可以看到常量池中的常量定義。還可以進行組合, 一個常量的定義中可以引用其他常量。

比如第一行: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V, 解讀如下:

  • #1 常量編号, 該檔案中其他地方可以引用。
  • = 等号就是分隔符.
  • Methodref 表明這個常量指向的是一個方法;具體是哪個類的哪個方法呢? 類指向的 #4, 方法簽名指向的 #13; 當然雙斜線注釋後面已經解析出來可讀性比較好的說明了。

同學們可以試着解析其他的常量定義。 自己實踐加上知識回顧,能有效增加個人的記憶和了解。

總結一下,常量池就是一個常量的大字典,使用編号的方式把程式裡用到的各類常量統一管理起來,這樣在位元組碼操作裡,隻需要引用編号即可。

5 檢視方法資訊

在 javap 指令中使用 -verbose 選項時, 還顯示了其他的一些資訊。 例如, 關于 main 方法的更多資訊被列印出來:

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

           

可以看到方法描述: ([Ljava/lang/String;)V:

  • 其中小括号内是入參資訊/形參資訊;
  • 左方括号表述數組;
  • L 表示對象;
  • 後面的java/lang/String就是類名稱;
  • 小括号後面的 V 則表示這個方法的傳回值是 void;
  • 方法的通路标志也很容易了解 flags: ACC_PUBLIC, ACC_STATIC,表示 public 和 static。

還可以看到執行該方法時需要的棧(stack)深度是多少,需要在局部變量表中保留多少個槽位, 還有方法的參數個數: stack=2, locals=2, args_size=1。把上面這些整合起來其實就是一個方法:

public static void main(java.lang.String[]);

注:實際上我們一般把一個方法的修飾符+名稱+參數類型清單+傳回值類型,合在一起叫“方法簽名”,即這些資訊可以完整的表示一個方法。

稍微往回一點點,看編譯器自動生成的無參構造函數位元組碼:

public demo.jvm0104.HelloByteCode();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1 // Method java/lang/Object."<init>":()V
         4: return

           

你會發現一個奇怪的地方, 無參構造函數的參數個數居然不是 0: stack=1, locals=1, args_size=1。 這是因為在 Java 中, 如果是靜态方法則沒有 this 引用。 對于非靜态方法, this 将被配置設定到局部變量表的第 0 号槽位中, 關于局部變量表的細節,下面再進行介紹。

有反射程式設計經驗的同學可能比較容易了解: Method#invoke(Object obj, Object... args); 有JavaScript程式設計經驗的同學也可以類比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);

6 線程棧與位元組碼執行模型

想要深入了解位元組碼技術,我們需要先對位元組碼的執行模型有所了解。

JVM 是一台基于棧的計算機器。每個線程都有一個獨屬于自己的線程棧(JVM stack),用于存儲棧幀(Frame)。每一次方法調用,JVM都會自動建立一個棧幀。棧幀 由 操作數棧, 局部變量數組 以及一個class 引用組成。class 引用 指向目前方法在運作時常量池中對應的 class)。

我們在前面反編譯的代碼中已經看到過這些内容。

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

局部變量數組 也稱為 局部變量表(LocalVariableTable), 其中包含了方法的參數,以及局部變量。 局部變量數組的大小在編譯時就已經确定: 和局部變量+形參的個數有關,還要看每個變量/參數占用多少個位元組。操作數棧是一個 LIFO 結構的棧, 用于壓入和彈出值。 它的大小也在編譯時确定。

有一些操作碼/指令可以将值壓入“操作數棧”; 還有一些操作碼/指令則是從棧中擷取操作數,并進行處理,再将結果壓入棧。操作數棧還用于接收調用其他方法時傳回的結果值。

7 方法體中的位元組碼解讀

看過前面的示例,細心的同學可能會猜測,方法體中那些位元組碼指令前面的數字是什麼意思,說是序号吧但又不太像,因為他們之間的間隔不相等。看看 main 方法體對應的位元組碼:

0: new #2 // class demo/jvm0104/HelloByteCode
         3: dup
         4: invokespecial #3 // Method "<init>":()V
         7: astore_1
         8: return

           

間隔不相等的原因是, 有一部分操作碼會附帶有操作數, 也會占用位元組碼數組中的空間。

例如, new 就會占用三個槽位: 一個用于存放操作碼指令自身,兩個用于存放操作數。

是以,下一條指令 dup 的索引從 3 開始。

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

每個操作碼/指令都有對應的十六進制(HEX)表示形式, 如果換成十六進制來表示,則方法體可表示為HEX字元串。例如上面的方法體百世成十六進制如下所示:

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

甚至我們還可以在支援十六進制的編輯器中打開 class 檔案,可以在其中找到對應的字元串:

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

(此圖由開源文本編輯軟體Atom的hex-view插件生成)

粗暴一點,我們可以通過 HEX 編輯器直接修改位元組碼,盡管這樣做會有風險, 但如果隻修改一個數值的話應該會很有趣。

其實要使用程式設計的方式,友善和安全地實作位元組碼編輯和修改還有更好的辦法,那就是使用 ASM 和 Javassist 之類的位元組碼操作工具,也可以在類加載器和 Agent 上面做文章,下一節課程會讨論 類加載器,其他主題則留待以後探讨。

4.8 對象初始化指令:new 指令, init 以及 clinit 簡介

我們都知道 new是 Java 程式設計語言中的一個關鍵字, 但其實在位元組碼中,也有一個指令叫做 new。 當我們建立類的執行個體時, 編譯器會生成類似下面這樣的操作碼:

0: new #2 // class demo/jvm0104/HelloByteCode
         3: dup
         4: invokespecial #3 // Method "<init>":()V

           

當你同時看到 new, dup 和 invokespecial 指令在一起時,那麼一定是在建立類的執行個體對象!

為什麼是三條指令而不是一條呢?這是因為:

  • new 指令隻是建立對象,但沒有調用構造函數。
  • invokespecial 指令用來調用某些特殊方法的, 當然這裡調用的是構造函數。
  • dup 指令用于複制棧頂的值。

由于構造函數調用不會傳回值,是以如果沒有 dup 指令, 在對象上調用方法并初始化之後,操作數棧就會是空的,在初始化之後就會出問題, 接下來的代碼就無法對其進行處理。

這就是為什麼要事先複制引用的原因,為的是在構造函數傳回之後,可以将對象執行個體指派給局部變量或某個字段。是以,接下來的那條指令一般是以下幾種:

  • astore {N} or astore_{N} – 指派給局部變量,其中 {N} 是局部變量表中的位置。
  • putfield – 将值賦給執行個體字段
  • putstatic – 将值賦給靜态字段

在調用構造函數的時候,其實還會執行另一個類似的方法 <init> ,甚至在執行構造函數之前就執行了。

還有一個可能執行的方法是該類的靜态初始化方法 <clinit>, 但 <clinit> 并不能被直接調用,而是由這些指令觸發的: new, getstatic, putstatic or invokestatic。

也就是說,如果建立某個類的新執行個體, 通路靜态字段或者調用靜态方法,就會觸發該類的靜态初始化方法【如果尚未初始化】。

實際上,還有一些情況會觸發靜态初始化, 詳情請參考 JVM 規範: [http://docs.oracle.com/javase/specs/jvms/se8/html/]

4.9 棧記憶體操作指令

有很多指令可以操作方法棧。 前面也提到過一些基本的棧操作指令: 他們将值壓入棧,或者從棧中擷取值。 除了這些基礎操作之外也還有一些指令可以操作棧記憶體; 比如 swap 指令用來交換棧頂兩個元素的值。下面是一些示例:

最基礎的是 dup 和 pop 指令。

  • dup 指令複制棧頂元素的值。
  • pop 指令則從棧中删除最頂部的值。

還有複雜一點的指令:比如,swap, dup_x1 和 dup2_x1。

  • 顧名思義,swap 指令可交換棧頂兩個元素的值,例如A和B交換位置(圖中示例4);
  • dup_x1 将複制棧頂元素的值,并在棧頂插入兩次(圖中示例5);
  • dup2_x1 則複制棧頂兩個元素的值,并插入第三個值(圖中示例6)。
深入了解JVM虛拟機——java位元組碼技術及其指令剖析

dup_x1 和 dup2_x1 指令看起來稍微有點複雜。而且為什麼要設定這種指令呢? 在棧中複制最頂部的值?

請看一個實際案例:怎樣交換 2 個 double 類型的值?

需要注意的是,一個 double 值占兩個槽位,也就是說如果棧中有兩個 double 值,它們将占用 4 個槽位。

要執行交換,你可能想到了 swap 指令,但問題是 swap 隻适用于單字(one-word, 單字一般指 32 位 4 個位元組,64 位則是雙字),是以不能處理 double 類型,但 Java 中又沒有 swap2 指令。

怎麼辦呢? 解決方法就是使用 dup2_x2 指令,将操作數棧頂部的 double 值,複制到棧底 double 值的下方, 然後再使用 pop2 指令彈出棧頂的 double 值。結果就是交換了兩個 double 值。 示意圖如下圖所示:

深入了解JVM虛拟機——java位元組碼技術及其指令剖析

dup、dup_x1、dup2_x1指令補充說明

指令的詳細說明可參考 JVM 規範:

dup 指令

官方說明是:複制棧頂的值,并将複制的值壓入棧。

操作數棧的值變化情況(方括号辨別新插入的值):

..., value →
..., value [,value]

           

dup_x1 指令

官方說明是:複制棧頂的值,并将複制的值插入到最上面 2 個值的下方。

操作數棧的值變化情況(方括号辨別新插入的值):

..., value2, value1 →
..., [value1,] value2, value1

           

dup2_x1 指令

官方說明是:複制棧頂 1 個 64 位/或 2 個 32 位的值, 并将複制的值按照原始順序,插入原始值下面一個 32 位值的下方。

操作數棧的值變化情況(方括号辨別新插入的值):

# 情景 1: value1, value2, and value3 都是分組 1 的值(32 位元素)
..., value3, value2, value1 →
..., [value2, value1,] value3, value2, value1

# 情景 2: value1 是分組 2 的值(64 位,long 或double), value2 是分組 1 的值(32 位元素)
..., value2, value1 →
..., [value1,] value2, value1

           
Table 2.11.1-B 實際類型與 JVM 計算類型映射和分組
實際類型 JVM 計算類型 類型分組
boolean int 1
byte int 1
char int 1
short int 1
int int 1
float float 1
reference reference 1
returnAddress returnAddress 1
long long 2
double double 2

4.10 局部變量表

stack 主要用于執行指令,而局部變量則用來儲存中間結果,兩者之間可以直接互動。

讓我們編寫一個複雜點的示例:

第一步,先編寫一個計算移動平均數的類:

package demo.jvm0104;
//移動平均數
public class MovingAverage {
    private int count = 0;
    private double sum = 0.0D;
    public void submit(double value){
        this.count ++;
        this.sum += value;
    }
    public double getAvg(){
        if(0 == this.count){ return sum;}
        return this.sum/this.count;
    }
}

           

第二步,然後寫一個類來調用:

package demo.jvm0104;
public class LocalVariableTest {
    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();
    }
}

           

其中 main 方法中向 MovingAverage 類的執行個體送出了兩個數值,并要求其計算目前的平均值。

然後我們需要編譯(還記得前面提到, 生成調試資訊的 -g 參數嗎)。

javac -g demo/jvm0104/*.java

           

然後使用 javap 反編譯:

javap -c -verbose demo/jvm0104/LocalVariableTest

           

看 main 方法對應的位元組碼:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=6, args_size=1
         0: new           #2                  // class demo/jvm0104/MovingAverage
         3: dup
         4: invokespecial #3                  // Method demo/jvm0104/MovingAverage."<init>":()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 demo/jvm0104/MovingAverage.submit:(D)V
        18: aload_1
        19: iload_3
        20: i2d
        21: invokevirtual #4                  // Method demo/jvm0104/MovingAverage.submit:(D)V
        24: aload_1
        25: invokevirtual #5                  // Method demo/jvm0104/MovingAverage.getAvg:()D
        28: dstore        4
        30: return
      LineNumberTable:
        line 5: 0
        line 6: 8
        line 7: 10
        line 8: 12
        line 9: 18
        line 10: 24
        line 11: 30
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      31     0  args   [Ljava/lang/String;
            8      23     1    ma   Ldemo/jvm0104/MovingAverage;
           10      21     2  num1   I
           12      19     3  num2   I
           30       1     4   avg   D

           
  • 編号 0 的位元組碼 new, 建立 MovingAverage 類的對象;
  • 編号 3 的位元組碼 dup 複制棧頂引用值。
  • 編号 4 的位元組碼 invokespecial 執行對象初始化。
  • 編号 7 開始, 使用 astore_1 指令将引用位址值(addr.)存儲(store)到編号為1的局部變量中: astore_1 中的 1 指代 LocalVariableTable 中ma對應的槽位編号,
  • 編号8開始的指令: iconst_1 和 iconst_2 用來将常量值1和2加載到棧裡面, 并分别由指令 istore_2 和 istore_3 将它們存儲到在 LocalVariableTable 的槽位 2 和槽位 3 中。
8: iconst_1
         9: istore_2
        10: iconst_2
        11: istore_3

           

請注意,store 之類的指令調用實際上從棧頂删除了一個值。 這就是為什麼再次使用相同值時,必須再加載(load)一次的原因。

例如在上面的位元組碼中,調用 submit 方法之前, 必須再次将參數值加載到棧中:

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

           

調用 getAvg() 方法後,傳回的結果位于棧頂,然後使用 dstore 将 double 值儲存到本地變量4号槽位,這裡的d表示目标變量的類型為double。

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

           

關于 LocalVariableTable 有個有意思的事情,就是最前面的槽位會被方法參數占用。

在這裡,因為 main 是靜态方法,是以槽位0中并沒有設定為 this 引用的位址。 但是對于非靜态方法來說, this 會将配置設定到第 0 号槽位中。

再次提醒: 有過反射程式設計經驗的同學可能比較容易了解: Method#invoke(Object obj, Object... args); 有JavaScript程式設計經驗的同學也可以類比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);

了解這些位元組碼的訣竅在于:

給局部變量指派時,需要使用相應的指令來進行 store,如 astore_1。store 類的指令都會删除棧頂值。 相應的 load 指令則會将值從局部變量表壓入操作數棧,但并不會删除局部變量中的值。

4.11 流程控制指令

流程控制指令主要是分支和循環在用, 根據檢查條件來控制程式的執行流程。

一般是 If-Then-Else 這種三元運算符(ternary operator), Java中的各種循環,甚至異常處的理操作碼都可歸屬于 程式流程控制。

然後,我們再增加一個示例,用循環來送出給 MovingAverage 類一定數量的值:

package demo.jvm0104;
public class ForLoopTest {
    private static int[] numbers = {1, 6, 8};
    public static void main(String[] args) {
        MovingAverage ma = new MovingAverage();
        for (int number : numbers) {
            ma.submit(number);
        }
        double avg = ma.getAvg();
    }
}

           

同樣執行編譯和反編譯:

javac -g demo/jvm0104/*.java
javap -c -verbose demo/jvm0104/ForLoopTest

           

因為 numbers 是本類中的 static 屬性, 是以對應的位元組碼如下所示:

0: new           #2                  // class demo/jvm0104/MovingAverage
         3: dup
         4: invokespecial #3                  // Method demo/jvm0104/MovingAverage."<init>":()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 demo/jvm0104/MovingAverage.submit:(D)V
        37: iinc          4, 1
        40: goto          18
        43: aload_1
        44: invokevirtual #6                  // Method demo/jvm0104/MovingAverage.getAvg:()D
        47: dstore_2
        48: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
           30       7     5 number   I
            0      49     0  args   [Ljava/lang/String;
            8      41     1    ma   Ldemo/jvm0104/MovingAverage;
           48       1     2   avg   D

           

位置 [8~16] 的指令用于循環控制。 我們從代碼的聲明從上往下看, 在最後面的LocalVariableTable 中:

  • 0 号槽位被 main 方法的參數 args 占據了。
  • 1 号槽位被 ma 占用了。
  • 5 号槽位被 number 占用了。
  • 2 号槽位是for循環之後才被 avg 占用的。

那麼中間的 2,3,4 号槽位是誰霸占了呢? 通過分析位元組碼指令可以看出,在 2,3,4 槽位有 3 個匿名的局部變量(astore_2, istore_3, istore 4等指令)。

  • 2号槽位的變量儲存了 numbers 的引用值,占據了 2号槽位。
  • 3号槽位的變量, 由 arraylength 指令使用, 得出循環的長度。
  • 4号槽位的變量, 是循環計數器, 每次疊代後使用 iinc 指令來遞增。
如果我們的 JDK 版本再老一點, 則會在 2,3,4 槽位發現三個源碼中沒有出現的變量: arr$, len$, i$, 也就是循環變量。

循環體中的第一條指令用于執行 循環計數器與數組長度 的比較:

18: iload         4
        20: iload_3
        21: if_icmpge     43

           

這段指令将局部變量表中 4号槽位 和 3号槽位的值加載到棧中,并調用 if_icmpge 指令來比較他們的值。

【if_icmpge 解讀: if, integer, compare, great equal】, 如果一個數的值大于或等于另一個值,則程式執行流程跳轉到pc=43的地方繼續執行。

在這個例子中就是, 如果4号槽位的值 大于或等于 3号槽位的值, 循環就結束了,這裡 43 位置對于的是循環後面的代碼。如果條件不成立,則循環進行下一次疊代。

在循環體執行完,它的循環計數器加 1,然後循環跳回到起點以再次驗證循環條件:

37: iinc          4, 1   // 4号槽位的值加1
        40: goto          18     // 跳到循環開始的地方

           

4.12 算術運算指令與類型轉換指令

Java 位元組碼中有許多指令可以執行算術運算。實際上,指令集中有很大一部分表示都是關于數學運算的。對于所有數值類型(int, long, double, float),都有加,減,乘,除,取反的指令。

那麼 byte 和 char, boolean 呢? JVM 是當做 int 來處理的。另外還有部分指令用于資料類型之間的轉換。

算術操作碼和類型

當我們想将 int 類型的值指派給 long 類型的變量時,就會發生類型轉換。

類型轉換操作碼

在前面的示例中, 将 int 值作為參數傳遞給實際上接收 double 的 submit() 方法時,可以看到, 在實際調用該方法之前,使用了類型轉換的操作碼:

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

           

也就是說, 将一個 int 類型局部變量的值, 作為整數加載到棧中,然後用 i2d 指令将其轉換為 double 值,以便将其作為參數傳給submit方法。

唯一不需要将數值load到操作數棧的指令是 iinc,它可以直接對 LocalVariableTable 中的值進行運算。 其他的所有操作均使用棧來執行。

4.13 方法調用指令和參數傳遞

前面部分稍微提了一下方法調用: 比如構造函數是通過 invokespecial 指令調用的。

這裡列舉了各種用于方法調用的指令:

  • invokestatic,顧名思義,這個指令用于調用某個類的靜态方法,這也是方法調用指令中最快的一個。
  • invokespecial, 我們已經學過了, invokespecial 指令用來調用構造函數,但也可以用于調用同一個類中的 private 方法, 以及可見的超類方法。
  • invokevirtual,如果是具體類型的目标對象,invokevirtual用于調用公共,受保護和打包私有方法。
  • invokeinterface,當要調用的方法屬于某個接口時,将使用 invokeinterface 指令。
那麼 invokevirtual 和 invokeinterface 有什麼差別呢?這确實是個好問題。 為什麼需要 invokevirtual 和 invokeinterface 這兩種指令呢? 畢竟所有的接口方法都是公共方法, 直接使用 invokevirtual 不就可以了嗎?

這麼做是源于對方法調用的優化。JVM 必須先解析該方法,然後才能調用它。

  • 使用 invokestatic 指令,JVM 就确切地知道要調用的是哪個方法:因為調用的是靜态方法,隻能屬于一個類。
  • 使用 invokespecial 時, 查找的數量也很少, 解析也更加容易, 那麼運作時就能更快地找到所需的方法。

使用 invokevirtual 和 invokeinterface 的差別不是那麼明顯。想象一下,類定義中包含一個方法定義表, 所有方法都有位置編号。下面的示例中:A 類包含 method1 和 method2 方法; 子類B繼承A,繼承了 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 處找到它。

現在我們來解釋invokevirtual 和 invokeinterface 之間的本質差別。

假設有一個接口 X 聲明了 methodX 方法, 讓 B 類在上面的基礎上實作接口 X:

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

           

新方法 methodX 位于索引 4 處,在這種情況下,它看起來與 method3 沒什麼不同。

但如果還有另一個類 C 也實作了 X 接口,但不繼承 A,也不繼承 B:

class C implements X
    1: methodC
    2: methodX

           

類 C 中的接口方法位置與類 B 的不同,這就是為什麼運作時在 invokinterface 方面受到更多限制的原因。 與 invokinterface 相比, invokevirtual 針對具體的類型方法表是固定的,是以每次都可以精确查找,效率更高(具體的分析讨論可以參見參考材料的第一個連結)。

4.14 JDK7 新增的方法調用指令 invokedynamic

Java 虛拟機的位元組碼指令集在 JDK7 之前一直就隻有前面提到的 4 種指令(invokestatic,invokespecial,invokevirtual,invokeinterface)。随着 JDK 7 的釋出,位元組碼指令集新增了invokedynamic指令。這條新增加的指令是實作“動态類型語言”(Dynamically Typed Language)支援而進行的改進之一,同時也是 JDK 8 以後支援的 lambda 表達式的實作基礎。

為什麼要新增加一個指令呢?

我們知道在不改變位元組碼的情況下,我們在 Java 語言層面想調用一個類 A 的方法 m,隻有兩個辦法:

  • 使用A a=new A(); a.m(),拿到一個 A 類型的執行個體,然後直接調用方法;
  • 通過反射,通過 A.class.getMethod 拿到一個 Method,然後再調用這個Method.invoke反射調用;

這兩個方法都需要顯式的把方法 m 和類型 A 直接關聯起來,假設有一個類型 B,也有一個一模一樣的方法簽名的 m 方法,怎麼來用這個方法在運作期指定調用 A 或者 B 的 m 方法呢?這個操作在 JavaScript 這種基于原型的語言裡或者是 C# 這種有函數指針/方法委托的語言裡非常常見,Java 裡是沒有直接辦法的。Java 裡我們一般建議使用一個 A 和 B 公有的接口 IC,然後 IC 裡定義方法 m,A 和 B 都實作接口 IC,這樣就可以在運作時把 A 和 B 都當做 IC 類型來操作,就同時有了方法 m,這樣的“強限制”帶來了很多額外的操作。

而新增的 invokedynamic 指令,配合新增的方法句柄(Method Handles,它可以用來描述一個跟類型 A 無關的方法 m 的簽名,甚至不包括方法名稱,這樣就可以做到我們使用方法 m 的簽名,但是直接執行的時候調用的是相同簽名的另一個方法 b),可以在運作時再決定由哪個類來接收被調用的方法。在此之前,隻能使用反射來實作類似的功能。該指令使得可以出現基于 JVM 的動态語言,讓 jvm 更加強大。而且在 JVM 上實作動态調用機制,不會破壞原有的調用機制。這樣既很好的支援了 Scala、Clojure 這些 JVM 上的動态語言,又可以支援代碼裡的動态 lambda 表達式。

RednaxelaFX 評論說:

簡單來說就是以前設計某些功能的時候把做法寫死在了位元組碼裡,後來想改也改不了了。 是以這次給 lambda 文法設計翻譯到位元組碼的政策是就用 invokedynamic 來作個弊,把實際的翻譯政策隐藏在 JDK 的庫的實作裡(metafactory)可以随時改,而在外部的标準上大家隻看到一個固定的 invokedynamic。