目录

ASM 和 JVM 关联知识

背景

最近做日志治理(其实也没啥治理的),发现日志存活时间很短,因为各个业务方的日志汇总在一个实例里,有任何无用日志频繁打印,都会把日志覆盖掉

那其实,治理的办法也很简单:

  1. 增大日志缓存大小
  2. 治理无用日志

对于1,改个参数就完事儿了,实际这也是最有效的办法。
对于2,这得深入业务,往往就是一些扯皮和甩锅的事情

但是,“追求极致”的价值观告诉我们,处理一个问题,那就要围绕这个问题建设一系列的工具,比如:日志出问题了,能主动发现吗?有无用日志,那能不能直接知道哪里是无用的?等等多种卷法,这也是所谓 “思考” 的体现

那折中的办法,就是各个业务自己使用自己的日志实例打印日志(日志后续是可以合并的),如果有业务方日志频繁打印也只是覆盖自己的日志,自己去处理

随之而来的就是另一个问题,如何修改这部分的代码呢?

也很简单:

  1. 写个脚本,把调用之前旧的日志方法的地方,全改成新的
  2. 编译期处理

对于1,简单直接,没有二义性,但是收到了反对意见:log 方法在 base 中,多包依赖,不可以含有业务任何属性。

那就只能走2了,于是才有了今天这篇文章

简单总结一下要做的事情:

在编译期,将所有的 Log.v/d/i/w/e(tag, msg) 等方法,修改为 LogV2.v/d/i/w/e(biz, tag, msg),也就是加上了一个 biz 参数

这个本身可以使用 lancet 简单解决,但是 lancet 会导致所有新增的 api 为 public,并且 biz 的值需要存储在内存之中。所以,最终自己实现一个类似简单版 lancet 功能的插件

前置知识

1.方法调用和栈帧的通用概念

不知道大家有没有想过:一个方法究竟是怎么调用另一个方法的?

假如,我们考虑最最底层的寄存器视角,其实就是不断的执行各种 cpu 指令做计算,然后从寄存器中存值和取值

那我们当然也知道,cpu 架构有多种,每种架构都有自己的规则,约定参数从哪里拿?返回值塞到哪里?等等,就些规则就叫做 调用约定(calling convention)

之前写过一篇 frame pointer 栈回溯的文章,也与之相关。比如,在 arm64 下 x29 寄存器就是用来存储 frame pointer 的,这就是约定

其次,每次调用一个方法时,都会为这个方法在栈上开辟一片空间,然后在这片空间上去存一些值,比如返回地址,方法参数地址,比如 frame pointer 的值(如果有),这些都是编译器帮你处理的,所以每种编译器最终产生的栈帧的内存布局,可能也不尽相同,但是有各自的规律,稳定的实现一个方法调用的过程

wiki 上给的一个宽泛的规定如下: /img/in-post/call_stack_layout.png

2.Java ClassFile Format 中的 Operand Stack 和 Local Variable

上面简单说了一个 C 方法的调用过程,那一个 Java 方法又是如何调用的呢?概念上基本是一致的

java 接收的是 class 文件,class 文件中包含许多信息,包括这个方法在执行的时候本地变量有多少个(local variable)?操作栈有多大(operand stack)?(其实要开辟多少内存空间)等等

这两个东西都是编译期已经确定的,如果解析过 class 文件的话,大家就知道在 code attribute 中存在这两项值,在创建 stack frame 的时候自然会用到

/img/in-post/operand_stack.png

当我们运行代码的时候,就是不断的从 local variable 中 load 一个值到 operand stack 上,或者从 operand stack 上 store 一个值到 local variable 中,如此反复循环~~

3.Java 方法执行流程

有了上面的基础,我们看一个非常简单的 kotlin 方法调用的字节码

fun main() {
    val a = 1
    val b = 2
    val c= a+b
    println("hello world! $c")
}

对应字节码如下:

  L0
    LINENUMBER 6 L0
    ICONST_1
    ISTORE 0
   L1
    LINENUMBER 7 L1
    ICONST_2
    ISTORE 1
   L2
    LINENUMBER 8 L2
    ILOAD 0
    ILOAD 1
    IADD
    ISTORE 2
   L3
    LINENUMBER 9 L3
    NEW java/lang/StringBuilder
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    LDC "hello world! "
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ILOAD 2
    INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    SWAP
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L4
    LINENUMBER 10 L4
    RETURN
   L5
    LOCALVARIABLE a I L1 L5 0
    LOCALVARIABLE b I L2 L5 1
    LOCALVARIABLE c I L3 L5 2
    MAXSTACK = 2
    MAXLOCALS = 3

通过上面的字节码我们也可以意外的发现,kotlin ${} 表达式,其实就是 StringBuilder~~

我们逐行解析

L0

LINENUMBER: 表示源码中的行号信息,这也是 class 文件中存在的,用于回溯代码行号,我这里是第六行开始写的第一行代码

ICONST_1: 表示将1 load 到 operand stack 上

此时 stack[0] = 1

ISTORE 0: 表示将 operand stack 上第一个元素 store 到 local variable 的 index = 0 的位置上

此时:value = pop stack[0], local_variable[0] = value

L1

基本同 L0, 不同的就是:
ICONST_2: 表示将 2 load 到 operand stack 上
ISTORE 1: 表示将 operand stack 上第一个元素 store 到 local variable 的 index = 1 的位置上

L2

ILOAD 0 和 1: 表示将 local_variable 的 index = 0 和 index = 1 的元素 load 到 operand stack 中
IADD:表示执行求和指令,那具体细节又是怎样的呢?
看官方文档:

Both value1 and value2 must be of type int. The values are popped from the operand stack. The int result is value1 + value2. The result is pushed onto the operand stack.
The result is the 32 low-order bits of the true mathematical result in a sufficiently wide two’s-complement format, represented as a value of type int. If overflow occurs, then the sign of the result may not be the same as the sign of the mathematical sum of the two values.
Despite the fact that overflow may occur, execution of an iadd instruction never throws a run-time exception.

也就是说,IADD 会从 operand stack 中 pop 两个值,然后求和,然后将结果 push 回 operand stack 中。而我们刚好执行了 ILOAD 0 和 ILOAD 1,这两个指令刚好是将 local variable 中的值 load 到 operand stack 中去。所以,此时我们的 operand stack 上就是 1 和 2,求和后得到 3,重新 push 回 operand stack,此时 operand_stack[0] = 3

IADD 后,执行了 ISTORE 2,这条命令就是将 operand stack 上的值保存到 local variable index = 2 的地方

此时:value = pop stack[0], local_variable[2] = value

L3

NEW:创建一个对象,并且把 object ref 存储到 operand stack 上

此时: operand_stack[0] = StringBuilderRef

DUP:copy operand stack 上的值,并 push 到 operand stack 上,复制了一份,因为初始化方法会消耗掉一个 obj ref

此时:operand_stack[1] = copy(operan_stack[0]) = StringBuilderRef

INVOKESPECIAL:初始化方法的调用,它会 pop 掉 operand stack 上的值,刚好我们的 operand stack 上是 StringBuilder 的 obj ref,没有其他意外的话,我们的 StringBuilder 就初始化成功了

此时: pop operand_stack[1],operand_stack 只剩下了 operand_stack[0]

LDC:从常量池中加载我们的 hello world 字符串到 operand stack(什么是常量池?研究一下 class file 就知道了)

此时: operand_stack[0] = StringBuilderRef, operand_stack[1] = “hello world!”

INVOKEVIRTUAL:调用实例方法,这里调用的是 StringBuilder 的 append 方法,需要一个 obj ref 和 一个参数,参数已经通过 ldc 指令加载到 operand stack 上了,obj ref 本来因为 DUP 也还在(这两个参数一同被消耗掉)。同时因为 append 方法还会返回一个 StringBuilder 对象,所以还会 push 一个 obj ref 回来

此时:pop operand_stack[1] = “hello world!”, operand_stack[0] = obj ref
push StringBuilderRef
operand_stack[0] = StringBuilderRef
同时,解释下:方法参数需要哪些从方法签名可以获取

ILOAD 2: 将 local_variable[2] push 到 operand stack 上。之前我们将求和的结果存储到了 local_variable[2] 上(其实就是1+2=3)

此时: operand_stack[0] = StringBuilderRef, operad_stack[1] = 3

INVOKEVIRTUAL: 同之前,把 3 拼了上去,顺便又返回了一个 obj ref 回来

此时: pop operand_stack[1] = 3, operand_stack[0] = obj ref
push StringBuilderRef
operand_stack[0] = StringBuilderRef

INVOKEVIRTUAL:同之前,调用的是 toString 方法,不需要参数,消耗掉 obj ref,返回值再 push 一个 string 回来

此时: operand_stack[0] = StringRef

GETSTATIC: 获取一个静态属性,并 push 到 operand stack,这个静态属性就是 System.out 类型是 java/io/PrintStream

此时: operand_stack[0] = StringRef, operand_stack[1] = PrintStreamRef

SWAP:交换栈顶元素

💡注意!!!为什么需要 swap? 因为 bytecode 约定,调用某个方法就是要 obj ref 放在前面,后面跟着方法签名所要求的方法参数!而现在 operand_stack[0] = StringRef, operand_stack[1] = PrintStreamRef 显然不对。需要交换一下,变成:operand_stack[0] = PrintStreamRef, operand_stack[1] = StringRef

INVOKEVIRTUAL: 调用 println 方法

注意: 其实这里还有很多细节,比如,无论值是在 local variable 还是在 operand stack 上,究竟占用几个字节?也是格局 type 区分的

4.ASM 简单了解

通过上面的一个非常简单的例子,我们发现,一个方法的调用,其实就是通过各种运算指令,然后配合着 operand stack 和 local variable 上的内存空间,反复的存取而已

那我们也会意识到,如果想要通过修改 class 文件来改变一个方法的调用过程,其实是很危险的,因为 operand stack 和 local variable 在编译期间就已经确定了数目,index 最大值已经固定,如果我们贸然添加一些奇怪的指令,可能会在编译或者运行期间存在严重问题

并且,如果我们通过修改 class 文件的方式去修改源码,是十分复杂的。单单 class file format 就十分惊人。如果你解析过,你就会记得 constant pool 和 attribute info 有多烦人,而且内部全是 index 的引用,牵一发动全身~~

幸运的是,ASM 帮我们处理了这所有的一切~~

解析 Class 有 ClassVisitor,具体到 Method 又有 MethodVisitor,如果你想修改方法细节,还有 AdviceAdapter(这个经常在代码插装中使用,方法前后增加任意代码,asm 已经帮我们封装完毕) 和 LocalVariableSorter(可以随意创建局部变量)

ClassWriter 又会帮助我们重新回写整个 class file,包括 max local 和 max stack 的计算

留给我们需要亲自实现的就是具体的 ASM 指令了

5.Transform 简单了解

前面我们已经知道了修改 class 文件可以使用 asm 来实现,那么在 Android 编译过程中,有什么地方可以让我们处理 class 呢?

那就是 Transform 了(高版本 API 被废弃了,这里就是适配的问题了,不多牵扯)

我们可以继承 Plugin,然后注册自己的 Transform,使用 ASM 来实现具体的功能

最终上传我们的 plugin,在需要使用的地方 apply plugin 即可,这就是另一小块的知识了

具体实现

具体实现一个 Log.e(tag, msg) 替换为 LogV2.e(biz, tag, msg)

因为这里使用 bytex,plugin 的代码实现上,回调略有不同

Plugin 创建

// bytex
@PluginConfig("bytex.alog_replace")
open class ALogPlugin : CommonPlugin<ALogExtension, ALogContext>(){
}

// bytex transform 回调
override fun transform(relativePath: String, chain: ClassVisitorChain): Boolean {

}

ASM 处理

直接从 MethodVisitor 的具体实现来看

因为我们要把原方法调用Log.e(tag, msg),替换为另一个方法调用 LogV2.e(biz, tag, msg),并且方法参数要多一个(而且新增参数顺序排在第一位)

由上面我们分析 java 方法流程可知:

Log.e 触发调用时,实际执行的 opcode 为 invoke-static,此时 operand stack 上存储了 tag 和 msg

visitMethodInsn 回调中,我们可以监听到 invoke-static 这个 opcode 的调用

如果我们此时直接替换 Log 变为 LogV2,显然不对,最终会调用成 LogV2.e(tag, msg),丢失了 biz 参数

如果我们替换 Log 为 LogV2,并且通过 ALOAD 或者 LDC 指令,将 biz 参数放到 operand stack 上,也不对,最终调用成 LogV2.e(tag, msg, biz),biz 参数顺序不对

所以,我们要做的是:

  1. 将 operand stack 上的 tag 和 msg 存储下来
  2. 将 biz tag msg 按顺序放到 operand stack 上

怎么把 operand stack 上的值存储下来呢?需要使用 STORE 系列指令,因为我们这里都是 String 类型,所以使用 ASTORE 即可

其次,ASTORE 表示将 operand stack 上的值存储到 local variable 中,local variable 我们在之前知道大小是固定的,我们需要扩展 local variable

好在 LocalVariableSorter 可以帮我们处理好这些事情,通过 newLocal 方法即可,返回值就是对应 local varaible 上的槽位

那我们的流程就变为:

  1. newLocal 创建存储 tag 和 msg 需要的空间
  2. ASTORE 到对应的 local variable 的槽位上
  3. LDC 加载 biz 参数(如果是常量,否则需要其他方式) 到 operand stack 上
  4. ALOAD 把之前存储的 tag 和 msg,按需要的顺序加载到 operand stack 上
  5. 调用 LogV2.e
class ALogMethodVisitor(
    api: Int,
    access: Int,
    descriptor: String?,
    mv: MethodVisitor
) : LocalVariablesSorter(api, access, descriptor, mv) {

    override fun visitMethodInsn(opcode: Int, owner: String?, name: String?, descriptor: String?, isInterface: Boolean) {
      if (isLoge()) {
            // 拿到 method 参数
            val methodParamTypes = Type.getArgumentTypes(descriptor)
            // local variable slot 槽位
            val slotList = Array(methodParamTypes.size) { 0 }
            methodParamTypes.reversed().forEachIndexed { index, type ->
                // 创建本地变量
                val slotIndex = newLocal(type)
                // 注意顺序
                slotList[methodParamTypes.size - index - 1] = slotIndex
                // 从 operand stack pop 到 local variable
                mv.visitVarInsn(Opcodes.ASTORE, slotIndex)
            }
            // ldc biz 参数
            mv.visitLdcInsn("test")
            // 把 local variable 上的变量重新 load 到 operand stack 上去
            methodParamTypes.forEachIndexed { index, type ->
                mv.visitVarInsn(Opcodes.ALOAD, slotList[index])
            }
            // LogV2.e 依然是静态方法,方法名不变,但是方法参数改变
            // 所以,需要用修改 owner 和 descriptor
            // 这里修改 methodParamTypes 增加一个 String 类型参数
            val newDescriptor =  addStringParam(methodParamTypes)
            // 调用 LogV2.e
            mv.visitMethodInsn(
                // 静态方法
                Opcodes.INVOKESTATIC,
                newOwner, // LogV2
                name,
                newDescriptor,
                isInterface
            )
        } else {
            super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
        }
    }
   
}

总结

之前默默做过一个 toy jvm,在实现 opcode 的时候,把 LOAD 和 STORE 系列的指令实现了,只记得有这些个指令,需要在 local variable slot 和 operand stack 上反复移动

如今,通过 ASM 又进行了加深理解

其实,在实现 LOAD 和 STORE 指令的时候,还有类型区分,比如 LONG 需要两个字节。再比如,GOTO 指令的实现 offset 跳转等等

希望次篇文章对于初次使用 asm 的程序猿有所帮助