从Dalvik字节码角度优化安卓编码

安卓开发中,Java/Kotlin等高级语言被编译成.class字节码,之后通过dx/d8r8等工具编译成dex文件(Dalvik字节码),打包到APK中。安卓通过ART或者DalvikVM加载运行Dalvik字节码。因此,对于安卓编码,Dalvik字节码层面相比.class字节码层面更有指导意义。

静态属性与this指针

静态属性(用static关键字修饰,包括字段以及方法)是类的属性,而非静态属性则是实例的属性。由于非静态属性往往跟实例绑定,静态属性的访问不存在实例,需要的参数更少。

DalvikVM是基于寄存器的指令集,每个方法内的pn寄存器都是参数寄存器,而vn都是本地寄存器,非静态方法中的p0表示this指针。

如下代码是读取类a.b.C的非静态字段bool和静态字段sbool,可以看到在读取非静态字段时会从p0(this)进行引用,而读取sbool则不需要。

iget-boolean v0, p0 La/b/C;->bool:Z
sget-boolean v1 La/b/C->sbool:Z

另外,对于代码调用,静态方法使用invoke-static,不需要传入实例本身;而非静态方法使用invoke-virtual调用,即便是从p0(this)调用自己的方法,也需要传入实例。

.method call()V
    .registers 2
    invoke-virtual {p0}, La/b/C;->getBool()Z
    move-result-object v0
    invoke-static La/b/C;->sgetBool()Z
    move-result-object v0
    return-void
.end

.method getBool()Z
    .registers 2
    sget-boolean v0, La/b/C;->sbool:Z
    return v0
.end

.method static sgetBool()Z
    .registers 1
    sget-boolean v0, La/b/C;->sbool:Z
    return v0
.end

可以看到,在调用getBoolsgetBool时,由于方法是否静态的差别,其调用参数也有差别:虽然二者的字节码指令一致,仅使用寄存器v0,但非静态方法调用时仍需传参p0。

另外,考虑以下调用:

a.b.C obj = null;

obj.sgetBool();
obj.getBool(); // NullPointerException

其中obj为一个空值,其在调用非静态方法getBool时必然抛出NullPointerException异常,但却可以安全调用静态方法sgetBool,因为编译器编译后会直接换作obj的类进行invoke-static静态调用,与实例本身无关。

因此,在不考虑子类重写以及使用this时,尽量用static修饰方法

字段与局部变量

前面说到Dalvik字节码是基于寄存器的指令集,经过ART的AOT/JIT后也更方便生成机器码,这与基于堆栈的.class字节码不同。局部变量在生成Dalvik字节码时往往都用寄存器表示,因此,在安卓开发中使用局部变量时直接当作寄存器即可,不必像堆栈型JVM那样考虑堆栈操作的开销。

考虑以下代码:

int a;

void compute1() {
    a = a * a - 1;
}

void compute2() {
    int i = a;
    a = i * i - 1;
}

对应的Dalvik字节码为:

.field a:I

.method compute1()V
    .locals 2
    iget v0, p0, LMain;->a:I
    iget v1, p0, LMain;->a:I
    mul-int/2addr v0, v1
    add-int/lit8 v0, v0, -0x1
    iput v0, p0, LMain;->a:I
    return-void
.end method

.method compute2()V
    .locals 1
    iget v0, p0, LMain;->a:I
    mul-int/2addr v0, v0
    add-int/lit8 v0, v0, -0x1
    iput v0, p0, LMain;->a:I
    return-void
.end method

对于compute1,读了两次字段a、写了一次;对于compute2,读了一次,写了一次。因此虽然后者的Java代码更多,但是生成的Dalvik字节码要少一句读a的操作,而局部变量i则用寄存器v0表示。另外,这种情况下两次对a的访问操作也不能通过编译优化为一次,因为Java多线程情况下,如果由于线程调度使两次获取a的值不一致,如果编译优化则会使其一致,影响了代码逻辑。

很多人写代码时,针对全局变量或者字段进行访问时,为图代码规整、简洁,大量重复访问同一字段,既使Dalvik字节码变得冗余,又增加了多线程不一致的隐患。建议在需要多次访问一个字段且保持一致性时,先赋值给一个局部变量,后续的访问仅针对这个局部变量进行操作;或者可以用final修饰字段(因为final修饰时多次访问可以被编译优化为一次)。

final属性与编译优化

常量替换与函数内联是一种常见的编译优化手段,用于将一些常量的值或短函数的代码直接嵌入到引用处,减少了寻址、调用栈变动的开销。考虑到JVM的继承与重写特性,可被子类修改的字段以及方法(无private且无final修饰)往往不会被常量替换与方法内联。因此,如果需要对这类字段或方法进行优化,尽量加上final修饰,如下:

boolean testa(int n) {
    return n != 0;
}
public final boolean testb(int n) {
    return n != 0;
}
private boolean testc(int n) {
    return n != 0;
}
void test() {
    int n = (int)(Math.random() * 100);
    System.out.println(testa(n));
    System.out.println(testb(n)); // System.out.println(n != 0);
    System.out.println(testc(n)); // System.out.println(n != 0);
}

如果编译器支持内联优化,那么:

  • testbtestc均会被内联优化为n != 0,而testa不会,因为privatefinal都保证了这些方法不能被子类重写,在编译阶段就能确认它们具体调用的代码;
  • testa可以被子类重写,因此此处调用testa的具体代码是什么还要看子类是否会重写,因此不能确认具体调用的代码,无法进行内联优化。

同理,仅有被final修饰的全局字面值常量才能进行常量替换。当然也有一些局部变量也可以被替换,但这应归于一种更广义的编译优化:预计算。

内部类与桥接方法

Dalvik字节码中的成员类是如何访问主类的私有属性的?考虑以下代码:

class Main{
    private int pA;
    private void pM() {}
    class Member{
        Member() {
            pA = 0;
            pM();
        }
    }
}

如果是在Hotspot中,这不成问题,private属性对成员类自然开放,可直接由invoke族、getfieldputfield等指令进行操作。但是对于Dalvik字节码中,成员类不能直接访问主类的private方法,需要由编译器生成一些桥接方法(Bridge Method)实现。

Hotspot本身支持桥接方法,主要用于子类对父类重写一些方法并改变一些签名类型时使用,比如父类方法签名()Ljava/lang/CharSequence;,而子类重写时改为()Ljava/lang/String;,此时编译器会给子类生成一个桥接方法来实现签名兼容。而在生成Dalvik字节码,通过对主类生成一些访问权限更宽的桥接方法来实现成员类对主类私有方法、字段的访问。上述代码生成的部分Dalvik字节码如下:

# ======== 成员类 ========
.class LMain$Member;

.method constructor <init>(LMain;)V
    .registers 3
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V        # super();
    const/4 v0, 0x0
    invoke-static {p1, v0}, LMain;->-$$Nest$fputpA(LMain;I)V # Main.this.pA = 0;
    invoke-static {p1}, LMain;->-$$Nest$mpM(LMain;)V         # Main.this.pM();
    return-void
.end method

# ======== 主类 ========
.class LMain;

.field private pA:I

# 桥接方法:修改pA
.method static bridge synthetic -$$Nest$fputpA(LMain;I)V
    .registers 2
    iput p1, p0, LMain;->pA:I
    return-void
.end method

# 桥接方法:调用pM
.method static bridge synthetic -$$Nest$mpM(LMain;)V
    .registers 1
    invoke-direct {p0}, LMain;->pM()V
    return-void
.end method

.method private pM()V
    .registers 1
    return-void
.end method

可以看到,编译器为主类Main生成了一个桥接方法-$$Nest$fputpA用于修改pA,以及一个桥接方法-$$Nest$mpM用于调用pM,这些桥接方法均为包内可见的静态方法,因此成员类Main.Member可以直接访问之。

因此,当成员类通过桥接方法访问主类时,必然造成调用栈变长。如果主类的某个私有字段已经创造了一些getter/setter方法,但是Java代码中成员类仍显式操作主类字段时,生成的桥接方法就显得多余重复。为减少不必要的桥接方法,尽量不显式访问主类的私有字段与方法。除了用getter/setter间接操作主类字段,也可以protected代替private避免桥接方法生成,因为在DalvikVM中,protected对子类与成员类均可见,可以实现一种较弱的私有化

匿名类与Lambda

匿名类可以实现闭包,编译期会生成一个.class文件;而Lambda编译期不会生成.class文件,而是直接用invokedynamic指令生成一个Lambda实例。但是DalvikVM不支持invokedynamic指令,在生成dex时,Lambda仍会转为匿名类。因此,在安卓上,Lambda只能算一种语法糖

在Lambda作为语法糖的情况下,此处仅讨论匿名类。考虑闭包,匿名类属于非静态成员类,在编译器生成的构造函数中会传入主类的this引用,因此才可以在匿名类中通过Main.this实现对主类字段的访问(主类this前缀可省,也是一种语法糖);另外,为了实现对本地变量的闭包,构造函数中也会自动生成引用的局部变量作为形参。如果不希望再匿名类的形参中出现this,建议直接通过有名的静态成员类或者封装一个静态方法来创建匿名类。

大多数情况下,我们只对接口类创建匿名类,而接口是可以被多实现的,因此可以通过单个类来实现多个接口来减少.class的数量,甚至直接用主类实现。对比以下代码:

interface IA { public void a(); }
interface IB { public void b(); }
class A implements IA, IB {
    void callThis() {
        callbackA(this);
        callbackB(this);
    }
    void callLambda() {
        callbackA(this::a); // Lambda实际转为匿名类,下同
        callbackB(this::b);
    }
    void callbackA(IA ia) {...}
    void callbackB(IB ib) {...}
    public void a() {...}
    public void b() {...}
}

其中callThiscallLambda生成的Dalvik字节码如下:

.method callThis()V
    .registers 1
    invoke-virtual {p0, p0}, LA;->callbackA(LIA;)V                      # this.callbackA(this);
    invoke-virtual {p0, p0}, LA;->callbackB(LIB;)V                      # this.callbackB(this);
    return-void
.end method

.method callLambda()V
    .registers 2
    new-instance v0, LA$$ExternalSyntheticLambda0;
    invoke-direct {v0, p0}, LA$$ExternalSyntheticLambda0;-><init>(LA;)V # v0 = new A$$ExternalSyntheticLambda0(this);
    invoke-virtual {p0, v0}, LA;->callbackA(LIA;)V                      # this.callbackA(v0);
    new-instance v0, LA$$ExternalSyntheticLambda1;
    invoke-direct {v0, p0}, LA$$ExternalSyntheticLambda1;-><init>(LA;)V # v0 = new A$$ExternalSyntheticLambda1(this);
    invoke-virtual {p0, v0}, LA;->callbackB(LIB;)V                      # this.callbackB(v0);
    return-void
.end method

可以看到callThis由于直接传入this对象作为callback方法的参数,因此并没有出现匿名类的情况;反观callLambda,由于使用Lambda进行传参,实际生成了A$$ExternalSyntheticLambda0A$$ExternalSyntheticLambda1两个匿名类,并分别创建了两个实例。相较而言,通过单个类(甚至是主类)实现多个接口,有利于减少匿名接口类的数量以及实例的数量,对减少字节码规模、减少堆内存使用、避免内存泄漏(某些回调接口实例)都是有帮助的

当然,通过单个类/主类实现多个接口也是有缺点的:

  • 使主类携带过多接口信息,影响封装性;
  • 主类中用于闭包的字段需要手动回收;
  • 需要为同一接口创建不同代码的实例时往往只能取其一。

小结

本文从Dalvik字节码的五个角度考虑,总结了相应的编码优化方案。文章仅个人观点,希望给广大读者提供一些参考。


原文链接:https://www.cnblogs.com/RainbowC0/p/19081965,未经作者许可禁止转载

posted @ 2025-11-13 22:41  RainbowC0  阅读(8)  评论(0)    收藏  举报