字節(jié)碼原理淺析 —— 基于棧的執(zhí)行引擎
字節(jié)碼是運行在 JVM 上的,為了能弄懂字節(jié)碼,需要對 JVM 的運行原理有所了解。這篇文章將以棧幀為切入點理解字節(jié)碼在 JVM 上執(zhí)行的細(xì)節(jié)。
虛擬機(jī)
虛擬機(jī)常見的實現(xiàn)方式有兩種:Stack based 的和 Register based。比如基于 Stack 的虛擬機(jī)有Hotspot JVM、.net CLR,這種基于 Stack 實現(xiàn)虛擬機(jī)是一種廣泛的實現(xiàn)方法。而基于 Register 的虛擬機(jī)有 Lua 語言虛擬機(jī) LuaVM 和 Google 開發(fā)的安卓虛擬機(jī) DalvikVM。
兩者有什么不同呢?舉一個計算兩數(shù)相加的例子:c = a + b 基于 HotSpot JVM 的源碼和字節(jié)碼如下
基于寄存器的 LuaVM 的 lua 源碼和字節(jié)碼如下,查看字節(jié)碼使用luac -l -l -v -s test.lua
命令
基于寄存器的 add 指令直接把寄存器 R0 和 R1 相加,結(jié)果保存在寄存器 R2 中。
基于棧和基于寄存器的過程對比如下:

基于棧和寄存器的指令集各有優(yōu)缺點,基于棧的指令集移植性更好,代碼更加緊湊、編譯器實現(xiàn)更加簡單,但完成相同功能所需的指令數(shù)一般比寄存器架構(gòu)多,需要頻繁的入棧出棧,棧架構(gòu)指令集的執(zhí)行速度會相對而言慢一些。
為了理解字節(jié)碼的細(xì)節(jié),我們需要詳細(xì)了解字節(jié)碼的執(zhí)行過程。眾所周知,Hotspot JVM 是一個基于棧的虛擬機(jī),每個線程都有一個虛擬機(jī)棧,存儲了「棧幀」。每次方法調(diào)用都伴隨著棧幀的創(chuàng)建銷毀。
棧幀
棧幀(Stack Frame)是用于支持虛擬機(jī)進(jìn)行方法調(diào)用和方法執(zhí)行的數(shù)據(jù)結(jié)構(gòu) 棧幀隨著方法調(diào)用而創(chuàng)建,隨著方法結(jié)束而銷毀,棧幀的存儲空間分配在 Java 虛擬機(jī)棧中,每個棧幀擁有自己的局部變量表(Local Variables)、操作數(shù)棧(Operand Stack) 和 指向運行時常量池的引用

局部變量表
每個棧幀內(nèi)部都包含一組稱為局部變量表(Local Variables)的變量列表,局部變量表的大小在編譯期間就已經(jīng)確定。Java 虛擬機(jī)使用局部變量表來完成方法調(diào)用時的參數(shù)傳遞,當(dāng)一個方法被調(diào)用時,它的參數(shù)會被傳遞到從 0 開始的連續(xù)局部變量列表位置上。當(dāng)一個實例方法(非靜態(tài)方法)被調(diào)用時,第 0 個局部變量是調(diào)用這個實例方法的對象的引用(也就是我們所說的 this )

操作數(shù)棧
每個棧幀內(nèi)部都包含了一個稱為操作數(shù)棧的后進(jìn)先出(LIFO)棧,棧的大小同樣也是在編譯期間確定。Java 虛擬機(jī)提供的一些字節(jié)碼指令用來從局部變量表或者對象實例的字段中復(fù)制常量或者變量到操作數(shù)棧,也有一些指令用于從操作數(shù)棧取走數(shù)據(jù)、操作數(shù)據(jù)和把操作結(jié)果重新入棧。在方法調(diào)用時,操作數(shù)棧也用來準(zhǔn)備調(diào)用方法的參數(shù)和接收方法返回的結(jié)果。
比如 iadd 指令用來將兩個 int 類型的數(shù)值相加,它要求執(zhí)行之前操作數(shù)棧已經(jīng)存在兩個由前面其它指令放入的 int 型數(shù)值,在 iadd 指令執(zhí)行時,兩個 int 值從操作數(shù)棧中出棧,相加求和,然后將求和的結(jié)果重新入棧。
比如 1 + 2
這樣的指令執(zhí)行過程如下

整個 JVM 指令執(zhí)行的過程就是局部變量表與操作數(shù)棧之間不斷 load、store 的過程

我們再來看一個稍微復(fù)雜一點的例子
javap 查看字節(jié)碼輸出如下
0 ~ 7:新建了一個 ScoreCalculator 對象,使用 astore_1 存儲在局部變量 calculator 中:astore_1 的含義是把棧頂?shù)闹荡鎯Φ骄植孔兞勘硐聵?biāo)為 1 的位置上,這里為什么會有一個 dup,我們后面會講到
8 ~ 11:iconst_1 和 iconst_2 用來將整數(shù) 1 和 2 加載到棧頂,istore_2 和 istore_3 用來將棧頂?shù)脑卮鎯Φ骄植孔兞勘?2 和 3 的位置上
12 ~ 15:可以看到 store 指令會把棧頂元素移除,所以下次我們要用到這些局部變量時,需要使用 load 命令重新把它加載到棧頂。比如我們要執(zhí)行calculator.record(score1),對應(yīng)的字節(jié)碼如下
可以看到 aload_1 先從局部變量表中 1 的位置加載 calculator 對象,iload_2 從 局部變量表中 2 的位置加載一個整型值,i2d 這個指令用來將整型值轉(zhuǎn)為 double 并將新的值重新入棧,到目前為止參數(shù)全部就緒,可以用 invokevirtual 執(zhí)行方法調(diào)用了
24 ~ 28:同樣是一個普通的方法調(diào)用,流程還是先 aload_1 加載 calculator 對象,invokevirtual 調(diào)用 getAverage 方法,并將 棧頂元素存儲到局部變量表下標(biāo)為 4 的位置上 有一點需要注意的是 javap 輸出的locals=6,但是我們目前看到的局部變量只有
args、calculator、score1、score2、avg
這 5 個,為什么這里等于 6 呢?這是因為 avg 為 double 型變量,需要兩個槽位(slot) 整個過程局部變量表如下圖所示

其實局部變量表可以通過 javap 用 -l 參數(shù)直接輸出,但是我們用 javap -v -p -l MyLocalVariableTest
并沒有輸出任何局部變量表相關(guān)的信息。這是因為默認(rèn)情況下局部變量表屬于調(diào)試級別的信息,javac 編譯的時候并沒有編譯進(jìn)字節(jié)碼,我們可以加上 javac -g 生成字節(jié)碼的時候同時生成所有的調(diào)試信息,如下所示
從二進(jìn)制看 class 文件和字節(jié)碼

直接從二進(jìn)制來看下這個 class 文件 xxd Get.class

我們可以手動用 16 進(jìn)制編輯器去修改這些字節(jié)碼文件,只是比較容易出錯,所以產(chǎn)生了一些字節(jié)碼操作的工具,最出名的莫過于 ASM 和 Javassist。我們后面講到軟件破解的時候,會介紹直接修改字節(jié)碼和通過 ASM 動態(tài)修改字節(jié)碼這兩種方式
小結(jié)
一起來回顧一下這篇文章的要點:
第一,基于棧和基于寄存器指令集的優(yōu)劣勢;
第二,講解了 JVM 棧幀的構(gòu)成(局部變量表、操作數(shù)棧、指向運行時常量池的引用),順帶講解了 javap -l 參數(shù)和其在局部變量表中的應(yīng)用;
第三,從類文件二進(jìn)制角度看字節(jié)碼的實現(xiàn),并引出 ASM 字節(jié)碼改寫技術(shù)。
轉(zhuǎn)載: https://mp.weixin.qq.com/s/tOZ7t8Tm1ZLZets3KnNeRw
本文使用 文章同步助手 同步