JVM字节码

前言

Android开发讲道理更应该卷的是dex字节码,但实际上做应用开发时,插桩流程往往在class2dex的过程中,一些插桩框架最终操作的还是class字节码。Java世界JVM最终执行字节码指令,将class字节码解释成机器码执行;Android世界ART最终执行dex,将dex字节码解释成机器码执行。当然ART本身也做了好几次变更,现在基本上是解释、JIT、AOT混合的方式执行,见下图:


Android Runtime

简单的总结就是,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的内存结构,偷张图:


JDK8

当类被首次加载时,会在堆上创建类的Class对象其实也就是反射会用到的Class对象,同时Class文件中的信息被加载到JVM方法区中,字节码指令也会装配到方法区中,为方法的运行提供支持。

虚拟机栈是描述方法的内存模型,属于线程私有。虚拟机栈中方法调用时会创建栈帧,方法return返回时相应的会将栈帧出栈。栈帧可视为单个方法的内存模型,其又包括局部变量表、操作数栈、动态连接、方法出口。

  • 局部变量表用于储存方法参数和局部变量。
  • 操作数栈保存求值的中间结果、调用别的方法参数。
  • 动态连接指的是栈帧当前方法指向运行时常量池的一个引用,说白了也就是找到对应方法区的方法然后根据这个引用读取字节码。
  • 方法出口记录被调用的方法退出后回到上层方法的位置。

下面通过例子看一下字节码指令执行流程。

加载存储指令

根据我自己的学习方法,重点先关注局部变量表、操作数栈和三个字节码指令:

  1. 常量入栈指令
    将一个常量加载到操作数栈 :bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_
  2. 局部变量压栈指令
    xload、xload_(其中x为i、l、f、d、a,而n为0~3)
  3. 出栈装入局部变量表指令
    将一个数值从操作数栈存储到局部变量表:xstore、xstore_其中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。

继续看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详见下图:

dup & pop

上述全局变量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
方法调用指令
  1. invokevirtual
    用于调用对象的实例方法,支持多态。
  2. invokeinterface
    用于调用接口方法,运行时找到实现类的接口方法进行调用。
  3. invokespecial
    构造方法,私有方法,父类方法。
  4. invokestatic
    静态方法或者说是类方法。
  5. invokedynamic
    Java8 lambda使用,在运行时动态解析出调用点限定符所引用的方法,并执行该方法。

5中this.b()就是对象实例方法了,所以调用指令为invokevirtualinvokevirtual、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

你可能感兴趣的:(JVM字节码)