AOP / 面向切面编程 / 字节码插桩 / ASM / 字节码扫盲学习 / 解读版

3,122 阅读6分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

前言

这篇文章也是看了很多博客总结整理的,出现原因如下:

如果顺便能帮到你,我也很开心,可以来个点赞评论关注。

say hi to Gradle

上篇文章中偏实战性的讲述了自定义 Gradle 的步骤,下面两篇文章比较好深入认识 Gradle,大家可以看一看,不过可以放到最后再看: Gradle 系列(1)为什么说 Gradle 是 Android 进阶绕不去的坎 Gradle 系列(2)手把手带你自定义 Gradle 插件

绕不开的字节码 and JVM

ASM 提供的 API 完全是面向 Java 字节码编程,所以我们也需要理解 Java 字节码的结构和原理。建议拿起曾经那本「深入了解 Java 虚拟机」,经典永远是经典。
下图是 JVM 运行时数据库的结构,在学习垃圾回收机制时应该就把 堆/方法区/程序计数器/本地方法栈/虚拟机栈 的大致概念了解清楚了。「图是copy的」

image.png

这次我们主要理解的也就是 JVM 的桢栈结构,如下图「图是copy的」:

image.png

有几个概念需要理解:

  • 栈:一个线程拥有一个执行栈,且相互独立;
  • 栈桢:stack frame,每个栈桢表示一个方法的调用;栈桢的主要组成部分:局部变量表/操作数栈/动态链接/方法返回地址信息;
  • 局部变量表「LocalVariable」:存储方法参数 and 方法内定义的局部变量;
  • 操作数栈「Operand Stack」:LIFO,即后进先出;当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。

为了帮助理解局部变量表和各种指令,看段代码:

//这两个方法定义在 MainActivity 中

fun getResult() {
    testBBB(5, 7)
}

fun testBBB(a: Int, b: Int): Int {
    return a + b
}

用 ASM Bytecode Viewer 转换后的字节码,具体看注释:

  // access flags 0x11
  public final getResult()V
   L0
    LINENUMBER 24 L0
    //将局部变量表中下标为0 的变量加载到操作数栈上
    ALOAD 0
    //注意这里,5 和 7 采用了两种不同的指令
    ICONST_5
    BIPUSH 7
    //INVOKEVIRTUAL 调用实例方法
    INVOKEVIRTUAL com/zly/bbb/MainActivity.testBBB (II)I
    POP
   L1
    LINENUMBER 25 L1
    //RETURN 当前方法无返回值
    RETURN
   L2
    //对于getResult,局部变量只有 1 个就是 this
    LOCALVARIABLE this Lcom/zly/bbb/MainActivity; L0 L2 0
    MAXSTACK = 3
    MAXLOCALS = 1

  // access flags 0x11
  public final testBBB(II)I
   L0
    LINENUMBER 28 L0
    //可以注意下 ILOAD 这个命令,这是将 int 类型的局部变量,加载到操作数栈
    //这个 ILOAD 1 中的 1 对应的就是局部变量表中的数据 「LOCALVARIABLE a I L0 L1 1」
    //所以就可以理解为:加载变量a到操作数栈
    ILOAD 1 
    ILOAD 2
    IADD
    //IRETURN 当前方法返回 int
    IRETURN
   L1
   //实例方法,所以局部变量表第 0 位为 this
   //下面分别是参数 a 和 b
    LOCALVARIABLE this Lcom/zly/bbb/MainActivity; L0 L1 0
    LOCALVARIABLE a I L0 L1 1
    LOCALVARIABLE b I L0 L1 2
    //操作数栈的最大个数有 2 个
    MAXSTACK = 2
    //局部变量表最大个数有 3 个
    MAXLOCALS = 3

再看一下几个常用的命令 load 和 store:

  • ILOAD: 用于加载 boolean、int、byte、short、char 类型的局部变量到操作数栈;
  • 类似的还有 FLOAD,LLOAD,DLOAD,ALOAD 分别对应 float,long,double,引用类型;
  • ISTORE:从操作数栈弹出 boolean、int、byte、short、char 类型的局部变量,并将它存储在由其索引 I 指定的局部变量中;
  • 类似的还有 FSTORE,LSTORE,DSTORE,ASTORE;
  • invokevirtual:调用对象的实例方法;
  • invokeinterface:调用接口方法;
  • invokespecial:需要特殊处理的实例方法;
  • invokestatic:调用类方法

为了使字节码更加紧凑,int 数据的加载分多种类型:

  • 在[-1, 5]范围内,使用 iconst_n 的方式,操作数和操作码加一起只占一个字节;
  • 在[-128, 127]范围内,使用 bipush n 的方式,操作数和操作码一起只占两个字节;
  • 在[-32768, 32767]范围内,使用 sipush n 的方式,操作数和操作码一起只占三个字节;
  • 在其他范围内,则使用 ldc 的方式,这个范围的整数值被放在常量池中;

image.png

下一节会结合 ASM 加深部分字节码的理解。 不要开始就尝试理解所有的字节码,会很枯燥乏味,先认识常用的,其余等使用的时候边看边学就行,稍后可以看这里,解释的非常清楚 上最通俗易懂的ASM教程字节码及ASM使用字节码基础

初步认识字节码后,再结合 ASM

初识 ClassReader/ClassVisitor/ClassWriter

ClassReader 用来读取原有的字节码,ClassWriter 用于写入字节码,ClassVisitor/FieldVisitor/MethodVisitor/AnnotationVisitor 访问修改对应组件。

image.png

MethodVisitor 可以对方法体内的内容进行增加删除,但是不能增加方法。AdviceAdapter 是 MethodVisitor 的一个封装类。在上篇文章中使用了 AdviceAdapter,在方法里面增加了 log 打印,和方法耗时计算。

ASM 具体 API

上面的 getResult 和 testBBB 对应的 ASM代码为:

{
  methodVisitor = classWriter.visitMethod(ACC_PUBLC | ACC_FINAL, "getResult", "()V", null, null);
  //ASM 开始扫描这个方法
  methodVisitor.visitCode();
  Label label0 = new Label();
  methodVisitor.visitLabel(label0);
  methodVisitor.visitLineNumber(24, label0);
  //对应与上文中的 aload
  methodVisitor.visitVarInsn(ALOAD, 0);
  //将5压入到栈中,1<=ICONST<=5 使用这种方式。
  //否则使用 BIPUSH 方式
  methodVisitor.visitInsn(ICONST_5);
  //将7压入到栈中,这里就是 BIPUSH 方式
  methodVisitor.visitIntInsn(BIPUSH, 7);
 //访问 testBBB 方法methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "com/zly/bbb/MainActivity", "testBBB", "(II)I", false);
  //将操作数栈的栈顶元素出栈,即testBBB 方法调用出栈
  methodVisitor.visitInsn(POP);
  Label label1 = new Label();
  methodVisitor.visitLabel(label1);
  methodVisitor.visitLineNumber(25, label1);
  methodVisitor.visitInsn(RETURN);
  Label label2 = new Label();
  methodVisitor.visitLabel(label2);
  
  methodVisitor.visitLocalVariable("this", "Lcom/zly/bbb/MainActivity;", null, label0, label2, 0);
  //定义操作数栈和局部变量表的最大个数。
  //visitMaxs 方法的调用只能在 visitEnd() 前调用。
  methodVisitor.visitMaxs(3, 1);
  //方法访问结束
  methodVisitor.visitEnd();
}
{
  methodVisitor = classWriter.visitMethod(ACC_PUBLIC | ACC_FINAL, "testBBB", "(II)I", null, null);
  methodVisitor.visitCode();
  Label label0 = new Label();
  methodVisitor.visitLabel(label0);
  methodVisitor.visitLineNumber(28, label0);
  methodVisitor.visitVarInsn(ILOAD, 1);
  methodVisitor.visitVarInsn(ILOAD, 2);
  methodVisitor.visitInsn(IADD);
  methodVisitor.visitInsn(IRETURN);
  Label label1 = new Label();
  methodVisitor.visitLabel(label1);
  methodVisitor.
  visitLocalVariable("this", "Lcom/zly/bbb/MainActivity;", null, label0, label1, 0);
  methodVisitor.visitLocalVariable("a", "I", null, label0, label1, 1);
  methodVisitor.visitLocalVariable("b", "I", null, label0, label1, 2);
   //定义操作数栈和局部变量表的最大个数
  methodVisitor.visitMaxs(2, 3);
  methodVisitor.visitEnd();
} 

认识 Label「标签」

上面的代码中,多次提到了Label,想必大家很奇怪作用是啥,来吧,揭秘。 label 是用来划明一部分字节码的标识。一个标签下的字节码块,应该从操作栈空开始到操作栈被清空结束。也就是说,一个标签代表的字节码块反编译之后应该是完整的一或多条语句。

{
//下面这段代码还是对应上文中 testBBB 方法
  methodVisitor = classWriter.visitMethod(ACC_PUBLIC | ACC_FINAL, "testBBB", "(II)I", null, null);
  methodVisitor.visitCode();
  Label label0 = new Label();
  methodVisitor.visitLabel(label0);
  
  //代表字节码块 label0 的开始
  methodVisitor.visitLineNumber(28, label0);
  methodVisitor.visitVarInsn(ILOAD, 1);
  methodVisitor.visitVarInsn(ILOAD, 2);
  methodVisitor.visitInsn(IADD);
  methodVisitor.visitInsn(IRETURN);
  //代表字节码块 label0 的结束
  
  Label label1 = new Label();
  methodVisitor.visitLabel(label1);
  methodVisitor.
  visitLocalVariable("this", "Lcom/zly/bbb/MainActivity;", null, label0, label1, 0);
  methodVisitor.visitLocalVariable("a", "I", null, label0, label1, 1);
  methodVisitor.visitLocalVariable("b", "I", null, label0, label1, 2);
  methodVisitor.visitMaxs(2, 3);
  methodVisitor.visitEnd();
} 

通过 label :

  • 可以保存代码的行号,通过 MethodVisitor::visitLineNumber 这个方法。
  • 可以保存局部变量的名称。局部变量有作用域,作用域可以通过 label 指定。在这两个标签之内且在指定局部变量槽位上的变量就是我们要命名的局部变量。写入局部变量的名称使用 MethodVisitor::visitLocalVariable。
  • 用于跳转字节码上。「本结两个方法不涉及这个,不说了,用到的时候再学」
public void visitLocalVariable(
    final String name,
    final String descriptor,
    final String signature,
    final Label start,
    final Label end,
    final int index) {
  if (mv != null) {
    mv.visitLocalVariable(name, descriptor, signature, start, end, index);
  }
}

TransformClassesWithAsmTask 源码

在实战篇中,注册实现了 AsmClassVisitorFactory,也就在编译中发现 Task 执行列表中,新增了 TransformClassesWithAsmTask。看关键代码来捋一下执行流程...

//TransformClassesWithAsmTask 继承自 NewIncrementalTask
abstract class NewIncrementalTask: AndroidVariantTask() {

    //被 @TaskAction 注解的方法,代表该 Task 执行后,此方法就会被调用,所以不需要管
    @TaskAction
    fun taskAction(inputChanges: InputChanges) {
        //代码不列了
    }
    
    //需要关注的就是这个方法了
    abstract fun doTaskAction(inputChanges: InputChanges)
}
abstract class TransformClassesWithAsmTask : NewIncrementalTask() {
    
    override fun doTaskAction(inputChanges: InputChanges) {
        //是否全量编译
        if (inputChanges.isIncremental) {
            //增量编译
            doIncrementalTaskAction(inputChanges)
        } else {
            //全量编译
            doFullTaskAction(inputChanges)
        }
    }
    
    private fun doFullTaskAction(inputChanges: InputChanges) {
        //...
        workerExecutor.noIsolation().submit(TransformClassesFullAction::class.java) {
            //...
        }
    }
    
    private fun doIncrementalTaskAction(inputChanges: InputChanges) {
        //...
        workerExecutor.noIsolation().submit(TransformClassesIncrementalAction::class.java) {
            //...
        }
    }  
    
    //看以上两个方法可知 TransformClassesIncrementalAction/TransformClassesFullAction 两个action,
    //二者都继承自 TransformClassesWorkerAction
    //然后对应进入 action 的 run 方法
    //调用 TransformClassesWorkerAction 的 processJars 方法
    //再调用入各自action 的 maybeProcessJacocoInstrumentedJars 方法 
    //调用 AsmInstrumentationManager 的 instrumentClassesFromJarToJar
}

//最终会调用这个方法
private fun doInstrumentByteCode(
    classContext: ClassContext,
    byteCode: ByteArray,
    visitors: List<AsmClassVisitorFactory<*>>,
    containsJsrOrRetInstruction: Boolean
): ByteArray {
    val classReader = ClassReader(byteCode)
    val classWriter =
        FixFramesClassWriter(
            classReader,
            getClassWriterFlags(containsJsrOrRetInstruction),
            classesHierarchyResolver
        )
    var nextVisitor: ClassVisitor = classWriter

    if (framesComputationMode == FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_CLASSES) {
        nextVisitor = MaxsInvalidatingClassVisitor(apiVersion, classWriter)
    }

    val originalVisitor = nextVisitor

    //visitors:注册 AsmClassVisitorFactory 的列表
    visitors.forEach { entry ->
        nextVisitor = entry.createClassVisitor(classContext, nextVisitor)
    }

    // No external visitor will instrument this class
    if (nextVisitor == originalVisitor) {
        return byteCode
    }

    classReader.accept(nextVisitor, getClassReaderFlags(containsJsrOrRetInstruction))
    return classWriter.toByteArray()
}

我也不知道怎么描述更简洁,抄一张图吧... image.png

字节码插桩实战(一)

AOP 面向切面编程, 字节码插桩, 自定义 Gradle Plugin + Transform + ASM「小白操作版」

字节码插桩实战(二)

想好实现内容后再更新,文章太长了...

参考链接

字节码及ASM使用 吹爆系列:Android 插桩之美,全面掌握!
Transform 被废弃,ASM 如何适配? Java ASM详解:MethodVisitor与Opcode(三)标签,条件结构,循环结构,栈帧
继往开来:Google I/O 21 Android Gradle Plugin 更新总结

题外话

这掘金的主题配色也太美了...
我是从什么时候想学 ASM 的呢?

  • 最早用 Profile 看内存泄漏,毕竟泄漏多了必然导致卡顿;
  • 然后就是 Profile dump之后,看方法耗时,后来这种方式解决了明知某个操作卡顿的问题,不好对整个app定位,且效率不高;
  • 之后开始查找第三方的监控,最后看到了 tencent 的 matrix,使用了里面的 TraceCanary 模块 Matrix-TraceCanary 实际使用,方法耗时知道后帮助解决了部分问题,看看源码,慢慢就了解到了很多其他的性能监控项目,为了看懂源码就开始 ASM 了,最后要实现 Thread 的优化,尤其是很多三方 sdk 导致的线程问题。

加油卷吧...