前言
java文件被jvm編譯成.class文件,.class文件中全部是二進制的數據。在JVM中用一個8bit的變量類型存儲指令,這樣0到255可以表示總共256個指令。我們編寫的代碼被編譯成相應的指令碼交給計算機執行,而在對代碼進行調優的時候,可以讀懂編譯後的.class文件也是很重要的。
基礎知識
Java內存模型
首先要先了解一下Java的內存模型,我們比較關心的主要有四個部分:堆,虛擬機棧,本地方法棧,方法區。
堆一般存放對象實例,也就是我們new 分配的對象,堆中依據對象存活時間分爲新生代和老年代,新生代中又分爲Eden空間和Survivor空間(主要是執行復制回收算法時使用的空間),新生代的Survivor空間又分成from空間和to空間。這裏簡單介紹一下新生代中發生垃圾回收的過程:
新生代生成對象的時候,首先分配在Eden區,當Eden區滿了以後,執行復制清除算法,將Eden區的對象複製到From然後清除Edne區。這樣下一輪時,可以繼續在Eden區中分配,Eden和From區是目前存活的對象,當Eden再次滿了以後,下一次MinorGC(針對新生代的GC,MajorGC針對老年代,FullGC針對全部)會將Eden和From複製到To中並清除原來的內容,以此類推。如果存活的對象太多導致Survivor區域無法容納,還需要老年代進行分配擔保,將無法保存在Survivor中的對象直接晉升到老年代。
方法區主要存放一些靜態變量,類信息等,通常我們可以把這部分看作永久代,因爲其中分配的對象不會被垃圾收集。
運行時常量池是class文件中每一個類或者接口的常量表,包含了字面量和符號飲用,充當一個符號表的作用。比如Java中的字符串,默認聲明其實作爲字面量存儲在常量池中的,參考如下代碼:
String a = "abc";
String b = "abc";
String c = new String("abc");
System.out.println(a == b);
System.out.println(a == c);
代碼中,a==b返回true,a==c返回false,這是因爲默認a和b的聲明方式實際是在常量池中聲明一個符號,然後a和b都指向那個符號”abc”,而c是在堆中聲明瞭一個char數組。java中==默認判斷的是兩個值地址是否相等,所以第一個返回true,第二個返回false。
前面提到,常量池相當於一個符號表。我們可以把它看作一個表結構,鍵是常量地址,值就是存儲的值。如下:
而符號引用,其實就是指類和接口的全限定名和方法的描述符,熟悉jni的話會比較清楚這些東西。簡單來講,在一個實例中可能有另一個對象的引用,那麼在class文件中其實存放的是符號引用,也就是java/lang/object 這種字符串,而不是真正的指向內存地址的引用。在動態鏈接的過程,纔會把class文件中這些“假的”符號飲用轉換成真正的直接引用(也就是指向一個內存地址)。
本地方法棧就是通過Jni調用底層方法的時候,本地C/C++代碼執行時候的方法棧。
虛擬機棧就是我們的Java代碼執行的棧了,也是本文的重點。

Java虛擬機棧
棧幀
當一個方法被調用的時候,就進入它所在的方法棧,棧幀隨着方法的創建而創建,隨着方法的結束而銷燬。每一個棧幀都擁有自己的本地變量表,操作數棧和運行時常量池的引用。
局部變量表
局部變量表中用slot來進行存儲,一個slot可以存放一個boolean、byte、char、short、int、float、reference或returnAddress的數據,兩個slot可以存儲一個long或double。
操作數棧
每個棧幀中有一個操作數棧,用來存放指令執行的中間結果。有點類似於一個棧實現的計算器。
比如當我們執行一個iadd指令中,則要求操作數棧頂是兩個int類型的數值,執行iadd後會把兩個數值取出來求和再把結果放回操作數棧中。
基本指令集
.class文件是二進制文件,嚴格規定了每個字節的含義。是一組以8位字節爲基礎單位的二進制流,所以又叫字節碼。只有兩種數據類型:無符號數和表。
無符號數可以存放數字、索引引用或者UTF-8編碼構成的字符串值。
表是無符號數和其他表構成的符合數據類型。指令碼由一個字節表示,不同的數字0到200多代表不同的指令。
當然在分析的時候,我們一般使用javap -verbose 命令對class文件進行反編譯,可以得到相應的明文指令,避免了我們對字節碼參照JVM規範手冊人工去翻譯。所以我們主要關注的是一些指令的具體含義。
加載和存儲指令
- 將一個本地變量加載到操作數棧:load相關指令,比如iload加載int類型,fload加載float類型等
- 將數值從操作數棧加載到本地變量表:store相關指令
- 加載常量到操作數棧:push、const相關指令
對於一些指令,比如iload_1,iload_2就是將操作數隱藏在指令中,就等同於iload 1,iload 2.
此外,需要說明,iconst n是把常量n壓入操作數棧,istore n 是把操作數棧頂的數存在本地變量表第n個位置,iload n是把本地變量表第n個元素壓入操作數棧,本地變量表可以看作一個ArrayList鏈表數據結構。
可以看到在棧幀中,對於變量的操作流程,基本就是把值從常量池拿到操作數棧,要存儲的話就放在本地變量表,要計算了再拿到操作數棧,然後調用相應的指令進行計算。
算數指令
算數指令用於兩個操作數棧上的值進行特定運算,並把計算結果壓入操作數棧。比如add,sub,mul,div相關。
方法調用和返回指令
invokevirtual用於調用實例方法,invokespecial用於調用一些特殊實例方法,比如構造方法,invokestatic用於調用靜態方法。
返回指令即return相關,比如ireturn。
其餘指令
其餘指令包括類型轉換指令,對象創建與操作指令,操作數棧管理指令和控制轉移指令,在這裏不詳細介紹可以查閱Java虛擬機規範。
實例
下面介紹一個工程實例,Java代碼如下:
public class Main {
public int cal() {
int a = 1;
int b = 1;
return a + b;
}
public int getInteger() {
Random random = new Random();
return random.nextInt(5);
}
}
利用javap -verbose命令對class文件進行反編譯,查看字節碼:
反編譯後,生成的字節碼如下:
public class Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#24 // java/lang/Object."<init>":()V
#2 = Class #25 // java/util/Random
#3 = Methodref #2.#24 // java/util/Random."<init>":()V
#4 = Methodref #2.#26 // java/util/Random.nextInt:(I)I
#5 = Class #27 // Main
#6 = Class #28 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LMain;
#14 = Utf8 cal
#15 = Utf8 ()I
#16 = Utf8 a
#17 = Utf8 I
#18 = Utf8 b
#19 = Utf8 getInteger
#20 = Utf8 random
#21 = Utf8 Ljava/util/Random;
#22 = Utf8 SourceFile
#23 = Utf8 Main.java
#24 = NameAndType #7:#8 // "<init>":()V
#25 = Utf8 java/util/Random
#26 = NameAndType #29:#30 // nextInt:(I)I
#27 = Utf8 Main
#28 = Utf8 java/lang/Object
#29 = Utf8 nextInt
#30 = Utf8 (I)I
{
public 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 LMain;
public int cal();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: iconst_1
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: ireturn
LineNumberTable:
line 6: 0
line 7: 2
line 8: 4
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 this LMain;
2 6 1 a I
4 4 2 b I
public int getInteger();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class java/util/Random
3: dup
4: invokespecial #3 // Method java/util/Random."<init>":()V
7: astore_1
8: aload_1
9: iconst_5
10: invokevirtual #4 // Method java/util/Random.nextInt:(I)I
13: ireturn
LineNumberTable:
line 12: 0
line 13: 8
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this LMain;
8 6 1 random Ljava/util/Random;
}
SourceFile: "Main.java"
下面開始分析主要部分:
運行時常量池
Constant pool 就是前面提到的運行時常量池,類似一個符號表,前面表示在常量池中的地址,後面的字段就是相應的值,同時Javap還會幫助我們生成一些字段輔助查看:
#1 = Methodref #6.#24 // java/lang/Object.””:()V
#2 = Class #25 // java/util/Random
#25 = Utf8 java/util/Random
#26 = NameAndType #29:#30 // nextInt:(I)I
在我們class文件的常量池中有四種類型的常量,Utf8就是字面常量,一個字符串。而Methodref是一個方法的符號引用,爲什麼說是符號引用呢,就是因爲最後看到它其實就是一個方法描述符:字符串而已。同時,Class表示引用到的一個類,NameAndType表示一個字段或者方法。
Code屬性
下面是方法的描述符標誌位等信息,然後是最重要的信息:code屬性。
分析其中的cal方法:
public int cal();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
...
LineNumberTable:
line 6: 0
line 7: 2
line 8: 4
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 this LMain;
2 6 1 a I
4 4 2 b I
stack = 2表明操作數棧最大深度是2,locals=3是本地變量表最大數目爲3,args_size = 1,是因爲該方法是實例方法,所以默認有一個輸入參數this,指向方法所在的實例。
下面的LineNumberTable表示java源代碼和字節碼的對應關係,用於堆棧跟蹤。
LocalVariableTable描述局部變量表中的變量和Java源代碼定義變量的對應關係。其中start代表局部變量聲明開始,length表示在字節碼中存活的長度,結合起來就是作用域範圍。參考下面的指令碼:this在一開始就聲明,時間限定爲0,變量a在第二個時刻 istore_1存入局部變量表,所以start的值就是2,b在第四個時刻存入,所以start是4,聲明週期可以依次推理。
0: iconst_1
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: ireturn
再來簡單分析一下指令,結合前面提高的指令介紹:
iconst_1,將常數1加載到操作數棧,istore_1將1從操作數棧棧頂元素1存儲到局部變量表第一個位置,下面的2,3條指令同理。iload_1把局部變量表第一個元素加入操作數棧,iload_2同理,現在操作數棧有兩個元素 1、1,然後iadd取出棧頂兩個元素相加,ireturn返回。
總結
基本內容就是這些,對於字節碼的學習,更詳細的內容可以參考Java虛擬機規範。