前言
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字节码再转换了一层,更适用于资源有限的移动平台机器。
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_、lconst_
、fconst_ 、dconst_ - 局部变量压栈指令
xload、xload_
(其中x为i、l、f、d、a,而n为0~3) - 出栈装入局部变量表指令
将一个数值从操作数栈存储到局部变量表:xstore、xstore_
,局部变量表可以看做一个数组,对于非静态方法索引为0的位置默认为this指针。其中x为i、l、f、d、a,而n为0~3);xastore(其中x为i、l、f、d、a、b、c、s)
说起来很抽象,下面看例子
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。
继续看1
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。
经典八股2
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++
还不是手到擒来,站在字节码的角度看这些迷惑操作可以说是降维打击了。
继续强化一波,经典八股3
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,到这肯定能看懂局部变量的声明和一些算数运算了。那有的同学就会问,你这是只是局部变量啊,那全局变量、静态变量执行流程也一样吗?别慌,拉出来溜溜看。
全局变量4
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默认保存在局部变量表的第零项。
方法调用5
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使用,在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
5中this.b()
就是对象实例方法了,所以调用指令为invokevirtual
。invokevirtual、invokeinterface
调用的方法都有可能会被重写,其实也就是多态,子类方法表首先会继承父类的方法,子类重写父类方法时,在方法表中用自己的实现替代父类方法。调用这些方法时,先找实例类,然后查找方法表中对应的方法进行调用。invokespecial、invokestatic
调用的方法都是静态绑定的,不会被重写。
android平台的lambda有一个脱糖的过程,这也是插桩的老生常谈了,简单来说就是将invokedynamic
的实现改为了匿名内部类的方式。
对象创建指令6
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. ()V
ASTORE 1 //引用出栈,存入局部变量表
RETURN
MAXSTACK = 2
MAXLOCALS = 2
对象创建的过程:先在堆上创建实例,对象引用push到操作数栈;dup复制一份引用,用来调用对象的构造方法;最后将剩余的引用存入局部变量,注意位置,0是当前对象this。
类型检查指令7
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向下转型。
8
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也看不明白。
9
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完成交换。多出的三个局部变量想来是lambda内部ab的拷贝,还有一个就是lambda内部it指向的变量。
总结
说实话,八股归八股,这些i++的例子抛开恶心不谈,确实也让我有所收获,只能说记忆深刻。卷到这里,再回过头去看kotlin的编译产物、ASM插桩等,应该也是得心应手了。当然,最重要的是,技术对线不再挂机,大佬们在高谈阔论的时候,我也可以插个嘴嘴=。=
那实用主义的同学可能就会说了,你卷这些有个鸡儿用,对咱们的项目有一点帮助吗?别急,看下booster、ByteX这么多ASM处理对项目的优化是实实在在的,总归要知其然知其所以然吧,总不能引入一下后续修改都无从下手吧。那booster、ByteX没有的一些功能,项目中的一些痛点自己下来也能撸了,还不是美滋滋。
相关链接:
jit-compiler
dalvik-bytecode
JVM-bytecode
dex-format
class-format