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