Android Java字节码编译的一些知识点

407 阅读13分钟

一、关于 Java 编译

java 从代码编写到程序运行需要两次编译:第一次是 Javac 编译,第二次是 JIT + 解释器编译

  • Javac 是前端编译:负责字节码优化、常量传播、公共表达式合并等
  • AOT + JIT + 解释器是后端编译:负责字节码优化,存储空间优化,内联优化(inline)、CHA、逃逸分析、分支预测、锁粗化、锁消除等

前端编译优化经常遇到问题是什么?

很多面试题都会问题类、构造方法、静态代码块、非静态代码块执行顺序,以及 final 和常量,synchornized、javac 优化问题,这类问题实际上我们开发时也需要注意,毕竟出问题也一般不好定位。

解决这类问题的办法,实际上需要通过查看字节码完成,但字节码本质上是二进制,因此我们可以使用伪字节码工具,尽可能查看清楚的查看 class 文件,这里我们使用 ASM ByteCode Outline 、当然也可以使用 Java2Smali,实际上 smali 更加接近真实字节码,但该插件有个缺点会生成 smali 文件在 java 类目录下,因此需要你手动删除。

二、字节码查看工具

2.1 ASM ByteCode Outline

ASM ByteCode Outline 是常用的字节码可视化工具,能够很明确的找到相关字节码最终的生成调用逻辑。

 public static String getA(){
        String  str = "id_";
        str += "12345";
        str += "_end";
        return  str;
    }

    public static String getB(){
        StringBuilder sb = new StringBuilder();
        sb.append("id_")
                .append("123456")
                .append("_end");
        return  sb.toString();
    }

下面是利用ASM ByteCode Outline 反编译后的代码,从下面的代码中我们能看到很多bytecode指令,当然,由于java的编译是在java环境下执行的,本身java是基于栈内存模型的,因此下面的调用是“操作数栈”进行局部变量的存储和读取,当然你可能认为ASM ByteCode Outline不支持寄存器模型的反编译,其实这个说法还是有问题的,因为class文件转换为dex后才会将“栈模型”,显然这个转换的前提条件就是不满足的。

@groovyx.ast.bytecode.Bytecode
  public static String getA() {
    ldc "id_"  //常量
    astore 0 //存储到栈的位置 - java方法栈帧
    _new 'java/lang/StringBuilder'
    dup
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    aload 0
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ldc "12345"
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    astore 0
    _new 'java/lang/StringBuilder'
    dup
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    aload 0
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ldc "_end"
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    astore 0
    aload 0
    areturn
  }

  @groovyx.ast.bytecode.Bytecode
  public static String getB() {
    _new 'java/lang/StringBuilder'
    dup
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    astore 0
    aload 0
    ldc "id_"
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ldc "123456"
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ldc "_end"
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    pop
    aload 0
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    areturn
  }

注意: 从上面对比可知,str 通过 "+" 会使用 StringBuilder 完成该操作,每个 “+” 都会创建 StringBuilder 对象

当然,我们也可以看到很多INVOKEXXX的指令,其实这些指令是有区别的:

  • INVOKESPECIAL: 负责调用构造方法和私有方法,因为构造方法和私有访问的访问权限和稳定性最强,在smail中,这个指令会变成invoke-direct。 正因为INVOKESPECIAL指令的稳定性很高,这就为后端编译如内联、标量化等提供了基础。注意,此指令和final修饰符没有任何关系。
  • INVOKEVIRTUAL: 负责调用public、protected、默认方法,做此标记是因为这类方法可以被继承和重写,因此他的稳定性比较差,但是后端编译也不是毫无办法,后端编译时会进行CHA(类的层次分析),在确定稳定性后会进行优化,如果每个Activity页面仅有一个与之对应的子类,那么子类的方法后续可能也会被内联。同时,如果类的继承层次越深,那么优化的复杂度也会提升,因此实现继承时,最高3-4层即可。
  • INVOKESTATIC: 负责调用静态方法,因为静态方法本身很稳定,因此也会很容易被优化。
  • INVOKEINTERFACE: 顾名思义,调用接口的方法,由于存在多态情况,这种调用的优化难度也比较高。
  • INVOKEDYNAMIC: Java7 新增的调用方式,其本质是配合MethodHandle调用,用于模拟方法调用提高动态性,,然而要注意的是,MethodHandle在Android平台的调用效率似乎不及反射,但在jvm上却比反射性能高。  

三、javac常量问题

javac 常量问题属于编译时常量池问题,java 在编译时就会创建常量池,在 Android 7.0 之后,字符串常量池移到堆空间,字符串常量可以在运行时添加,因此 JIT 会做相应的处理。

public class AppTest {

    private final int C = 2;
    private int A = 1;
    private final String D = "D";
    private final String B = "B";
    private String B2 = "B";
    private String F = "F";
    private final String E = "E";
    private static final String AE = "AB"+"CDE";
    private static AppTest appTest = new AppTest();
    
    static {
        FGH = "GHF";
    }
    public static  String FGH = "FGH";

    static {
        FGH = "GFH";
    }
    {
        FGH = "HGF";
        mA = 2;
    }
    private int mA = 3;
    {
        mA = 1;
    }
    
    AppTest(){
    
    }

    public void helloWorld(){
        int a = A + 3;
        int b = 1 + 3;
        int c = C + 3;
        int d = b + 4;

        String AB_1 = "AB";
        String AB_2 = "A" + "B";
        String AB_3 = "A" + B;
        String AB_4 = "A" + B2;

        String aA  = "A";
        String AB_5 = aA +"B";

        String ABD = new String("A"+"B" +D);

    }

    public static void main(String[] args) {
        AppTest appTest = new AppTest();

    }

}

由于我们是面向Android 平台开发,下面我们通过smail来观察其实相对更加准确

在下面的代码中,方法内部".registers"的指令,和上面的bytecode的操作书栈有很多不同的地方,具体来说.registers并不存在单独的栈帧,而且空间相比栈帧要多一些,因此不会出现频繁出栈和入栈的情况,相比而言性能要好,这也是面试过程中经常要文档的,为什么DVM比JVM性能好,原因之一就是这个。

.class public Lcntest/AppTest;
.super Ljava/lang/Object;
.source "AppTest.java"


# static fields
.field private static final AE:Ljava/lang/String; = "ABCDE"

.field public static FGH:Ljava/lang/String;

.field private static appTest:Lcntest/AppTest;


# instance fields
.field private A:I

.field private final B:Ljava/lang/String;

.field private B2:Ljava/lang/String;

.field private final C:I

.field private final D:Ljava/lang/String;

.field private final E:Ljava/lang/String;

.field private F:Ljava/lang/String;

.field private mA:I


# direct methods
.method static constructor <clinit>()V   // 静态变量的初始化方法
    .registers 1

    .prologue
    .line 17
    new-instance v0, Lcntest/AppTest;

    invoke-direct {v0}, Lcntest/AppTest;-><init>()V

    sput-object v0, Lcntest/AppTest;->appTest:Lcntest/AppTest;

    .line 20
    const-string v0, "GHF"

    sput-object v0, Lcntest/AppTest;->FGH:Ljava/lang/String;

    .line 22
    const-string v0, "FGH"

    sput-object v0, Lcntest/AppTest;->FGH:Ljava/lang/String;

    .line 25
    const-string v0, "GFH"

    sput-object v0, Lcntest/AppTest;->FGH:Ljava/lang/String;

    .line 26
    return-void
.end method

.method public constructor <init>()V
    .registers 4

    .prologue
    const/4 v2, 0x2

    const/4 v1, 0x1

    .line 3
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V   //类的构造函数或者初始化方法

    .line 5
    iput v2, p0, Lcntest/AppTest;->C:I

    .line 6
    iput v1, p0, Lcntest/AppTest;->A:I

    .line 8
    const-string v0, "D"

    iput-object v0, p0, Lcntest/AppTest;->D:Ljava/lang/String;

    .line 10
    const-string v0, "B"

    iput-object v0, p0, Lcntest/AppTest;->B:Ljava/lang/String;

    .line 11
    const-string v0, "B"

    iput-object v0, p0, Lcntest/AppTest;->B2:Ljava/lang/String;

    .line 13
    const-string v0, "F"

    iput-object v0, p0, Lcntest/AppTest;->F:Ljava/lang/String;

    .line 14
    const-string v0, "E"

    iput-object v0, p0, Lcntest/AppTest;->E:Ljava/lang/String;

    .line 28
    const-string v0, "HGF"

    sput-object v0, Lcntest/AppTest;->FGH:Ljava/lang/String;

    .line 29
    iput v2, p0, Lcntest/AppTest;->mA:I

    .line 31
    const/4 v0, 0x3

    iput v0, p0, Lcntest/AppTest;->mA:I

    .line 33
    iput v1, p0, Lcntest/AppTest;->mA:I

    .line 34
    return-void
.end method

.method public static main([Ljava/lang/String;)V
    .registers 2
    .param p0, "args"    # [Ljava/lang/String;

    .prologue
    .line 55
    new-instance v0, Lcntest/AppTest;

    invoke-direct {v0}, Lcntest/AppTest;-><init>()V

    .line 57
    .local v0, "appTest":Lcntest/AppTest;
    return-void
.end method


# virtual methods
.method public helloWorld()V
    .registers 14

    .prologue
    .line 37
    iget v11, p0, Lcntest/AppTest;->A:I

    add-int/lit8 v6, v11, 0x3

    .line 38
    .local v6, "a":I
    const/4 v8, 0x4

    .line 39
    .local v8, "b":I
    const/4 v9, 0x5

    .line 40
    .local v9, "c":I
    add-int/lit8 v10, v8, 0x4

    .line 42
    .local v10, "d":I
    const-string v1, "AB"

    .line 43
    .local v1, "AB_1":Ljava/lang/String;
    const-string v2, "AB"

    .line 44
    .local v2, "AB_2":Ljava/lang/String;
    const-string v3, "AB"

    .line 45
    .local v3, "AB_3":Ljava/lang/String;
    new-instance v11, Ljava/lang/StringBuilder;

    invoke-direct {v11}, Ljava/lang/StringBuilder;-><init>()V

    const-string v12, "A"

    invoke-virtual {v11, v12}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    move-result-object v11

    iget-object v12, p0, Lcntest/AppTest;->B2:Ljava/lang/String;

    invoke-virtual {v11, v12}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    move-result-object v11

    invoke-virtual {v11}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v4

    .line 47
    .local v4, "AB_4":Ljava/lang/String;
    const-string v7, "A"

    .line 48
    .local v7, "aA":Ljava/lang/String;
    new-instance v11, Ljava/lang/StringBuilder;

    invoke-direct {v11}, Ljava/lang/StringBuilder;-><init>()V

    invoke-virtual {v11, v7}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    move-result-object v11

    const-string v12, "B"

    invoke-virtual {v11, v12}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    move-result-object v11

    invoke-virtual {v11}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v5

    .line 50
    .local v5, "AB_5":Ljava/lang/String;
    new-instance v0, Ljava/lang/String;

    const-string v11, "ABD"

    invoke-direct {v0, v11}, Ljava/lang/String;-><init>(Ljava/lang/String;)V

    .line 52
    .local v0, "ABD":Ljava/lang/String;
    return-void
.end method

总体上smail的可读性要稍微高一些,另外就是两者也有很多相似的部分。不过,ByteCode和Smali仍然存在很多缺陷,比如模数、常量池并不会直观的展示出来,这个时候可能需要010Editor等工具才能完整的展示。

从上面的伪字节码我们可以还原 javac 编译之后的代码

public class AppTest {

    private static final String AE = "ABCDE"; //javac 优化,指向常量
    private static AppTest appTest;
    public static  String FGH;

    private final int C;
    private int A;
    private final String D;
    private final String B;
    private String B2;
    private String F;
    private final String E;
    private int mA;

    public static class_init(){  
     //类初始化函数,由ClassLoader去初始化
        appTest = new AppTest();
        FGH = "GHF"; //指向常量
        FGH = "FGH"; //指向常量
        FGH = "GFH"; //指向常量
    }

    public  ppTest(){
        super();
        //下面语句会在super之后执行
        {
            C = 2; //指向常量
            A = 1; //指向常量
            D = "D";//指向常量
            B = "B";//指向常量
            B2 = "B";//指向常量
            F = "F";//指向常量
            E = "E";//指向常量
            FGH = "HGF";//指向常量
            mA = 2;//指向常量
            mA = 3;//指向常量
            mA = 1;//指向常量
        }

        //后面是构造方法其他代码,因为类外赋值的的变量在编译后会优先初始化
    }

    public void helloWorld(){
        int a = A + 3; //a最终指向栈内存
        int b = 4; //javac optimized 自动计算
        int c = 5; //javac optimized 自动计算
        int d = b + 4; //虽然指向常量,但javac并没有优化,原因待解,这里d最终指向栈内存,因为d属于基本类型

        String AB_1 = "AB"; //javac optimized  AB是常量,常量吃不存在,添加到常量池
        String AB_2 = "AB"; //javac optimized  javac 自动合并,合并后从常量池查找,复制
        String AB_3 = "A" + B; //javac optimized  javac 自动合并,合并后从常量池查找,复制
        String AB_4 = "A" + B2;  //javac 不会处理,AB_4 最终指向堆空间

        String aA  = "A"; //生成常量A,添加到常量池,并赋值给aA
        String AB_5 = aA +"B"; //当前aA是局部变量引用常量,明显可以可以继续优化,但并没有,待解,此处AB_5最终指向堆空间

        String ABD = new String("A"+"B" +D); //javac 自动合并 ABD,然后new String,导致ABD最终指向堆空间

    }

    public static void main(String[] args) {
        AppTest appTest = new AppTest();

    }

}

从上面的代码我们可知,构造方法外面的变量最终还是在构造方法中初始化,且在super调用之后插入的,这就意味着在源码中,构造方法中的变量执行是晚于构造方法外的变量的。这样做的好处是避免产生空指针。

三、运行时常量问题

java 常量池中,存在运行时常量池 主要分为:string.intern () , 字节小于 1Byte 的包装类型。

        Integer ia = 200;
        Integer ib = 200;

        System.out.println(ia==ib);  //false

        Integer ic = 100;
        Integer id = 100;

        System.out.println(ic==id); //true


        final String A = "A";
        String ABC = A + "BC"  ;
        System.out.println(ABC=="ABC");

        String c = new String("c") +new String("d");
        System.out.println(c == c.intern()); //true

        String s = new String("a") + new String("b");
        System.out.println(s == s.intern()); //t

编译后的代码,从下面的代码中我们看到,在1Byte范围内的变量会被自动转换,这也是面试过程中最长问到的问题

// class version 51.0 (51)
// access flags 0x21
public class io/rmiri/keepalive/TestJavac {


  @groovyx.ast.bytecode.Bytecode
  public void <init>() {
    aload 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    return
  }

  @groovyx.ast.bytecode.Bytecode
  public static void main(String[] a) {
    sipush 200
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    astore 1
    sipush 200
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    astore 2
    getstatic 'java/lang/System.out','Ljava/io/PrintStream;'
    aload 1
    aload 2
    if_acmpne l0
    iconst_1
    _goto l1
   l0
    iconst_0
   l1
    INVOKEVIRTUAL java/io/PrintStream.println (Z)V
    bipush 100
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    astore 3
    bipush 100
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    astore 4
    getstatic 'java/lang/System.out','Ljava/io/PrintStream;'
    aload 3
    aload 4
    if_acmpne l2
    iconst_1
    _goto l3
   l2
    iconst_0
   l3
    INVOKEVIRTUAL java/io/PrintStream.println (Z)V
    ldc "A"
    astore 5
    ldc "ABC"
    astore 6
    getstatic 'java/lang/System.out','Ljava/io/PrintStream;'
    aload 6
    ldc "ABC"
    if_acmpne l4
    iconst_1
    _goto l5
   l4
    iconst_0
   l5
    INVOKEVIRTUAL java/io/PrintStream.println (Z)V
    _new 'java/lang/StringBuilder'
    dup
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    _new 'java/lang/String'
    dup
    ldc "1"
    INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    _new 'java/lang/String'
    dup
    ldc "2"
    INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    astore 7
    getstatic 'java/lang/System.out','Ljava/io/PrintStream;'
    aload 7
    aload 7
    INVOKEVIRTUAL java/lang/String.intern ()Ljava/lang/String;
    if_acmpne l6
    iconst_1
    _goto l7
   l6
    iconst_0
   l7
    INVOKEVIRTUAL java/io/PrintStream.println (Z)V
    _new 'java/lang/StringBuilder'
    dup
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    _new 'java/lang/String'
    dup
    ldc "a"
    INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    _new 'java/lang/String'
    dup
    ldc "b"
    INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    astore 8
    getstatic 'java/lang/System.out','Ljava/io/PrintStream;'
    aload 8
    aload 8
    INVOKEVIRTUAL java/lang/String.intern ()Ljava/lang/String;
    if_acmpne l8
    iconst_1
    _goto l9
   l8
    iconst_0
   l9
    INVOKEVIRTUAL java/io/PrintStream.println (Z)V
    return
  }
}

其中对于 intern 用法这里必须强调一下

java 7 中 string 常量在堆空间,因此如果是如下合成的变量本身是存在堆空间中,但是 intern 调用之后,会将 “cd” 引用添加至常量池中,因此,就有可能出现局部变量和常量相等的情况

String c = new String( "c" ) +new String( "d" );

包装类型,我们看到字节码中 Interge.valueOf (),该方法对 -128 到 127 之间的数据有特殊处理

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

四、指令消除问题

这些问题很多,比如下面的代码会造成死循环,但是原因是是什么呢?

int i = 10;
while(i > 0){
  i = i--;
}

说实话,这种代码其实对于编译器而言,其实也是有一定的难度。编译器本身要避免二义性,对于不同的语言或许结果也有差异。不过,对于这种钻牛角尖的问题,最好也通过字节码角度来查看,当然也要平时积累。

这里我们就不贴代码了。

当然,这个问题还算简单的,之前有大佬的博客里是当前类中创建当前类的对象,后续补充到这里。

四、总结

本篇就到这里,这里我们简单了解下编译,其实很多问题都可以从字节码中找到答案。对于编译器,其本身也会存在一些规则,一些问题靠推理其实很难解释,正如大千世界的app,没有一个是万能的。

注意:本篇后续还会修改,不要转载。