前言
Android開發講道理更應該卷的是dex字節碼,但實際上做應用開發時,插樁流程往往在class2dex的過程中,一些插樁框架最終操作的還是class字節碼。Java世界JVM最終執行字節碼指令,將class字節碼解釋成機器碼執行;Android世界ART最終執行dex,將dex字節碼解釋成機器碼執行。當然ART本身也做了好幾次變更,現在基本上是解釋、JIT、AOT混合的方式執行,見下圖:
簡單的總結就是,app啟動時將dex加載進內存,首次執行方法時解釋執行,并將多次執行的熱點方法配置到profile文件,機器在充電或其它閑置狀態時執行dex2oat
將profile文件配置的熱點函數AOT編譯成.oat
文件,.oat
本質上就是個elf文件了。后續方法執行時先找.oat,然后判斷是否為熱點函數,熱點函數取JIT Cache,未命中緩存JIT編譯執行并添加到JIT Cache,都沒有就解釋執行。
dex字節碼指令是基于寄存器的,class字節碼是基于棧的,虛擬機層面的寄存器雖然也是內存模擬的,但是同樣的操作基于寄存器的指令相比較于基于棧的class字節碼是要少一些的,指令少就意味著對內存的訪問次數少,執行效率自然就高。一般來說應用開發不太會去操作dex字節碼,除非說做逆向,反編譯dex,拿到smali文件,調試魔改重簽名。雖然運行時也有dexmaker這種框架可以用Java api動態生成dex指令,但實際上還是在class字節碼層面操作更多。
無論是class字節碼還是dex字節碼,都是編譯器前端的產物IR,dex字節碼相當于對class字節碼再轉換了一層,更適用于資源有限的移動平臺機器。
JVM內存模型
BB了一堆,回到本文的重點。Java字節碼是一組可以由JVM執行的高度優化的指令,它被記錄在Class文件中,在虛擬機加載Class文件時執行。Class文件并不等于字節碼,Class文件包含字節碼,通俗來說字節碼就是Class文件方法表(methods)中的Code屬性。
先看看JDK8的內存結構,偷張圖:
當類被首次加載時,會在堆上創建類的Class對象其實也就是反射會用到的Class對象,同時Class文件中的信息被加載到JVM方法區中,字節碼指令也會裝配到方法區中,為方法的運行提供支持。
虛擬機棧是描述方法的內存模型,屬于線程私有。虛擬機棧中方法調用時會創建棧幀,方法return返回時相應的會將棧幀出棧。棧幀可視為單個方法的內存模型,其又包括局部變量表、操作數棧、動態連接、方法出口。
- 局部變量表用于儲存方法參數和局部變量。
- 操作數棧保存求值的中間結果、調用別的方法參數。
- 動態連接指的是棧幀當前方法指向運行時常量池的一個引用,說白了也就是找到對應方法區的方法然后根據這個引用讀取字節碼。
- 方法出口記錄被調用的方法退出后回到上層方法的位置。
下面通過例子看一下字節碼指令執行流程。
加載存儲指令
根據我自己的學習方法,重點先關注局部變量表、操作數棧和三個字節碼指令:
- 常量入棧指令
將一個常量加載到操作數棧 :bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
- 局部變量壓棧指令
xload、xload_<n>(其中x為i、l、f、d、a,而n為0~3)
- 出棧裝入局部變量表指令
將一個數值從操作數棧存儲到局部變量表:xstore、xstore_<n>其中x為i、l、f、d、a,而n為0~3);xastore(其中x為i、l、f、d、a、b、c、s)
,局部變量表可以看做一個數組,對于非靜態方法索引為0的位置默認為this指針。
說起來很抽象,下面看例子
public void add() {
int a = 3;
int b = 6;
int c = a + b;
}
對應字節碼,逐行注釋。這里用asm插件查看字節碼,除了大小寫之外基本沒啥區別。
public add()V
ICONST_3 //常量3入棧
ISTORE 1 //常量3出棧存入局部變量表下表為1的位置
BIPUSH 6 //常量6入棧
ISTORE 2 //常量6出棧存入局部變量表下表為2的位置
ILOAD 1 //常量3入棧,load指令將局部變量復制一份到操作數棧
ILOAD 2 //常量6入棧
IADD //3,6出棧執行加法,將結果9入棧
ISTORE 3 //9出棧存入局部變量表下標為3的位置
RETURN
MAXSTACK = 2
MAXLOCALS = 4
這里有個新的指令iadd
表示操作數棧棧頂兩個int類型數據出棧,執行加法運算將相加的結果入棧。與之相似,加減乘除等算數指令都一樣,其實完全沒有必要去背這些字節碼指令,弄懂了同一類指令的執行流程就夠了,遇到不記得的指令對應去查找就好了。
正好借這個簡單的例子看一下dex字節碼,這里借助AS java2smali
插件:
# virtual methods
.method public add()V
.registers 4
.prologue
.line 5
const/4 v0, 0x3
.line 6
.local v0, "a":I
const/4 v1, 0x6
.line 7
.local v1, "b":I
add-int v2, v0, v1
.line 8
.local v2, "c":I
return-void
.end method
其實相對來說更接近匯編的dex字節碼反而更好懂,畢竟計算沒有入棧出棧的過程直接add-int v2, v0, v1
,去掉行號使用的指令確實也更少。事實上學會了閱讀class字節碼,dex字節碼肯定也不在話下。本文后面就不再關注dex字節碼了,有興趣的小伙伴可以對照指令集學習dalvik-bytecode。
-
i++
public void add(){
int i = 3;
i = i++;
}
對應字節碼
public add()V
ICONST_3 //將常量3壓入操作數棧
ISTORE 1 //操作數棧出棧,存入局部變量表下標為1的位置
ILOAD 1 //局部變量表下標為1的變量加載到操作數棧
IINC 1 1 //4--->自增指令,IINC后面兩個1,第一個是局部變量表位置,第二個是增加多少。
ISTORE 1 //3--->操作數棧出棧,存入局部變量表下標為1的位置
RETURN
MAXSTACK = 1
MAXLOCALS = 2
很清晰了啊,i++
自增之后會被操作數棧原本的值重新覆蓋,所以還是3。
-
經典八股
public void add() {
int i = 3;
i = i++ + ++i;
}
字節碼,重點關注操作數棧和局部變量表變化,先忽略局部變量表下標為0的位置保存了this。
public add()V
ICONST_3 //棧3,表null
ISTORE 1 //棧null,表3
ILOAD 1 //棧3,表3
IINC 1 1 //棧3,表4
IINC 1 1 //棧3,表5
ILOAD 1 //棧3、5,表5
IADD //棧8,表5
ISTORE 1 //棧null,表8
RETURN
MAXSTACK = 2
MAXLOCALS = 2
}
看到這里,什么i = i++、i = ++i、i = i++ + ++i、i = ++i + i++
還不是手到擒來,站在字節碼的角度看這些迷惑操作可以說是降維打擊了。
-
繼續強化一波
public void add() {
int i = 3;
i = ++i + i++;
}
對應字節碼
public add()V
ICONST_3 //棧3,表null
ISTORE 1 //棧null,表3
IINC 1 1 //棧null,表4
ILOAD 1 //棧4,表4
ILOAD 1 //棧4、4,表4
IINC 1 1 //棧4、4,表5
IADD //棧8,表5
ISTORE 1 //棧null,表8
RETURN
MAXSTACK = 2
MAXLOCALS = 2
}
過程有點不一樣,但結果還是8,到這肯定能看懂局部變量的聲明和一些算數運算了。那有的同學就會問,你這是只是局部變量啊,那全局變量、靜態變量執行流程也一樣嗎?別慌,拉出來溜溜看。
-
全局變量
public class Num {
int i;
public void add() {
i = 3;
i = ++i + i++;
}
}
說實話,下面這段我建議大家自己跟一遍,沒看字節碼之前無法想象這么點代碼居然會生成這么多指令。
public add()V
ALOAD 0 //0是this,局部變量表的this加載到操作數棧
ICONST_3 //常量3入棧
//賦值給全局變量i,其實就等同于this.i = 3
PUTFIELD com/chenxuan/code/Num.i : I
ALOAD 0 //this入棧
ALOAD 0 //this入棧
DUP //復制棧頂this,此時棧3個this
//this出棧,獲取全局變量i入棧
GETFIELD com/chenxuan/code/Num.i : I
ICONST_1 //常量1入棧,此時棧this、this、i、1
IADD //1,i出棧執行加法,加法運算結果4入棧,此時棧this、this、4
DUP_X1 //復制棧頂4,插入下標1,此時棧this、4、this、4
//全局變量賦值,this.i = 4,此時棧this、4
PUTFIELD com/chenxuan/code/Num.i : I
ALOAD 0 //this入棧
DUP //復制this,此時棧this、4、this、this
//全局變量i入棧,此時棧this、4、this、i,需注意i的值已經是4了
GETFIELD com/chenxuan/code/Num.i : I
DUP_X1 //復制棧頂i插入下標1,此時棧this、i、4、this、i
ICONST_1 //常量1入棧,此時棧this、i、4、this、i、1
IADD //加法運算,此時棧this、i、4、this、5
//this.i = 5,此時棧this、i、4,需注意棧內的i還是4
PUTFIELD com/chenxuan/code/Num.i : I
IADD //加法運算,此時棧this、8
//this.i = 8,操作數棧空了
PUTFIELD com/chenxuan/code/Num.i : I
RETURN
//棧的最大深度,對應前面this、i、4、this、i、1
MAXSTACK = 6
MAXLOCALS = 1
GETFIELD、PUTFIELD
很好理解,全局變量取值賦值;DUP、DUP_X1
詳見下圖:
上述全局變量get、put都是通過this進行調用,而this默認保存在局部變量表的第零項。
-
方法調用
public class Num {
public void a() {
b();
}
public void b() {
}
}
看下字節碼,方法內調用原類方法也是通過this,只不過這個this編譯器幫忙生成了。
public a()V
ALOAD 0 //this
INVOKEVIRTUAL com/chenxuan/code/Num.b ()V
RETURN
MAXSTACK = 1
MAXLOCALS = 1
public b()V
RETURN
MAXSTACK = 0
MAXLOCALS = 1
方法調用指令
- invokevirtual
用于調用對象的實例方法,支持多態。 - invokeinterface
用于調用接口方法,運行時找到實現類的接口方法進行調用。 - invokespecial
構造方法,私有方法,父類方法。 - invokestatic
靜態方法或者說是類方法。 - invokedynamic
Java8 lambda使用,在運行時動態解析出調用點限定符所引用的方法,并執行該方法。
this.b()
就是對象實例方法了,所以調用指令為invokevirtual
。invokevirtual、invokeinterface
調用的方法都有可能會被重寫,其實也就是多態,子類方法表首先會繼承父類的方法,子類重寫父類方法時,在方法表中用自己的實現替代父類方法。調用這些方法時,先找實例類,然后查找方法表中對應的方法進行調用。invokespecial、invokestatic
調用的方法都是靜態綁定的,不會被重寫。
android平臺的lambda有一個脫糖的過程,這也是插樁的老生常談了,簡單來說就是將invokedynamic
的實現改為了匿名內部類的方式。
-
對象創建指令
public class Num {
public void a() {
Inner inner = new Inner();
}
static class Inner {
}
}
字節碼
public a()V
//堆上創建對象實例,引用入棧
NEW com/chenxuan/code/Num$Inner
DUP //復制引用到棧頂
//調用構造方法,引用出棧
INVOKESPECIAL com/chenxuan/code/Num$Inner.<init> ()V
ASTORE 1 //引用出棧,存入局部變量表
RETURN
MAXSTACK = 2
MAXLOCALS = 2
對象創建的過程:先在堆上創建實例,對象引用push到操作數棧;dup復制一份引用,用來調用對象的構造方法;最后將剩余的引用存入局部變量,注意位置,0是當前對象this。
-
類型檢查指令
public void a(View view) {
if (view instanceof TextView) {
((TextView) view).setText("TextView");
}
}
字節碼
public a(Landroid/view/View;)V
ALOAD 1
INSTANCEOF android/widget/TextView
IFEQ L0
ALOAD 1
CHECKCAST android/widget/TextView
LDC "TextView"
INVOKEVIRTUAL android/widget/TextView.setText (Ljava/lang/CharSequence;)V
L0
RETURN
MAXSTACK = 2
MAXLOCALS = 2
對應指令就是INSTANCEOF、CHECKCAST
,比較好理解,此處條件指令IFEQ
不滿足條件跳轉L0,方法return返回。思考一下kotlin的智能轉換,看一下kotlin的字節碼,為啥不需要在代碼中顯示as向下轉型。
-
自動轉型
fun text(view: View) {
if (view is TextView) {
view.text = "TextView"
view.textSize = 16f
}
}
字節碼,很容易想到編譯器幫忙加了CHECKCAST
。
public final text(Landroid/view/View;)V
// annotable parameter count: 1 (invisible)
@Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0
ALOAD 1
LDC "view"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V
ALOAD 1
INSTANCEOF android/widget/TextView
IFEQ L0
ALOAD 1
CHECKCAST android/widget/TextView
LDC "TextView"
CHECKCAST java/lang/CharSequence
INVOKEVIRTUAL android/widget/TextView.setText (Ljava/lang/CharSequence;)V
ALOAD 1
CHECKCAST android/widget/TextView
LDC 16.0
INVOKEVIRTUAL android/widget/TextView.setTextSize (F)V
L0
RETURN
MAXSTACK = 2
MAXLOCALS = 2
有個比較神奇的ab值交換,這個說實話,反編譯成Java也看不明白。
-
ab交換
public fun ab() {
var a = 3;
var b = 4;
a = b.also {
b = a
}
}
字節碼
public final ab()V
ICONST_0
ISTORE 1
ICONST_3
ISTORE 1
ICONST_0
ISTORE 2
ICONST_4
ISTORE 2
ILOAD 2
ISTORE 3
ILOAD 3
ISTORE 4
ICONST_0
ISTORE 5
ILOAD 1
ISTORE 2
NOP
ILOAD 3
ISTORE 1
RETURN
MAXSTACK = 1
MAXLOCALS = 6
}
MAXLOCALS為6,里面一堆操作,最終局部變量表1的值變成了4,2的值變成了3完成交換。
總結
說實話,八股歸八股,這些i++的例子拋開惡心不談,確實也讓我有所收獲,只能說記憶深刻。卷到這里,再回過頭去看kotlin的編譯產物、ASM插樁等,應該也是得心應手了。當然,最重要的是,技術對線不再掛機,大佬們在高談闊論的時候,我也可以插個嘴嘴=。=
那實用主義的同學可能就會說了,你卷這些有個雞兒用,對咱們的項目有一點幫助嗎?別急,看下booster、ByteX這么多ASM處理對項目的優化是實實在在的,總歸要知其然知其所以然吧,總不能引入一下后續修改都無從下手吧。那booster、ByteX沒有的一些功能,項目中的一些痛點自己下來也能擼了,還不是美滋滋。
相關鏈接:
jit-compiler
dalvik-bytecode
JVM-bytecode
dex-format
class-format