1. 程式人生 > 實用技巧 >java位元組碼解析

java位元組碼解析

/Java位元組碼 /

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

Write Once, Run Anywhere.

為了實現該目的,Sun公司以及其他虛擬機器提供商釋出了許多可以執行在不同平臺上的JVM虛擬機器,而這些虛擬機器都擁有一個共同的功能,那就是可以載入和執行同一種與平臺無關的位元組碼(ByteCode)。

於是,我們的原始碼不再必須根據不同平臺翻譯成0和1,而是間接翻譯成位元組碼,儲存位元組碼的檔案再交由運行於不同平臺上的JVM虛擬機器去讀取執行,從而實現一次編寫,到處執行的目的。

如今,JVM也不再只支援Java,由此衍生出了許多基於JVM的程式語言,如Groovy, Scala, Koltin等等。

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

/例子 /

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

//Main.java
publicclassMain{

privateintm;

publicintinc(){
returnm+1;
}
}

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

javacMain.java

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

cafebabe0000003400130a0004000f09
000300100700110700120100016d0100
01490100063c696e69743e0100032829
56010004436f646501000f4c696e654e
756d6265725461626c65010003696e63
01000328294901000a536f7572636546
696c650100094d61696e2e6a6176610c
000700080c00050006010010636f6d2f
72687974686d372f4d61696e0100106a
6176612f6c616e672f4f626a65637400
21000300040000000100020005000600
00000200010007000800010009000000
1d00010001000000052ab70001b10000
0001000a000000060001000000030001
000b000c000100090000001f00020001
000000072ab400020460ac0000000100
0a000000060001000000080001000d00
000002000e

對於檔案中的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)SERuntimeEnvironment(build1.8.0_131-b11)
JavaHotSpot(TM)64-BitServerVM(build25.131-b11,mixedmode)

結果驗證成立。

繼續往下是常量池。但我並不打算繼續直接分析這個十六進位制檔案,這樣會比較繁瑣,我們通過另一種更容易讓人看懂的方式來分析這個class檔案。

反編譯位元組碼檔案

使用到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
Lastmodified2018-4-7;size362bytes
MD5checksum4aed8540b098992663b7ba08c65312de
Compiledfrom"Main.java"
publicclasscom.rhythm7.Main
minorversion:0
majorversion:52
flags:ACC_PUBLIC,ACC_SUPER
Constantpool:
#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=Utf8m
#6=Utf8I
#7=Utf8<init>
#8=Utf8()V
#9=Utf8Code
#10=Utf8LineNumberTable
#11=Utf8LocalVariableTable
#12=Utf8this
#13=Utf8Lcom/rhythm7/Main;
#14=Utf8inc
#15=Utf8()I
#16=Utf8SourceFile
#17=Utf8Main.java
#18=NameAndType#7:#8//"<init>":()V
#19=NameAndType#5:#6//m:I
#20=Utf8com/rhythm7/Main
#21=Utf8java/lang/Object
{
privateintm;
descriptor:I
flags:ACC_PRIVATE

publiccom.rhythm7.Main();
descriptor:()V
flags:ACC_PUBLIC
Code:
stack=1,locals=1,args_size=1
0:aload_0
1:invokespecial#1//Methodjava/lang/Object."<init>":()V
4:return
LineNumberTable:
line3:0
LocalVariableTable:
StartLengthSlotNameSignature
050thisLcom/rhythm7/Main;

publicintinc();
descriptor:()I
flags:ACC_PUBLIC
Code:
stack=2,locals=1,args_size=1
0:aload_0
1:getfield#2//Fieldm:I
4:iconst_1
5:iadd
6:ireturn
LineNumberTable:
line8:0
LocalVariableTable:
StartLengthSlotNameSignature
070thisLcom/rhythm7/Main;
}
SourceFile:"Main.java"

位元組碼檔案資訊

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

常量池

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=Utf8java/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=Utf8m
#6=Utf8I
#19=NameAndType#5:#6//m:I
#20=Utf8com/rhythm7/Main


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


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

方法表集合


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

privateintm;
descriptor:I
flags:ACC_PRIVATE


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

publiccom.rhythm7.Main();
descriptor:()V
flags:ACC_PUBLIC
Code:
stack=1,locals=1,args_size=1
0:aload_0
1:invokespecial#1//Methodjava/lang/Object."<init>":()V
4:return
LineNumberTable:
line3:0
LocalVariableTable:
StartLengthSlotNameSignature
050thisLcom/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問題:

publicclassTestCode{
publicintfoo(){
intx;
try{
x=1;
returnx;
}catch(Exceptione){
x=2;
returnx;
}finally{
x=3;
}
}
}


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

publicclassTestCode{
javacTestCode.java
javap-verboseTestCode.class


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

publicintfoo();
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:astore4//將棧頂引用型數值存入第五個區域性變數=any
19:iconst_3//將int型數值3入棧->棧頂3
20:istore_1//將棧頂第一個int數值存入第二個區域性變數->區域性2=3
21:aload4//將區域性第五個區域性變數(引用型)推送至棧頂
23:athrow//將棧頂的異常丟擲
Exceptiontable:
fromtotargettype
048Classjava/lang/Exception//0到4行對應的異常,對應#8中儲存的異常
0417any//Exeption之外的其他異常
81317any
171917any


在位元組碼的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
packagecom.rhythm7

funAny.sayHello(){
println("Hello")
}


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

Classfile/E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/SayHelloKt.class
Lastmodified2018-4-8;size958bytes
MD5checksum780a04b75a91be7605cac4655b499f19
Compiledfrom"SayHello.kt"
publicfinalclasscom.rhythm7.SayHelloKt
minorversion:0
majorversion:52
flags:ACC_PUBLIC,ACC_FINAL,ACC_SUPER
Constantpool:
//省略常量池部分位元組碼
{
publicstaticfinalvoidsayHello(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//Methodkotlin/jvm/internal/Intrinsics.checkParameterIsNotNull:(Ljava/lang/Object;Ljava/lang/String;)V
6:ldc#17//StringHello
8:astore_1
9:getstatic#23//Fieldjava/lang/System.out:Ljava/io/PrintStream;
12:aload_1
13:invokevirtual#28//Methodjava/io/PrintStream.println:(Ljava/lang/Object;)V
16:return
LocalVariableTable:
StartLengthSlotNameSignature
0170$receiverLjava/lang/Object;
LineNumberTable:
line4:6
line5:16
RuntimeInvisibleParameterAnnotations:
0:
0:#7()
}
SourceFile:"SayHello.kt"


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

publicstaticfinalvoidsayHello(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(),那麼在編譯後的檔案中則不會有這段位元組碼的出現。