輕鬆看懂Java位元組碼

鹹魚不思議發表於2018-04-08

java位元組碼

計算機只認識0和1。這意味著任何語言編寫的程式最終都需要經過編譯器編譯成機器碼才能被計算機執行。所以,我們所編寫的程式在不同的平臺上執行前都要經過重新編譯才能被執行。 而Java剛誕生的時候曾經提過一個非常著名的宣傳口號: "一次編寫,到處執行"

Write Once, Run Anywhere.

為了實現該目的,Sun公司以及其他虛擬機器提供商釋出了許多可以執行在不同平臺上的JVM虛擬機器,而這些虛擬機器都擁有一個共同的功能,那就是可以載入和執行同一種與平臺無關的位元組碼(ByteCode)。 於是,我們的原始碼不再必須根據不同平臺翻譯成0和1,而是間接翻譯成位元組碼,儲存位元組碼的檔案再交由執行於不同平臺上的JVM虛擬機器去讀取執行,從而實現一次編寫,到處執行的目的。 如今,JVM也不再只支援Java,由此衍生出了許多基於JVM的程式語言,如Groovy, Scala, Koltin等等。

Java虛擬機器

原始碼中的各種變數,關鍵字和運算子號的語義最終都會編譯成多條位元組碼命令。而位元組碼命令所能提供的語義描述能力是要明顯強於Java本身的,所以有其他一些同樣基於JVM的語言能提供許多Java所不支援的語言特性。

例子

下面以一個簡單的例子來逐步講解位元組碼。

//Main.java
public class Main {
    
    private int m;
    
    public int inc() {
        return m + 1;
    }
}
複製程式碼

通過以下命令, 可以在當前所在路徑下生成一個Main.class檔案。

javac Main.java
複製程式碼

以文字的形式開啟生成的class檔案,內容如下:

cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4d61 696e 2e6a 6176 610c
0007 0008 0c00 0500 0601 0010 636f 6d2f
7268 7974 686d 372f 4d61 696e 0100 106a
6176 612f 6c61 6e67 2f4f 626a 6563 7400
2100 0300 0400 0000 0100 0200 0500 0600
0000 0200 0100 0700 0800 0100 0900 0000
1d00 0100 0100 0000 052a b700 01b1 0000
0001 000a 0000 0006 0001 0000 0003 0001
000b 000c 0001 0009 0000 001f 0002 0001
0000 0007 2ab4 0002 0460 ac00 0000 0100
0a00 0000 0600 0100 0000 0800 0100 0d00
0000 0200 0e
複製程式碼

對於檔案中的16進位制程式碼,除了開頭的cafe babe,剩下的內容大致可以翻譯成: 啥玩意啊這是......

英雄莫慌,我們就從我們所能認識的"cafe babe"講起吧。 檔案開頭的4個位元組稱之為 魔數,唯有以"cafe babe"開頭的class檔案方可被虛擬機器所接受,這4個位元組就是位元組碼檔案的身份識別。 目光右移,0000是編譯器jdk版本的次版本號0,0034轉化為十進位制是52,是主版本號,java的版本號從45開始,除1.0和1.1都是使用45.x外,以後每升一個大版本,版本號加一。也就是說,編譯生成該class檔案的jdk版本為1.8.0。 通過java -version命令稍加驗證, 可得結果。

Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)
複製程式碼

結果驗證成立。

繼續往下是常量池。但我並不打算繼續直接分析這個十六進位制檔案。

反編譯位元組碼檔案

使用到java內建的一個反編譯工具javap可以反編譯位元組碼檔案。 通過javap -help可瞭解javap的基本用法

用法: javap <options> <classes>
其中, 可能的選項包括:
  -help  --help  -?        輸出此用法訊息
  -version                 版本資訊
  -v  -verbose             輸出附加資訊
  -l                       輸出行號和本地變數表
  -public                  僅顯示公共類和成員
  -protected               顯示受保護的/公共類和成員
  -package                 顯示程式包/受保護的/公共類
                           和成員 (預設)
  -p  -private             顯示所有類和成員
  -c                       對程式碼進行反彙編
  -s                       輸出內部型別簽名
  -sysinfo                 顯示正在處理的類的
                           系統資訊 (路徑, 大小, 日期, MD5 雜湊)
  -constants               顯示最終常量
  -classpath <path>        指定查詢使用者類檔案的位置
  -cp <path>               指定查詢使用者類檔案的位置
  -bootclasspath <path>    覆蓋引導類檔案的位置
複製程式碼

輸入命令javap -verbose -p Main.class檢視輸出內容:

Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class
  Last modified 2018-4-7; size 362 bytes
  MD5 checksum 4aed8540b098992663b7ba08c65312de
  Compiled from "Main.java"
public class com.rhythm7.Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#18         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#19         // com/rhythm7/Main.m:I
   #3 = Class              #20            // com/rhythm7/Main
   #4 = Class              #21            // java/lang/Object
   #5 = Utf8               m
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/rhythm7/Main;
  #14 = Utf8               inc
  #15 = Utf8               ()I
  #16 = Utf8               SourceFile
  #17 = Utf8               Main.java
  #18 = NameAndType        #7:#8          // "<init>":()V
  #19 = NameAndType        #5:#6          // m:I
  #20 = Utf8               com/rhythm7/Main
  #21 = Utf8               java/lang/Object
{
  private int m;
    descriptor: I
    flags: ACC_PRIVATE

  public com.rhythm7.Main();
    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
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/rhythm7/Main;

  public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field m:I
         4: iconst_1
         5: iadd
         6: ireturn
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lcom/rhythm7/Main;
}
SourceFile: "Main.java"
複製程式碼

位元組碼檔案資訊

開頭的7行資訊包括:Class檔案當前所在位置,最後修改時間,檔案大小,MD5值,編譯自哪個檔案,類的全限定名,jdk次版本號,主版本號。 然後緊接著的是該類的訪問標誌:ACC_PUBLIC, ACC_SUPER,訪問標誌的含義如下:

標誌名稱 標誌值 含義
ACC_PUBLIC 0x0001 是否為Public型別
ACC_FINAL 0x0010 是否被宣告為final,只有類可以設定
ACC_SUPER 0x0020 是否允許使用invokespecial位元組碼指令的新語義.
ACC_INTERFACE 0x0200 標誌這是一個介面
ACC_ABSTRACT 0x0400 是否為abstract型別,對於介面或者抽象類來說,
次標誌值為真,其他型別為假
ACC_SYNTHETIC 0x1000 標誌這個類並非由使用者程式碼產生
ACC_ANNOTATION 0x2000 標誌這是一個註解
**ACC_ENUM ** 0x4000 標誌這是一個列舉

常量池

Constant pool意為常量池。 常量池可以理解成Class檔案中的資源倉庫。主要存放的是兩大類常量:字面量(Literal)和符號引用(Symbolic References)。字面量類似於java中的常量概念,如文字字串,final常量等,而符號引用則屬於編譯原理方面的概念,包括以下三種:

  • 類和介面的全限定名(Fully Qualified Name)
  • 欄位的名稱和描述符號(Descriptor)
  • 方法的名稱和描述符

不同於C/C++, JVM是在載入Class檔案的時候才進行的動態連結,也就是說這些欄位和方法符號引用只有在執行期轉換後才能獲得真正的記憶體入口地址。當虛擬機器執行時,需要從常量池獲得對應的符號引用,再在類建立或執行時解析並翻譯到具體的記憶體地址中。

直接通過反編譯檔案來檢視位元組碼內容:

#1 = Methodref          #4.#18         // java/lang/Object."<init>":()V
#4 = Class              #21            // java/lang/Object
#7 = Utf8               <init>
#8 = Utf8               ()V
#18 = NameAndType        #7:#8          // "<init>":()V
#21 = Utf8               java/lang/Object
複製程式碼

第一個常量是一個方法定義,指向了第4和第18個常量。以此類推檢視第4和第18個常量。最後可以拼接成第一個常量右側的註釋內容:

java/lang/Object."<init>":()V
複製程式碼

這段可以理解為該類的例項構造器的宣告,由於Main類沒有重寫構造方法,所以呼叫的是父類的構造方法。此處也說明了Main類的直接父類是Object。 該方法預設返回值是V, 也就是void,無返回值。

同理可分析第二個常量:

#2 = Fieldref           #3.#19         // com/rhythm7/Main.m:I
#3 = Class              #20            // com/rhythm7/Main
#5 = Utf8               m
#6 = Utf8               I
#19 = NameAndType        #5:#6          // m:I
#20 = Utf8               com/rhythm7/Main
複製程式碼

此處宣告瞭一個欄位m,型別為I, I即是int型別。關於位元組碼的型別對應如下:

標識字元 含義
B 基本型別byte
C 基本型別char
D 基本型別double
F 基本型別float
I 基本型別int
J 基本型別long
S 基本型別short
Z 基本型別boolean
V 特殊型別void
L 物件型別,以分號結尾,如Ljava/lang/Object;

對於陣列型別,每一位使用一個前置的"["字元來描述,如定義一個java.lang.String[][]型別的維陣列,將被記錄為"[[Ljava/lang/String;"

方法表集合

在常量池之後的是對類內部的方法描述,在位元組碼中以表的集合形式表現,暫且不管位元組碼檔案的16進位制檔案內容如何,我們直接看反編譯後的內容。

private int m;
  descriptor: I
  flags: ACC_PRIVATE
複製程式碼

此處宣告瞭一個私有變數m,型別為int,返回值為int

public com.rhythm7.Main();
   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
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           0       5     0  this   Lcom/rhythm7/Main;
複製程式碼

這裡是構造方法:Main(),返回值為void, 公開方法。 code內的主要屬性為:

  • stack 最大運算元棧,JVM執行時會根據這個值來分配棧幀(Frame)中的操作棧深度,此處為1

  • locals: 區域性變數所需的儲存空間,單位為Slot, Slot是虛擬機器為區域性變數分配記憶體時所使用的最小單位,為4個位元組大小。方法引數(包括例項方法中的隱藏引數this),顯示異常處理器的引數(try catch中的catch塊所定義的異常),方法體中定義的區域性變數都需要使用區域性變數表來存放。值得一提的是,locals的大小並不一定等於所有區域性變數所佔的Slot之和,因為區域性變數中的Slot是可以重用的。

  • args_size: 方法引數的個數,這裡是1,因為每個例項方法都會有一個隱藏引數this

  • attribute_info 方法體內容,0,1,4為位元組碼"行號",該段程式碼的意思是將第一個引用型別本地變數推送至棧頂,然後執行該型別的例項方法,也就是常量池存放的第一個變數,也就是註釋裡的"java/lang/Object."":()V", 然後執行返回語句,結束方法。

  • LineNumberTable 該屬性的作用是描述原始碼行號與位元組碼行號(位元組碼偏移量)之間的對應關係。可以使用 -g:none 或-g:lines選項來取消或要求生成這項資訊,如果選擇不生成LineNumberTable,當程式執行異常時將無法獲取到發生異常的原始碼行號,也無法按照原始碼的行數來除錯程式。

  • LocalVariableTable 該屬性的作用是描述幀棧中區域性變數與原始碼中定義的變數之間的關係。可以使用 -g:none 或 -g:vars來取消或生成這項資訊,如果沒有生成這項資訊,那麼當別人引用這個方法時,將無法獲取到引數名稱,取而代之的是arg0, arg1這樣的佔位符。 start 表示該區域性變數在哪一行開始可見,length表示可見行數,Slot代表所在幀棧位置,Name是變數名稱,然後是型別簽名。

同理可以分析Main類中的另一個方法"inc()": 方法體內的內容是:將this入棧,獲取欄位#2並置於棧頂, 將int型別的1入棧,將棧內頂部的兩個數值相加,返回一個int型別的值。

SourceFile

原始碼檔名稱


實戰

分析try-catch-finally

通過以上一個最簡單的例子,可以大致瞭解原始碼被編譯成位元組碼後是什麼樣子的。 下面利用所學的知識點來分析一些Java問題:

public class TestCode {
    public int foo() {
        int x;
        try {
            x = 1;
            return x;
        } catch (Exception e) {
            x = 2;
            return x;
        } finally {
            x = 3;
        }
    }
}
複製程式碼

試問當不發生異常和發生異常的情況下,foo()的返回值分別是多少。 使出老手段

javac TestCode.java
javap -verbose TestCode.class
複製程式碼

檢視位元組碼的foo方法內容:

public int foo();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=5, args_size=1
         0: iconst_1 //int型1入棧 ->棧頂=1
         1: istore_1 //將棧頂的int型數值存入第二個區域性變數 ->區域性2=1
         2: iload_1 //將第二個int型區域性變數推送至棧頂 ->棧頂=1
         3: istore_2 //!!將棧頂int型數值存入第三個區域性變數 ->區域性3=1
         
         4: iconst_3 //int型3入棧 ->棧頂=3
         5: istore_1 //將棧頂的int型數值存入第二個區域性變數 ->區域性2=3
         6: iload_2 //!!將第三個int型區域性變數推送至棧頂 ->棧頂=1
         7: ireturn //從當前方法返回棧頂int數值 ->1
         
         8: astore_2 // ->區域性3=Exception
         9: iconst_2 // ->棧頂=2
        10: istore_1 // ->區域性2=2
        11: iload_1 //->棧頂=2
        12: istore_3 //!! ->區域性4=2
        
        13: iconst_3 // ->棧頂=3
        14: istore_1 // ->區域性1=3
        15: iload_3 //!! ->棧頂=2
        16: ireturn // -> 2
        
        17: astore        4 //將棧頂引用型數值存入第五個區域性變數=any
        19: iconst_3 //將int型數值3入棧 -> 棧頂3
        20: istore_1 //將棧頂第一個int數值存入第二個區域性變數 -> 區域性2=3
        21: aload         4 //將區域性第五個區域性變數(引用型)推送至棧頂
        23: athrow //將棧頂的異常丟擲
      Exception table:
         from    to  target type
             0     4     8   Class java/lang/Exception //0到4行對應的異常,對應#8中儲存的異常
             0     4    17   any //Exeption之外的其他異常
             8    13    17   any
            17    19    17   any
複製程式碼

在位元組碼的4,5,以及13,14中執行的是同一個操作,就是將int型的3入運算元棧頂,並存入第二個區域性變數。這正是我們原始碼在finally語句塊中內容。也就是說,JVM在處理異常時,會在每個可能的分支都將finally語句重複執行一遍。 通過一步步分析位元組碼,可以得出最後的執行結果是:

  • 不發生異常時: return 1
  • 發生異常時: return 2
  • 發生非Exception及其子類的異常,丟擲異常,不返回值

以上例子來自於《深入理解Java虛擬機器 JVM高階特性與最佳實踐》 關於虛擬機器位元組碼指令表,也可以在《深入理解Java虛擬機器 JVM高階特性與最佳實踐-附錄B》中獲取。

kotlin 函式擴充套件的實現

kotlin提供了擴充套件函式的語言特性,藉助這個特性,我們可以給任意物件新增自定義方法。 以下示例為Object新增"sayHello"方法

//SayHello.kt
package com.rhythm7

fun Any.sayHello() {
    println("Hello")
}
複製程式碼

編譯後,使用javap檢視生成SayHelloKt.class檔案的位元組碼。

Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/SayHelloKt.class
Last modified 2018-4-8; size 958 bytes
 MD5 checksum 780a04b75a91be7605cac4655b499f19
 Compiled from "SayHello.kt"
public final class com.rhythm7.SayHelloKt
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_FINAL, ACC_SUPER
Constant pool:
    //省略常量池部分位元組碼
{
 public static final void sayHello(java.lang.Object);
   descriptor: (Ljava/lang/Object;)V
   flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
   Code:
     stack=2, locals=2, args_size=1
        0: aload_0
        1: ldc           #9                  // String $receiver
        3: invokestatic  #15                 // Method kotlin/jvm/internal/Intrinsics.checkParameterIsNotNull:(Ljava/lang/Object;Ljava/lang/String;)V
        6: ldc           #17                 // String Hello
        8: astore_1
        9: getstatic     #23                 // Field java/lang/System.out:Ljava/io/PrintStream;
       12: aload_1
       13: invokevirtual #28                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
       16: return
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           0      17     0 $receiver   Ljava/lang/Object;
     LineNumberTable:
       line 4: 6
       line 5: 16
   RuntimeInvisibleParameterAnnotations:
     0:
       0: #7()
}
SourceFile: "SayHello.kt"
複製程式碼

觀察頭部發現,koltin為檔案SayHello生成了一個類,類名"com.rhythm7.SayHelloKt". 由於我們一開始編寫SayHello.kt時並不希望SayHello是一個可例項化的物件類,所以,SayHelloKt是無法被例項化的,SayHelloKt並沒有任何一個構造器。 再觀察唯一的一個方法:發現Any.sayHello()的具體實現是靜態不可變方法的形式:

public static final void sayHello(java.lang.Object);
複製程式碼

所以當我們在其他地方使用Any.sayHello()時,事實上等同於呼叫java的SayHelloKt.sayHello(Object)方法。 順便一提的是,當擴充套件的方法為Any時,意味著Any是non-null的,這時,編譯器會在方法體的開頭檢查引數的非空,即呼叫 kotlin.jvm.internal.Intrinsics.checkParameterIsNotNull(Object value, String paramName) 方法來檢查傳入的Any型別物件是否為空。如果我們擴充套件的函式為Any?.sayHello(),那麼在編譯後的檔案中則不會有這段位元組碼的出現。

相關文章