字節一面:能聊聊字節碼么?
1.前言
上一篇《??你能和我聊聊Class文件么??》中,我們對Class文件的各個部分做了簡單的介紹,當時留了一個很重要的部分沒講,不是敖丙不想講啊,而是這一部分實在太重要了,不獨立成篇好好zhejinrong 講講都對不起詹姆斯·高斯林。
這最重要的部分當然就是字節碼啦。
先來個定義:Java字節碼是一組可以由Java虛擬機(JVM)執行的高度優化的指令,它被記錄在Class文件中,在虛擬機加載Class文件時執行。
說大白話就是,字節碼是Java虛擬機能夠看明白的可執行指令。
前面的文章中已經強調了很多次了,Class文件不等于字節碼,為什么我要一直強調這個事情呢?
因為在絕大部分的中文資料和博客中,這兩個東西都被嚴重的弄混了...
導致現在一說字節碼大家就會以為和Class文件是同一個東西,甚至有的文章直接把Class文件稱為“字節碼”文件。
這樣的理解顯然是有偏差的。
舉個例子,比如我們所熟知的.exe可執行文件,.exe文件中包含機器指令,但除了機器指令之外,.exe文件還包含其他與準備執行這些指令相關的信息。
因此我們不能說“機器指令”就是.exe文件,也不能把.exe文件稱為“機器指令”文件,它們只是一種包含關系,僅此而已。
同樣的,Class文件并不等于字節碼,只能說Class文件包含字節碼。
上次的文章中我們提到,字節碼(或者稱為字節碼指令)被存儲在Class文件中的方法表中,它以Code屬性的形式存在。
因此,可以通俗地說,字節碼就是Class文件方法表(methods)中的Code屬性。
今天我們來好好聊聊字節碼。
但是在講字節碼知識之前我們需要對Java虛擬機(Java Virtual Machine,簡稱JVM)的內部結構有一個簡單的理解,畢竟字節碼說到底指示虛擬機各個部分需要執行什么操作的命令,先簡單了解JVM,知己知彼方能百戰百勝。
2.JVM的內部結構
我們借這么一張圖來稍微聊聊JVM執行Class文件的流程。
這是學習JVM過程中躲不開的一張圖,當然我們今天不講那么深。
字節碼是對方法執行過程的抽象,于是我們今天只把跟方法執行過程最直接相關的幾個部分拎出來講講。
其實虛擬機執行代碼時,虛擬機中的每一部分都需要參與其中,但本篇我們更關注的是跟"執行過程"相關的幾個部分,也就是跟代碼順序執行這一動態過程相關的幾個部分。有點云里霧里了嗎,不要急,往下看。
以Hello.class作為今天的主角。
當Hello.class被加載時,首先經歷的是Class文件中的信息被加載到JVM方法區中的過程。
方法區是什么?
方法區是存儲方法運行相關信息的一個區域。
如果把Class文件中的信息理解為一顆顆的子彈,那么方法區就可以看做是成JVM的"彈藥庫",而將Class文件中的信息加載到方法區這一過程相當于“子彈上膛”。
只有當子彈上膛后,JVM才具備了“開火”的能力,這很合理吧。
例如,原本記錄在Class文件中的常量池,此時被加載到方法區中,成為運行時常量池。同時,字節碼指令也被裝配到方法區中,為方法的運行提供支持。
類加載動圖
當類Hello.class被加載到方法區后,JVM會為Hello這個類在堆上新建一個類對象。
第二個知識點來咯:堆是 放置對象實例的地方,所有的對象實例以及數組都應當在運行時分配在堆上。
一般在執行新建對象相關操作時(例如 new HashMap),才會在堆上生成對象。
但是你看,我們明明還沒開始執行代碼呢,這才剛處于類的加載階段,堆上就開始進行對象分配了,難道有什么特殊的對象實例在類加載的時候就被創建了嗎?
沒錯,這個實例的確特殊,它就是我們在反射時常常會用到的 java.lang.Class對象!!!
如果你忘了什么是反射的話,我來提醒你一下:
Hello obj = new Hello();
Class<?> clz = obj.getClass();
在Hello這個類的Class文件被加載到方法區的之后,JVM就在堆區為這個新加載的Hello類建立了一個java.lang.Class實例。
說到這里,你對”Java是一門面向對象的語言“這句話有沒有更深入的理解——在Java中,即使連類也是作為對象而存在的。
不僅如此,由于JDK 7之后,類的靜態變量存放在該類對應的java.lang.Class對象中。因此當 java.lang.Class在堆上分配好之后,靜態變量也將被分配空間,并獲得最初的零值。
注意,這里的零值指的不是靜態變量初始化哦,僅僅只是在類對象空間分配后,JVM為所有的靜態變量賦了一個用于占位的零值,零值很好理解嘛,也就是數值對象被設為0,引用類型被設為null。
到這里為止,類的信息已經完全準備好了,接下來要開始的,就是執行方法。我們在《Java代碼編譯流程是怎樣的》一文中討論過,方法是類的構造方法,它的作用是初試化類中所有的靜態變量并執行用static {}包裹的代碼塊,而且該方法的收集是有順序的:
- 父類靜態變量初始化 及 父類靜態代碼塊;
- 子類靜態變量初始化 及 子類靜態代碼塊。
<clinit>方法相當于是把靜態的代碼打包在一起執行,而且函數是在編譯時就已經將這些與類相關的初始化代碼按順序收集在一起了,因此在Class文件中可以看到函數:
當然,如果類中既沒有靜態變量,也沒有靜態代碼塊,則不會有函數。
總之,如果函數存在,那么在類被加載到JVM之后,函數開始執行,初始化靜態變量。
接下來我們今天最重要的部分要登場了!!!
就決定是你了,虛擬機棧!!
第三個知識點:虛擬機棧是線程中的方法的內存模型。
上面這句話聽著很抽象是吧,沒事,我來好好解釋一下。
首先要明白的是,虛擬機棧,顧名思義是用棧結構實現的一種的線性表,其限制是僅允許在表的同一端進行插入和刪除運算,這一端被稱為棧頂,相對地,把另一端稱為棧底。
棧的特性是每次操作都是從棧頂進或者從棧頂出,且滿足先進后出的順序,而虛擬機棧也繼承了這一優良傳統。
虛擬機棧是與方法執行最直接相關的一個區域,用于記錄Java方法調用的“活動記錄”(activation record)。
虛擬機棧以棧幀(frame)為單位線程的運行狀態,每調用一個方法就會分配一個新的棧幀壓入Java棧上,每從一個方法返回則彈出并撤銷相應的棧幀。
例如,這么一段代碼:
public class Hello {
public static int a = 0;
public static void main(String[] args) {
add(1,2);
}
public static int add(int x,int y) {
int z = x+y;
System.out.println(z);
return z;
}
}
它的調用鏈如下:
調用鏈
現在你明白了吧,代碼中層層調用的概念在JVM里是使用棧數據結構來實現的,調用方法時生成棧幀并入棧,方法執行完出棧,直到所有方法都出棧了,就意味著整個調用鏈結束。
還記得二叉樹的前序遍歷怎么寫的嗎:
public void preOrderTraverse(TreeNode root) {
if (root != null) {
System.out.print(root.val + "->");
preOrderTraverse(root.left);
preOrderTraverse(root.right);
}
}
這種遞歸形式本質上就是利用虛擬機棧對同一個方法的遞歸入棧實現的,如果我們寫成非遞歸形式的前序遍歷,應該是這樣子的:
public void preOrderTraverse(TreeNode root) {
// 自己聲明一個棧
Stack<TreeNode> stack = new Stack<>();
TreeNode node = root;
while (node != null || !stack.empty()) {
if (node != null) {
System.out.print(node.val + "->");
stack.push(node);
node = node.left;
} else {
TreeNode tem = stack.pop();
node = tem.right;
}
}
}
二叉樹遍歷的非遞歸形式就是由我們自己把棧寫好,并實現出棧入棧的功能,跟遞歸方式調用的本質是相似的,只不過遞歸操作中我們依賴虛擬機棧來執行入棧出棧。
總之,靠棧可以很好地表達方法間的這種層層調用的層級關系。
當然,棧空間是有限的,如果只有入棧沒有出棧,最后必然會出現空間不足,同時也就會報出經典的StackOverflowError(棧溢出錯誤),最常見的導致棧溢出的情況就是遞歸函數里忘了寫終止條件。
其次,多個線程的方法執行應當為獨立且互不干擾的,因此每一個線程都擁有自己獨立的一個虛擬機棧。
這也導致了各個線程之間方法的執行速度并不能保持一致,有時A線程先執行完,有時B線程先執行完,究其原因就是因為虛擬機棧是線程私有,各自獨立執行。
談完了虛擬機棧的整體情況,我們再來看看虛擬機棧中的棧幀。
棧幀是虛擬機棧中的基礎元素,它隨著方法的調用而創建,記錄了被調用方法的運行需要的重要信息,并隨著方法的結束而消亡。
那么你就要問了,棧幀里到底包裹了些什么東西呀?
好的同學,等我把這個問題回答完,今天的知識你至少就懂了一半。
3.棧幀的組成
棧幀主要由以下幾個部分組成:
- 局部變量表
- 操作數棧
- 動態連接
- 方法出口
- 其他信息
3.1 局部變量表
局部變量表(Local Variable Table)是一個用于存儲方法參數和方法內部定義的局部變量的空間。
一個重要的特性是,在Java代碼被編譯為Class文件時,就已經確定了該方法所需要分配的局部變量表的最大容量。
也就是說,早在代碼編譯階段,就已經把局部變量表需要分配的大小計算好了,并記錄在Class文件中,例如:
public class Hello {
public static void main(String[] args) {
for (int i=0;i<3;i++){
System.out.printf(i+"");
}
}
}
這個類的main方法,通過javap之后可以得到其中的局部變量表:
LocalVariableTable:
Start Length Slot Name Signature
2 41 1 i I
0 44 0 args [Ljava/lang/String;
這個意思就是告訴你,這個方法會產生兩個局部變量,Slot代表他們在局部變量表中的下標。
難道方法里定義了多少個局部變量,局部變量表就會分配多少個Slot坑位嗎?
不不不,編譯器精明地很,它會采取一種稱為Slot復用的方法來節省空間,舉個例子,我們為前面的方法再增加一個for循環:
public class Hello {
public static void main(String[] args) {
for (int i=0;i<3;i++){
System.out.printf(i+"");
}
for (int j=0;j<3;j++){
System.out.printf(j+"");
}
}
}
然后會得到如下局部變量表:
LocalVariableTable:
Start Length Slot Name Signature
2 41 1 i I
45 41 1 j I
0 87 0 args [Ljava/lang/String;
雖然還是三個變量,但是i和j的Slot是同一個,也就是說,他們共用了同一個下標,在局部變量表中占的是同一個坑位。
至于原因呢,相信聰明的你已經看出來了,跟局部變量的作用域有關系。
變量i作用域是第一個for循環的內部,而當變量j創建時,i的生命周期就已經結束了。因此j可以復用i的Slot將其覆蓋掉,以此來節省空間。
所以,雖然看起來創建了三個局部變量,但其實只需要分配兩個變量的空間。
3.2 操作數棧
棧幀中的第二個重要部分是操作數棧。
等等,這怎么又來了個棧,擱這套娃呢???
沒辦法呀,棧這玩意實在太好用了,首先棧的基本操作非常簡單,只有入棧和出棧兩種,這個優勢可以保證每一條JVM的指令都代碼緊湊且體積小;其次棧用來求值也是非常經典的用法,簡單又方便喔。
也有一種基于寄存器的體系結構,將局部變量表與操作數棧的功能組合在一起,關于這兩種體系優劣勢的詳細討論可以移步至R大的博客:https://www.iteye.com/blog/rednaxelafx-492667
至于用棧來求值這種用法,大家在《數據結構》課上學棧這一結構的時候應該都接觸過了,這里不多展開。如果沒有印象了,建議看看Leetcode上的這一題:https://leetcode-cn.com/problems/evaluate-reverse-polish-notation/
總之,情況就是這么個情況,虛擬機棧的每一個棧幀里都包含著一個操作數棧,作用是保存求值的中間結果和調用別的方法的參數等。
3.3 動態連接
動態連接這個名詞在全網的JVM中文資料中解釋得非常混亂,在你基礎沒有打牢之前不建議你深入去細究,腦子會亂掉的。
我這里會給大家一個非常通俗易懂的解釋,了解即可。
首先,棧幀中的這個動態連接,英文是Dynamic Linking,Linking在這里是作為名詞存在的,跟前面的表、棧是同一個層次的東西。
這個連接說白了就是棧幀的當前方法指向運行時常量池的一個引用。
為什么需要有這個引用呢?
前面說了,Class文件中關鍵信息都保存在方法區中,所以方法執行的時候生成的棧幀得知道自己執行的是哪個方法,靠的就是這個動態連接直接引用了方法區中該方法的實際內存位置,然后再根據這個引用,讀取其中的字節碼指令。
至于"動態"二字,牽扯到的就是Java的繼承和多態的機制,有的類繼承了其他的類并重寫了父類中的方法,因此在運行時,需要"動態地"識別應該要連接的實際的類、以及需要執行的具體的方法是哪一個。
3.4 方法出口
當一個方法開始執行,只有兩種方式退出這個方法,第一種方式是正常返回,即遇到了return語句,另一種方式則是在執行中遇到了異常,需要向上拋出。
無論是那種形式的返回,在此方法退出之后,虛擬機棧都應該退回到該方法被上層方法調用時的位置。
棧幀中的方法出口記錄的就是被調用的方法退出后應該回到上層方法的什么位置。
好了,到這里為止,棧幀中的內容就介紹結束了,接下來我們用一個簡單的例子來了解字節碼指令,以及執行執行時JVM各區域的運行過程。
4.實例:++i與i++的字節碼實例
public class Hello {
public static int a = 0;
public static void main(String[] args) {
int b = 0;
b = b++;
System.out.println(b);
b = ++b;
System.out.println(b);
a = a++;
System.out.println(a);
a = ++a;
System.out.println(a);
}
}
這段程序的輸出會是是這樣的:
0
1
0
1
這是初學Java時一道經典的誤導題,大家可能已經知其然,一眼就能看出正確的結果,可對于最底層的原理卻未必知其所以然。
b=b++執行完后變量b并沒有發生變化,只有在b=++b時變量b才自增成功。
這里其實涉及到自增操作在字節碼層面的實現問題。
我們先來看看這一段代碼對應的字節碼是怎樣的,使用jclasslib來查看Hello類的main方法中的Code屬性:
將Code中的信息粘貼出來:
0 iconst_0
1 istore_1
2 iload_1
3 iinc 1 by 1
6 istore_1
7 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
10 iload_1
11 invokevirtual #3 <java/io/PrintStream.println : (I)V>
14 iinc 1 by 1
17 iload_1
18 istore_1
19 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
22 iload_1
23 invokevirtual #3 <java/io/PrintStream.println : (I)V>
26 getstatic #4 <com/cc/demo/Hello.a : I>
29 dup
30 iconst_1
31 iadd
32 putstatic #4 <com/cc/demo/Hello.a : I>
35 putstatic #4 <com/cc/demo/Hello.a : I>
38 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
41 getstatic #4 <com/cc/demo/Hello.a : I>
44 invokevirtual #3 <java/io/PrintStream.println : (I)V>
47 getstatic #4 <com/cc/demo/Hello.a : I>
50 iconst_1
51 iadd
52 dup
53 putstatic #4 <com/cc/demo/Hello.a : I>
56 putstatic #4 <com/cc/demo/Hello.a : I>
59 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
62 getstatic #4 <com/cc/demo/Hello.a : I>
65 invokevirtual #3 <java/io/PrintStream.println : (I)V>
68 return
Emmm....看起來有點密密麻麻,不知道該從哪看起。
其實閱讀字節碼指令是有技巧的,字節碼和源碼的對應關系已經記錄在了字節碼中,也就是Code屬性中的LineNumberTable,這里記錄的是源碼的行號和字節碼行號的對應關系。
如圖,右側的起始PC指的是字節碼的起始行號,行號則是字節碼對應的源碼行號。
將這個例子中的源碼和字節碼對應起來的效果如圖所示:
這么一對應,是不是就清晰很多了?
掌握了這個技巧之后我們就可以開始分析整體的流程和細節了。
4.1 靜態變量賦值
首先來捋一捋,當Hello類加載到JVM之后發生了什么,按我們前面說的,加載完成之后,虛擬機棧需要進行方法入棧,而眾所周知,main方法是執行的入口,所以main方法最先入棧。
但是,是這樣的嗎?
別忘記了這一行代碼:
靜態變量的賦值需要在main方法之前執行,前面已經提到了,靜態變量的賦值操作被封裝在方法中。
因此,**方法需要先于main方法入棧執行**,在本例中,方法長這樣:
當然,方法的LineNumberTable也記錄了字節碼跟源碼的對應關系,只不過在這里對應源碼只有一行:
因此public static int a = 0;這一行源代碼就對應了三行的字節碼:
0 iconst_0
1 putstatic #4 <com/cc/demo/Hello.a : I>
4 return
簡直沒有比這更適合作為字節碼教學入門素材的了!
接下來就可以開始愉快地手撕字節碼了。
第一句iconst_0,在官方的JVM規范中是這么解釋的:“Push the int constant onto the operand stack”,也就是說iconst操作是把一個int類型的常量數據壓入到操作數棧的棧頂。
這個指令開頭的字母表示的是類型,在本例中i代表int。我們可以舉一反三,當然還會有lconst代表把long類型的常量入棧到棧頂,有fconst指令表示把float類型的常量推到棧頂等等等等。
這個指令結尾的數字就是需要入棧的值了~
恭喜你,看完上面這段話,你至少已經學會了n種字節碼指令了。
不就是排列組合嘛,so easy!
再來看第二句,putstatic #4,光看字面意思就能很容易的猜出它的作用,這個指令的含義是:當前操作數棧頂出棧,并給靜態字段賦值。
把剛才放到操作數棧頂的0拿出來,賦值給常量池中#4位置字面量表示的靜態變量,這里可以看到#4位置的字面量就是。
所以,這第二行字節碼,本質上是一個賦值操作,將0這個值賦給了靜態變量a。
靜態變量存儲在堆中該類對應的Class對象實例中,也就是我們在反射機制中用對應類名拿到的那個Class對象實例。
最后一行是一個return,這個沒啥好說的。
好了,這就是本例中的方法中的全部了,并不難吧。
當<clinit>方法執行完出棧后,main方法入棧,開始執行main方法Code屬性中的字節碼指令。
為了方便講解,接下來我會逐行將源碼與其對應的字節碼貼在一起。
4.2 局部變量賦值
首先是源碼中的第六行 ,也就是main函數的第一句:
//Source code
int b = 0;
//Byte code
0 iconst_0
1 istore_1
這一句源碼對應了兩行字節碼。
其中,iconst_0這個在前面已經講過了,將int類型的常量從棧頂壓入,由于此時操作數棧為空,所以0被壓入后理所當然地既是棧頂,也是棧底。
然后是istore_1命令,這個跟iconst_0的結構很像,以一個類型縮寫開頭,以一個數字結尾,那么我們只要弄清楚store的含義就行了,store表示將棧頂的對應類型元素出棧,并保存到局部變量表指定位置中。
由于此時的棧頂元素就是剛才壓入的int類型的0,所以我們要存儲到局部變量表中的就是這個0。
那么問題來了,這個值需要放到局部變量表中的哪個位置呢?
在iconst_0命令中,末尾的數字代表需要入棧的常量,但在istore_1命令中,操作數是從操作數棧中取出的,是不用聲明的,那istore_1命令末尾這個數字的用途是什么呢?
前面說了,store表示將棧頂的對應類型元素保存到局部變量表指定位置中。
因此iconst_0指令末尾這個數字代表就是指定位置啦,也就是局部變量表的下標。
從LocalVariableTable中可以看出,下標為1的位置中存儲的就是局部變量b。
下標0位置存儲的是方法的入參。
總之,istore_1這個命令就意味著棧頂的int元素出棧,并保存到局部變量表下標為1的位置中。
同樣的,stroe這個命令也可以與各種類型縮寫的開頭組合成不同的命令,像什么lstroe、fstore等等。
ok,這又是一個經典的聲明和賦值操作。
4.3 局部變量
自增4.3.1 i++過程
我們繼續往下看,源碼第七行和它對應的字節碼:
//Source code
b = b++;
//Byte code
2 iload_1
3 iinc 1 by 1
6 istore_1
首先是iload_1命令,這個命令是與istore_1命令對應的反向命令。
store不是從操作數棧棧頂取數存到局部變量表中嘛,那么load要做的事情恰恰相反,它做的是從局部變量表指定位置中取數值,并壓入到操作數棧的棧頂。
那么iload_1詳細來說就是:從局部變量表的位置1中取出int類型的值,并壓入操作數棧。
但是,這里的取值操作其實是一個“拷貝”操作:從局部變量表中取出一個數,其實是將該值復制一份,然后壓入操作數棧,而局部變量表中的數值還保存著,沒有消失。
然后是一個iinc 1 by 1指令,這是一個雙參數指令,主要的功能是將局部變量表中的值自增一個常量值。
iinc指令的第一個參數值的含義是局部變量表下標,第二個參數值需要增加的常量值。
因此**iinc 1 by 1就表示局部變量表中下標為1位置的值增加1。**
再來看第三條指令istore_1,這個很熟悉了,操作數棧棧頂元素出棧,存到局部變量表中下標為1的位置。
等等,是不是有什么奇怪的事情發生了。
iinc 1 by 1就表示局部變量表中下標為1位置的值由0變成了1,但是istore_1把一開始從局部變量表下標1復制到操作數棧的0值又賦值到了下標位置1。
因此無論中間局部變量表中的對應元素做了什么操作,到了這一步都直接白費功夫,相當于是脫褲子放屁了。
來個動圖,看得更清晰:
局部變量b++流程
因此b = b++從字節碼上來看,自增后又被初始值覆蓋了,最終自增失敗。
繼續看下一句:
//Source code
System.out.println(b);
//Byte code
7 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;>
10 iload_1
11 invokevirtual #3 <java/io/PrintStream.println : (I)V>
這一句是與控制臺打印有關的字節碼,與今天的主題聯系不大,稍微過一下即可。
getstatic #2是獲取常量池中索引為#2的字面量對應的靜態元素。
iload_1 從局部變量表中索引為1的位置取數值,并壓入到操作數棧的棧頂,這里取的就是變量b的值啦。
然后最后一句是invokevirtual #3,invoke這個單詞我們在代理模式中也經常見到,是調用的意思,因此invokevirtual #3代表的就是 調用常量池索引為3的字面量對應的方法,這里的對應方法就是java/io/PrintStream.println,
最終,將變量b的值打印出來。
4.3.2 ++i過程
再來看看++b操作:
//Source code
b = ++b;
//Byte code
14 iinc 1 by 1
17 iload_1
18 istore_1
這里的三行字節碼與前面講解的b=b++中的字節碼完全一樣,只是順序發生了變化:
先在局部變量表中自增(iinc 1 by 1),然后再入棧到操作數棧中(iload_1),最后出棧保存到局部變量表中(istore_1)。
先自增就保證了自增操作是有效的,不管后面怎么折騰,參與的都是已經自增后的值,來個動圖:
4.4 靜態變量自增
最后我們看看靜態變量a的自增操作:
//Source code
a = a++;
//Byte code
26 getstatic #4 <com/cc/demo/Hello.a : I>
29 dup
30 iconst_1
31 iadd
32 putstatic #4 <com/cc/demo/Hello.a : I>
35 putstatic #4 <com/cc/demo/Hello.a : I>
getstatic #4?就是獲取常量池中索引為#4的字面量對應的靜態字段。前面已經講過了,這一步是到堆中去拿的,拿到靜態變量的值以后,會放到當前棧幀的操作數棧。
然后執行dup操作,dup是duplicate的縮寫,意思是復制。
dup指令的意義就是復制頂部操作數堆棧值并壓入棧中,也就是說此時的棧頂有兩個一模一樣的元素。
這是個什么操作啊,兩份一樣的值能干什么,別急,我們繼續往下看。
隨后是一個iconst_1,將int類型的數值1壓入棧頂。
然后是一個iadd指令,這個指令是將操作數棧棧頂的兩個int類型元素彈出并進行加法運算,最后將求得的結果壓入棧中。
像這種兩個值進行數值運算的操作,其實是操作數棧中除了簡單的入棧出棧外最常見的操作了。
類似的還有isub——棧頂兩個值相減后結果入棧,imul——棧頂兩個值相乘后結果入棧等等。
總之,此時的棧頂最上面的兩個元素是剛剛壓入棧的常量1以及靜態變量a的值0(這是剛才dup之后壓入棧的那個),這兩數一加,結果入棧,那還是個1。
接下來的指令是一個 putstatic #4,取棧頂元素出棧并賦值給靜態變量,這里當然就是靜態變量a啦。
因此靜態變量a的值就自增完成,變成了1。
可是!!!
事情到這里還沒結束,因為字節碼中清清楚楚地記錄著隨后又進行了一次 putstatic #4操作。
此時的棧頂元素就是最開始從堆中取過來的變量a的初始值0,現在把這個值出棧,又賦值給了a,這不是中間的操作都白費了嗎?
靜態變量a的值又變成0了。
等等,這一波脫褲子放屁的操作怎么似曾相識?
前面局部變量b = b++好像也經歷過這么一個過程,先復制一份自己到操作數棧中,然后局部變量表里的值一頓操作,最后操作數棧中的原始值又跑回去把自己給覆蓋了。
靜態變量不遠萬里從堆中趕到操作數棧,先復制一份自己造了個分身到操作數棧棧頂,隨后對這個棧頂的分身一頓操作,最后留在操作數棧中的原始值又跑回去把自己給覆蓋了。
難道說,這波復制操作是因為靜態變量需要分配一個位置充當局部變量表的作用,另一個位置需要充當操作數棧位置的作用?
為了驗證這個猜測是否正確,我們最后來看看a = ++a:
//Source code
a = ++a;
//Byte code
47 getstatic #4 <com/cc/demo/Hello.a : I>
50 iconst_1
51 iadd
52 dup
53 putstatic #4 <com/cc/demo/Hello.a : I>
56 putstatic #4 <com/cc/demo/Hello.a : I>
相信大家閱讀這一段字節碼已經沒有問題了,我只講講中間幾句最重要的:
靜態變量a從堆中被復制到操作數棧之后,緊跟的是一個iconst_1,將int類型的數值1壓入棧頂。
然后是一個iadd指令,將操作數棧棧頂的兩個int類型元素彈出并進行加法運算,也就是剛剛壓入棧的常量1以及靜態變量a的值0進行求和操作。
這兩數一加,結果入棧,那就是個1。
接下來有意思了,進行了一次dup操作,那操作數棧中的棧頂此時就有兩個1了。
這跟執行++b時,局部變量先在局部變量表中自增,再復制一份到操作數棧的操作是不是很像?
然后是兩個 putstatic #4,取棧頂元素出棧并賦值給靜態變量,現在棧頂兩個都是1,即使賦值兩次,最終靜態變量a的值還得是1啦。
懂了嗎寶,一切的源頭就是因為靜態變量被加載到棧幀后不能加入局部變量表,因此它將自己的一個分身壓到棧頂,現在操作數棧中有兩個一模一樣的值,一個充當局部變量表的作用,另一個充當正常操作數棧位置的作用。
5.小結
俗話說,授人以魚不如授人以漁。本文通過對虛擬機結構的簡單介紹,慢慢引申到字節碼的執行的過程。
最后用兩個例子一步一步手撕字節碼,跟著這個思路思考,相信大家以后遇到字節碼的問題也能稍微有點頭緒了吧。