diff --git "a/24.07/java\345\255\227\350\212\202\347\240\201.md" "b/24.07/java\345\255\227\350\212\202\347\240\201.md" new file mode 100644 index 00000000..903e0691 --- /dev/null +++ "b/24.07/java\345\255\227\350\212\202\347\240\201.md" @@ -0,0 +1,274 @@ +--- +title: java字节码 +date: 2024-07-09 23:38:00+8 +tags: + - java + - 字节码 + - classFile +--- +# 1 什么是字节码 +`.class`文件的内容就是字节码,`jvm`定制了一套`class`文件规范,只要按照这个规范的文件就可以在`jvm`中被加载成`类`。 + +![img](https://i.imgur.com/SNQQFzw.png) + +`class`文件与`elf`这种可执行文件一样,也是一种数据结构,或者说是一个结构体,只不过相比于`elf`来说,`class`文件要简单太多了,简单讲就是把我们的`java`代码给压缩了。通过`xxd`指令可以简单的看一下,`class`文件中的内容,这些字节数组就是字节码,整个文件满足`ClassFile`的结构规范。 + +![img](https://i.imgur.com/zxdXgW4.png) +# 2 类文件规范 +`class file specification`类文件规范就是字节码规范,可以从`oracle Java SE Specifications`的文档中找到第四章,[链接](https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-4.html),这部分是class文件的规范,url中`se21`是java21版本的规范,可以改成其他比如`se7`,你会发现class文件的整体结构变化不大,并且java有着非常变态的向前兼容性,java21能够兼容java1.0版本的class文件。 + +官网上来就贴出来了一张图,这张图至少从java7到21都没有变过,更早的文档不在这里维护了,不过可能也没变过,就是下面这张`ClassFile`的结构。 + +![img](https://i.imgur.com/N5Hn7Sy.png) + +每个class文件都是满足这个结构体的。我们可以下载[jclasslib](https://github.com/ingokegel/jclasslib/releases)小工具或idea插件,打开后会发现在通用信息中,其实涵盖了`ClassFile`结构体的大部分字段,这些字段都是单层深度,没有嵌套。 + +![img](https://i.imgur.com/iBEIbvT.png) + +插件的用法是,先`compile`之后,通过`view->Show Bytecode With Jclasslib` + +![img](https://i.imgur.com/lqQ6x4Y.png) + +一般信息中,涵盖了一个类的基础信息,对应了上面图片中单层结构的字段,即非数组的部分。这部分比较简单,有一些内定的值,比如 +- 大版本号52对应java8,53是java9... +- 访问标识指类是`public/pravite`等。 +- `this`和`parent`的类名,是用`/`分割的,而不是`.`。 + +剩下的部分在左侧栏,依次是`常量池` `实现的接口列表` `字段列表` `方法列表` `属性列表` +## 2.1 常量池 +常量池一般是`ClassFile`结构体中最大的部分之一(方法也很大),`cp_info`是每一种常量类型的结构体,如下,第一个字节是类型,后面是数据,每一种类型或者叫`tag`,对应的`info[]`的内容是不一样的。 +``` +cp_info { + u1 tag; + u1 info[]; +} +``` +主要的`tag`类型如下(这个其实不重要所有这些信息都可以从官方文档看到,只是简单列举一下) + +|Constant Type|Value| +|---|---| +|CONSTANT_Class|7| +CONSTANT_Fieldref|9 +CONSTANT_Methodref|10 +CONSTANT_InterfaceMethodref|11 +CONSTANT_String|8 +CONSTANT_Integer|3 +CONSTANT_Float|4 +CONSTANT_Long|5 +CONSTANT_Double|6 +CONSTANT_NameAndType|12 +CONSTANT_Utf8|1 +CONSTANT_MethodHandle|15 +CONSTANT_MethodType|16 +CONSTANT_InvokeDynamic|18 + +这些类型中很多都是定长的,很容易定位,比如`Long` `Integer` `Float`等等,甚至`Class`这些基本也是定长的,虽然类名长度是不确定的,但是`Class_info`是定长3个字节,后面2个是个下标指向类名字符串的位置,最终会指向一个`CONSTANT_Utf8`。 + +![img](https://i.imgur.com/k2cY9P2.png) + +![image](https://i.imgur.com/8lG0eIw.png) + +`CONSTANT_Utf8`中有2个字节记录长度,如下,所以这样就能分割出每种结构了,要么是定长,变长的会有字段记录长度。 + +![img](https://i.imgur.com/ow9n3qJ.png) +## 2.2 接口 +这部分是当前`class`实现的接口列表,`interfaces`部分的定义是个`u2[]`而不是字符串,每个`u2`是`cp_info`的一个下标指,即接口名也是作为字符串常量存储到常量池的。 + +如下,实现了`java.lang.Runnable`,这里存储的是下标4,指向的是常量池字符串`java/lang/Runnable` + +![img](https://i.imgur.com/5HxZJNf.png) + +## 2.3 字段 +`fields`部分是如下结构体的数组, +```c +field_info { + u2 access_flags; + u2 name_index; + u2 descriptor_index; + u2 attributes_count; + attribute_info attributes[attributes_count]; +} +``` +![image](https://i.imgur.com/e50f2Eo.png) + +`access_flags`与之前类的是类似的,`name_index`记录字段名在常量池的下标,所以是`u2`类型,`descriptor_index`是类似的,表示当前字段的类型描述。这里需要专门解释下`discriptor`的形式,后面方法中也会看到类似的。基础类型都是用一个字母表示,而对象类型是用`Ljava/lang/String;`表示,注意前面有个`L`,后面有个`;`,这是一个`discriptor`写法规范。基础类型的描述符如下,基本都是首字母的大写: +- int I +- long J (因为L给长类型用作前缀了,所以换J) +- short S +- byte B +- float F +- double D +- boolean Z (因为B给byte了,所以换Z) +- char C + +`attribute_info`是属性列表,这个暂时跳过,最后会简单提一下,属性非常多,每个java版本基本都会新增一些属性。 +## 2.4 方法 +方法是`method_info`数组如下,也是访问级别,名称,描述和属性,与`field_info`是一模一样的。 +``` +method_info { + u2 access_flags; + u2 name_index; + u2 descriptor_index; + u2 attributes_count; + attribute_info attributes[attributes_count]; +} +``` +![img](https://i.imgur.com/fQKmcQD.png) + +与`field_info`的描述符格式不一致,函数的描述符是由`(入参)返回值`组成的,入参如果有多个,是并排的列出的并不需要任何符号隔开,例如`String test(int a, long b, String c)`的描述符就是`(IJLjava/lang/String;)Ljava/lang/String;`。而返回值部分除了正常的返回值类型的描述符,还增加了一种`V`是对`void`返回类型的描述。同时还需要注意有两个方法名是比较特殊的,一个是``指的是构造方法的方法名,还有一个是``是静态代码块组成的类初始化时候运行的方法。 + +此外,方法中都会有`code`这个属性,该属性中放置了方法的代码字节码,我们也放到属性部分再说。 +## 2.5 属性 +在`ClassFile`级别最后的部分是由属性`attributes`的,而上面的`field_info`和`class_info`中也是有`attributes`信息的,属性信息会有较大可扩展性,很多java新版本的特性想要扩展,那属性是一个很好的放置位置,以便于不改变整体的结构。属性部分是最复杂,在jdk21的规范中已经支持了30种属性结构了。 + +![img](https://i.imgur.com/pIQ1bYp.png) + +这里我们不再对每一种属性都单独讲解了,官方文档有较为细致的解释,这里挑几个比较常见的。 + +### 2.5.1 ConstantValue +`ConstantValue`类型,只针对常量`static final`的`基础类型或字符串`的属性,在编译器赋值,而不是运行时,提高效率。 + +![img](https://i.imgur.com/ZNph83v.png) + +### 2.5.2 Code +`Code`类型,函数体的内容,这个是非常重要的,尤其是后面学习`ASM`指令,一个类主要承载的功能,都反应在了`method`的`code`里,code类型的结构体非常复杂,我们可以直接看`jclasslib`给我们图形化展示之后的,以构造方法``为例,这段代码中,我们虽然没有写构造方法,但是默认也会有构造方法,默认的实现就是`super()`也就是调用父类的构造方法;此外我们还对字段进行了赋值所以有如下代码。 + +![img](https://i.imgur.com/fvyZVCe.png) + +我们在下一节会详细展开介绍code中的不同指令。 + +### 2.5.3 Exception +`Exception`类型,函数中声明的抛出的异常,可以有多个。注意这里是声明的抛出的异常,不包含一些运行时的异常。 + +![img](https://i.imgur.com/W3ZETMA.png) + +![img](https://i.imgur.com/ccMe6SI.png) + +区分`Code异常表`和`Exception`属性:通过`try-catch`的异常会出现在`code异常表`。 + +![image](https://i.imgur.com/MF9D9cf.png) + +### 2.5.4 LineNumberTable +`Code`属性中的一个属性,记录行号的,方便debug + +### 2.5.5 LocalVariableTable +局部变量或者叫本地变量表,也是`Code`中的属性,记录本地变量的名字,比如方法中`int a = 100;`,`a`这个变量名字和变量索引的对应关系就会记录在局部变量表,这个也是debug方便的,与`LineNumberTable`一样,他俩即使删了,也不影响字节码运行。因为学`asm`的时候会看到这俩所以提一下。 + +![img](https://i.imgur.com/7eB2f6b.png) + +### 2.5.6 Signature +`Signature`与泛型密切相关,虽然java的泛型在执行的时候会被擦除,但是这是为了兼容老版本的`java`,泛型信息其实还是被记录了下来,会被放置到这个属性中,例如`names`是个`List`,他的字段信息中只有`List`没有泛型信息,但是`Signature`属性中,是有记录泛型信息的。 + +![img](https://i.imgur.com/b401ydy.png) + +![img](https://i.imgur.com/H8aTpsZ.png) + +# 3 函数Code中的指令 +`ClassFile`的结构介绍完毕了,其中最最核心的部分其实没有展开,那就是函数的code部分的字节码。这里我们需要了解,操作数和操作数栈的概念: + +`操作数`就是常见的变量例如基础类型和对象引用,我们的函数就是在操作这些操作数,如果想要操作他们,那么必须先进行`load`加载,加载会将操作数加载到一个栈的数据结构上,这个栈就是`操作数栈`。例如我们想要完成`a + b`这个操作,需要把a加载到栈,再把b加载到栈,然后运行加法操作。 + +我们看一下对应的字节码: + +![image](https://i.imgur.com/YOGrKU6.png) + +通过这个图,我们有了一个大概的概念,就是我们想要执行一个操作或者说一个行为,不管是加法操作还是函数调用操作还是其他操作,都需要先准备好要操作的数,比如这里的`a`和`b`要先load到栈上,然后执行`iadd`进行加法操作,操作会消耗掉栈顶特定个数的操作数,比如`iadd`是消耗两个,如果操作有返回,也会放置到栈顶。 + +接下来我们就需要了解一些常用的指令了,比如操作数需要`load`才能放置到栈顶,那么有哪些`load`指令呢? +## 3.1 load/push +`load`的形式有很多种,比如我们可以把`本地变量load`到栈顶 +- `iload_{y}`按照`int`或`byte`或`char`或`boolean`或`short`类型,加载第y个变量。 +- `lload_{y}`按照`long`类型加载第y个变量。 +- `fload_{y}`按照`float`类型加载第y个变量。 +- `dload_{y}`按照`double`类型加载第y个变量。 +- `aload_{y}`按照对象类型加载第y个变量,`aload_0`加载this,默认第0个位置是`this` + +或者`常量load`到栈顶 +- `ldc` load contant 加载常量(`int`或`byte`或`char`或`boolean`或`short`或`float`类型或字符串常量) +- `ldc_w` 如果上面几种类型,因为一些不可抗力存到了宽索引,即2个栈帧中,则需要用这个指令,较少使用。 +- `ldc2_w` 加载`long` 或 `double`类型常量 + +但是`ldc`对于一些小数字类型的性能稍差(但也可以用),于是为了性能有一些专门的指令 +- `iconst_`如果是0-5可以优化性能 +- `iconst_m1`同上专门针对-1的load +- `bipush`针对byte范围的int值的load +- `sipush`针对short范围的int值的load +- 上面只是`int`的其他类型也有专门的指令,这里不再列出。 + +## 3.2 store +上面`iload_1`是把本地变量1加载到栈顶,但是一开始没有存储本地变量1呢?所以是会先有一个存储的过程,这就是`store`指令了。 +- `istore_{y}`把栈顶的`int`或`byte`或`char`或`boolean`或`short`类型消耗掉,存到本地变量y,y是数字。 +- `lstore_{y}`把栈顶的`long`消耗,存到本地变量y。!!注意long占用两个栈帧,消耗掉两个栈顶的位置。 +- `fstore_{y}`把栈顶的`float`消耗,存到本地变量y。 +- `dstore_{y}`把栈顶的`double`消耗,存到本地变量y。!!注意double占用两个栈帧,消耗掉两个栈顶的位置。 +- `astore_{y}`把栈顶的对象地址消耗,存到本地变量y。 + +## 3.3 return +`return`之后需要保证栈是空的,不然编译会验证不通过。 +- `return`等于代码return,不消耗栈顶 +- `ireturn`消耗栈顶一帧,返回一个`int`或`byte`或`char`或`boolean`或`short`类型 +- `freturn`消耗栈顶一帧返回一个float +- `lreturn`消耗栈顶2帧返回一个long +- `dreturn`消耗栈顶2帧返回一个double +- `areturn`消耗栈顶一帧返回一个地址,即返回一个对象类型的内存地址 + +注意:`return`不一定是代码结束的地方,可能有判断分支有多个`return`语句,而且还有可能是`athrow`抛出异常。 + +## 3.4 pop/dup/new +如果一个栈上的操作数,想要直接消耗掉,则直接用`pop`指令消耗一个栈帧,比如运行了一个函数操作后,直接忽略函数的返回值就可以`pop`消耗掉,如果返回值是`long/double`可以`pop`两次,或者`pop2`指令消耗。 + +如果想要复制一份操作数栈顶的数,即栈顶连续两个相同操作数则使用`dup` `dup2`这样的指令,这经常用于`new`一个对象。 +```java +Object obj = new Object(); +``` +对应字节码,如下`new`指令作用是,创建一个对象会在堆上分配内存,并将内存的地址放到操作数栈上;注意这里有个`dup`把地址复制了一份,这是`new`对象的一个固定操作,因为`invokespecial #1 : ()V>`这个构造方法与普通非静态方法一样,会消耗掉一个操作数作为`this`。所以需要提前把地址备份一下,不然`new`完地址就丢了,下面会说`invoke`相关指令。 +``` +new #4 +dup +invokespecial #1 : ()V> +``` +![img](https://i.imgur.com/0bqdThZ.png) + +`dup`还有一些变种,例如`dup_x1`效果是`[top-A-B]` => `[top-A-B-A]`,复制栈顶,但复制的位置是跳过一个。`dup_x2`同理还有什么`dup2_x1` `dup2_x2`,当然这几个指令都可以用`dup` `pop` `store` `load`实现,只不过这个效率更高。 + +此外`new`不能创建数组对象,数组比较特殊,有专门`newarray`基础类型数组,`anewarray`创建对象类型数组,`multianewarray`创建数组类型数组(多维)。 + +## 3.5 invoke +`invoke`是函数调用的指令,他主要有5种, +- `invokevirtual`普通的可访问的方法,需要依次把`对象`,`参数从左到右`放到栈顶。 +- `invokestatic`静态方法,需要依次把`参数从左到右`放到栈顶。 +- `invokespecial`特殊方法,构造方法,私有方法,父类中的方法,接口的default实现等,根据情况参考上面的操作数顺序。 +- `invokeinterface`接口方法,栈顶操作数顺序参考上面。 +- `invokedynamic`动态方法,一般是lambda表达式,栈顶操作数顺序参考上面。 + +## 3.6 基础运算 +基础运算是加减乘除位运算等,`[iflda]`是代表类型,下面用`{t}`表示 +- `{t}add` `{t}sub` `{t}mul` `{t}div` 栈顶俩数,加减乘除四则运算 +- `{t}and` `{t}or` `{t}xor` 栈顶俩数,与 或 异或,注意没有非门,非是通过和全1的值异或得到。 +- `{t}shl` `{t}shr` `{t}shur` 左右移 无符号右移`<< >> >>>`,没有无符号左移,左移与符号本就无关。 +- `{t}rem` 栈顶一个数,取余`%` +- `{t}neg` 栈顶一个数,取反`-a` +- `{t}2{t}` 基础类型转换 +- `iinc` int特有的`++`操作符,其他类型没有 + +## 3.7 跳转相关 +当出现流程控制的时候,字节码会变得复杂。例如`if(a>b) print("a>b"); else print("a<=b");`最基本的判断分支,单次执行只能走到一个分支,那就需要跳转。还是用`{t}`表示类型,用`{cond}`表示条件:`eq`等,`ne`不等,`lt` `le` `gt` `ge`小于、小于等于、大于、大于等于、 +- `if_{t}cmp{cond} num` 比较栈顶两个数,是否满足`cond`,如果是则跳转到`num`指令。 +- `if{cond} num` 直接判断栈顶一个数,是否满足`cond`,例如`ifeq`代表栈顶为0则跳转,`ifnq`是栈顶不为0跳转 +- `goto num` 无脑跳转 + +这是条件分支的代码,满足`a<=b`跳转16行,否则继续往下执行,执行到`goto`直接跳转到24行`return`指令。 + +![img](https://i.imgur.com/Z9tsWLg.png) + +这里不得不提一些`try-catch`,例如对上述代码套一层,看字节码会发现两个判断分支都会走到`24`指令,原来的24是`return`现在是`goto 36`,而后者其实就是`return`,所以看上去根本执行不到`24-36`之间的`catch`处理。 + +![img](https://i.imgur.com/YxjfQat.png) + +其实`try-catch`是专门记录到`code`的异常表中的,上面提到过异常表和异常属性的区别。 + +![imag](https://i.imgur.com/GeRbzt2.png) + +注意只需要记录`tryStart` `tryEnd` 和 `catchStart`,不需要记录`catchEnd`,因为`catch`中可以自己`goto`跳走,或`return/athrow`结束。 + +感兴趣的可以自己看下,如果是`try-catch-finally`会是怎样的字节码,要复杂很多。 \ No newline at end of file diff --git "a/24.07/java\347\232\204asm\345\272\223\346\267\261\345\205\245\346\265\205\345\207\272.md" "b/24.07/java\347\232\204asm\345\272\223\346\267\261\345\205\245\346\265\205\345\207\272.md" index 3cfba20e..afb529eb 100644 --- "a/24.07/java\347\232\204asm\345\272\223\346\267\261\345\205\245\346\265\205\345\207\272.md" +++ "b/24.07/java\347\232\204asm\345\272\223\346\267\261\345\205\245\346\265\205\345\207\272.md" @@ -6,241 +6,9 @@ tags: - asm - 字节码 --- -`asm`库是用来查看(分析)、创建和修改`jvm`字节码的,所以学习`asm`之前我们要先学习`jvm`字节码,然后再了解是如何分析、创建和修改字节码的。 +没看上一篇[java字节码](./java字节码.md),建议先看上一篇。没有字节码背景知识,本文基本不可能看懂。 -# 1 jvm的字节码 -`.class`文件的内容就是字节码,`jvm`定制了一套`class`文件规范,只要按照这个规范的文件就可以在`jvm`中被加载成`类`。 - -![img](https://i.imgur.com/SNQQFzw.png) - -`class`文件与`elf`这种可执行文件一样,也是一种数据结构,或者说是一个结构体,只不过相比于`elf`来说,`class`文件要简单太多了,简单讲就是把我们的`java`代码给压缩了。 -## 1.1 class file specification -可以从`oracle Java SE Specifications`的文档中找到第四章,[链接](https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-4.html),这部分是class文件的规范,url中`se21`是java21版本的规范,可以改成其他比如`se7`,你会发现class文件的整体结构变化不大,并且java有着非常变态的向前兼容性,java21能够兼容java1.0版本的class文件。 - -官网上来就贴出来了一张图,这张图至少从java7到21都没有变过,更早的文档不在这里维护了,不过可能也没变过,就是下面这张`class`文件的结构。 - -![img](https://i.imgur.com/N5Hn7Sy.png) - -每个class文件都是满足这个结构体的。我们可以下载[jclasslib](https://github.com/ingokegel/jclasslib/releases)小工具或idea插件,打开后会发现在通用信息中,其实涵盖了`ClassFile`结构体的大部分字段,这些字段都是单层深度,没有嵌套。 - -![img](https://i.imgur.com/iBEIbvT.png) - -插件的用法是,先`compile`之后,通过`view->Show Bytecode With Jclasslib` - -![img](https://i.imgur.com/lqQ6x4Y.png) - -一般信息中,涵盖了一个类的基础信息,对应了上面图片中单层结构的字段,即非数组的部分。这部分比较简单,有一些内定的值,比如 -- 大版本号52对应java8,53是java9... -- 访问标识0x0001是ACC_PUBLIC,当然这里是0x0021其实是`ACC_PUBLIC|ACC_SUPER`后者与`invokespecial`指令有关,具体可以参考官网的诸多枚举定义。 -- `this`和`parent`的类名,是用`/`分割的,而不是`.`。 - -剩下的部分在左侧栏,依次是`常量池` `实现的接口列表` `字段列表` `方法列表` `属性列表` -### 1.1.1 常量池 -常量池一般是`ClassFile`结构体中最大的部分,`cp_info`结构体的基本类型是第一个字节代表是那种类型的常量,从第二个字节开始就是一个数组了。 -``` -cp_info { - u1 tag; - u1 info[]; -} -``` -这只是一个基础的结构对于不同的`tag`下`info[]`的长度是有规律的,这样才知道怎么截断,主要的`tag`类型如下(这个其实不重要所有这些信息都可以从官方文档看到,只是简单列举一下) - -|Constant Type|Value| -|---|---| -|CONSTANT_Class|7| -CONSTANT_Fieldref|9 -CONSTANT_Methodref|10 -CONSTANT_InterfaceMethodref|11 -CONSTANT_String|8 -CONSTANT_Integer|3 -CONSTANT_Float|4 -CONSTANT_Long|5 -CONSTANT_Double|6 -CONSTANT_NameAndType|12 -CONSTANT_Utf8|1 -CONSTANT_MethodHandle|15 -CONSTANT_MethodType|16 -CONSTANT_InvokeDynamic|18 - -这些类型中很多都是定长的,很容易定位,比如`Long` `Integer` `Float`等等,甚至`Class`这些基本也是定长的,虽然类名长度是不确定的,但是`Class_info`是定长3个字节,后面2个是个下标指向类名字符串的位置。 - -![img](https://i.imgur.com/k2cY9P2.png) - -![image](https://i.imgur.com/8lG0eIw.png) - -而变长的字符串类型则是有记录长度的,如下,所以这样能分割出每种结构。 - -![img](https://i.imgur.com/ow9n3qJ.png) -### 1.1.2 接口 -这部分是当前`class`实现的接口列表,`interfaces`部分的定义是个`u2[]`而不是字符串,每个`u2`是`cp_info`的一个下标指,即接口名也是作为字符串常量存储到常量池的。 - -如下,实现了`java.lang.Runnable`,这里存储的是下标4,指向的是常量池字符串`java/lang/Runnable` - -![img](https://i.imgur.com/5HxZJNf.png) - -### 1.1.3 字段 -`fields`部分是如下结构体的数组, -```c -field_info { - u2 access_flags; - u2 name_index; - u2 descriptor_index; - u2 attributes_count; - attribute_info attributes[attributes_count]; -} -``` -![image](https://i.imgur.com/e50f2Eo.png) - -`access_flags`与之前类的是类似的,`name_index`记录字段名在常量池的下标,所以是`u2`类型,`descriptor_index`是类似的,表示当前字段的类型描述。这里需要专门解释下`discriptor`的形式,后面方法中也会看到类似的。基础类型都是用一个字母表示,而对象类型是用`Ljava/lang/String;`表示,注意前面有个`L`,后面有个`;`,这是一个`discriptor`写法规范。基础类型的描述符如下,基本都是首字母的大写: -- int I -- long J (因为L给长类型用作前缀了,所以换J) -- short S -- byte B -- float F -- double D -- boolean Z (因为B给byte了,所以换Z) -- char C - -`attribute_info`最后会说是属性列表。 -### 1.1.4 方法 -方法是`method_info`数组如下,也是访问级别,名称,描述和属性,与`field_info`是一模一样的。 -``` -method_info { - u2 access_flags; - u2 name_index; - u2 descriptor_index; - u2 attributes_count; - attribute_info attributes[attributes_count]; -} -``` -![img](https://i.imgur.com/fQKmcQD.png) - -与`field_info`对比之下,会发现有两处不同。 - -一个就是描述符格式不一致,函数的描述符是由`(入参)返回值`组成的,入参如果有多个,是并排的列出的并不需要任何符号隔开,例如`String test(int a, long b, String c)`的描述符就是`(IJLjava/lang/String;)Ljava/lang/String;`。而返回值部分除了正常的返回值类型的描述符,还增加了一种`V`是对`void`返回类型的描述。同时还需要注意有两个方法名是比较特殊的,一个是``指的是构造方法的方法名,还有一个是``是静态代码块组成的类初始化时候运行的方法。 - -另一个不太一样的事,下面有`Code`属性,这是`attribute`的一种,我们同样放到属性去说,这里简单提一下,`Code`类型的属性,就是记录了方法体的指令内容。 -### 1.1.5 属性 -在`ClassFile`级别最后的部分是由属性`attributes`的,而上面的`field_info`和`class_info`中也是有`attributes`信息的,属性信息会有较大可扩展性,很多java新版本的特性想要扩展,那属性是一个很好的放置位置,以便于不改变整体的结构。属性部分是最复杂,在jdk21的规范中已经支持了30种属性结构了。 - -![img](https://i.imgur.com/pIQ1bYp.png) - -这里我们不再对每一种属性都单独讲解了,官方文档有较为细致的解释,这里挑几个比较常见的。 - -一、`ConstantValue`类型,只针对常量`static final`的`基础类型或字符串`的属性,这些可以在编译器赋值,而不是运行时,提高效率。 - -![img](https://i.imgur.com/ZNph83v.png) - -二、`Code`类型,函数体的内容,这个是非常重要的,尤其是后面学习`ASM`指令,一个类主要承载的功能,都反应在了`method`的`code`里,code类型的结构体非常复杂,我们可以直接看`jclasslib`给我们图形化展示之后的,以构造方法``为例,这段代码中,我们虽然没有写构造方法,但是默认也会有构造方法,默认的实现就是`super()`也就是调用父类的构造方法;此外我们还对字段进行了赋值所以有如下代码。 - -![img](https://i.imgur.com/fvyZVCe.png) - -我们在下一节会详细展开介绍code中的不同指令。 - -三、`Exception`类型,函数中声明的抛出的异常,可以有多个。注意这里是声明的抛出的异常,不包含一些运行时的异常。尤其要区分好,`Exception`类型的属性和`Code`类型的属性中的"异常表","异常表"指的是方法的`try-catch`的 - -![img](https://i.imgur.com/W3ZETMA.png) - -![img](https://i.imgur.com/ccMe6SI.png) - -只有通过`try-catch`的异常会出现在`code异常表`。 - -![image](https://i.imgur.com/MF9D9cf.png) - -四、`LineNumberTable`与`LocalVariableTable` - -![img](https://i.imgur.com/7eB2f6b.png) - -五、`Signature`与泛型密切相关,虽然java的泛型在执行的时候会被擦除,但是这是为了兼容老版本的`java`,泛型信息其实还是被记录了下来,会被放置到这个属性中,例如`names`是个`List`,他的字段信息中只有`List`没有泛型信息,但是`Signature`属性中,是有记录泛型信息的。 - -![img](https://i.imgur.com/b401ydy.png) - -![img](https://i.imgur.com/H8aTpsZ.png) - -## 1.2 函数Code中的指令 -`ClassFile`的结构介绍完毕了,其中最最核心的部分其实没有展开,那就是函数的code部分的字节码。这里我们需要了解,操作数和操作数栈的概念: - -`操作数`就是常见的变量例如基础类型和对象引用,我们的函数就是在操作这些操作数,如果想要操作他们,那么必须先进行`load`加载,加载会将操作数加载到一个栈的数据结构上,这个栈就是`操作数栈`。例如我们想要完成`a + b`这个操作,需要把a加载到栈,再把b加载到栈,然后运行加法操作。 - -我们看一下对应的字节码: - -![image](https://i.imgur.com/YOGrKU6.png) - -通过这个图,我们有了一个大概的概念,就是我们想要执行一个操作或者说一个行为,不管是加法操作还是函数调用操作还是其他操作,都需要先准备好要操作的数,比如这里的`a`和`b`要先load到栈上,然后执行`iadd`进行加法操作,操作会消耗掉栈顶特定个数的操作数,比如`iadd`是消耗两个,如果操作有返回,也会放置到栈顶。 - -接下来我们就需要了解一些常用的指令了,比如操作数需要`load`才能放置到栈顶,那么有哪些`load`指令呢? -### 1.2.1 load/push -`load`的形式有很多种,比如我们可以把`本地变量load`到栈顶 -- `iload_{y}`按照`int`或`byte`或`char`或`boolean`或`short`类型,加载第y个变量。 -- `lload_{y}`按照`long`类型加载第y个变量。 -- `fload_{y}`按照`float`类型加载第y个变量。 -- `dload_{y}`按照`double`类型加载第y个变量。 -- `aload_{y}`按照对象类型加载第y个变量,`aload_0`加载this,默认第0个位置是`this` - -或者`常量load`到栈顶 -- `ldc` load contant 加载常量(`int`或`byte`或`char`或`boolean`或`short`或`float`类型或字符串常量) -- `ldc_w` 如果上面几种类型,因为一些不可抗力存到了宽索引,即2个栈帧中,则需要用这个指令,较少使用。 -- `ldc2_w` 加载`long` 或 `double`类型常量 - -但是`ldc`对于一些小数字类型的性能稍差(但也可以用),于是为了性能有一些专门的指令 -- `iconst_`如果是0-5可以优化性能 -- `iconst_m1`同上专门针对-1的load -- `bipush`针对byte范围的int值的load -- `sipush`针对short范围的int值的load -- 上面只是`int`的其他类型也有专门的指令,这里不再列出。 - -### 1.2.2 store -上面`iload_1`是把本地变量1加载到栈顶,但是一开始没有存储本地变量1呢?所以是会先有一个存储的过程,这就是`store`指令了。 -- `istore_{y}`把栈顶的`int`或`byte`或`char`或`boolean`或`short`类型消耗掉,存到本地变量y,y是数字。 -- `lstore_{y}`把栈顶的`long`消耗,存到本地变量y。!!注意long占用两个栈帧,消耗掉两个栈顶的位置。 -- `fstore_{y}`把栈顶的`float`消耗,存到本地变量y。 -- `dstore_{y}`把栈顶的`double`消耗,存到本地变量y。!!注意double占用两个栈帧,消耗掉两个栈顶的位置。 -- `astore_{y}`把栈顶的对象地址消耗,存到本地变量y。 - -### 1.2.3 return -`return`之后需要保证栈是空的,不然编译会验证不通过。 -- `return`等于代码return,不消耗栈顶 -- `ireturn`消耗栈顶一帧,返回一个`int`或`byte`或`char`或`boolean`或`short`类型 -- `freturn`消耗栈顶一帧返回一个float -- `lreturn`消耗栈顶2帧返回一个long -- `dreturn`消耗栈顶2帧返回一个double -- `areturn`消耗栈顶一帧返回一个地址,即返回一个对象类型的内存地址 - -注意:`return`不一定是代码结束的地方,可能有判断分支有多个`return`语句,而且还有可能是`athrow`抛出异常。 - -### 1.2.4 pop/dup/new -如果一个栈上的操作数,想要直接消耗掉,则直接用`pop`指令消耗一个栈帧,比如运行了一个函数操作后,直接忽略函数的返回值就可以`pop`消耗掉,如果返回值是`long/double`可以`pop`两次,或者`pop2`指令消耗。 - -如果想要复制一份操作数栈顶的数,即栈顶连续两个相同操作数则使用`dup` `dup2`这样的指令,这经常用于`new`一个对象。 -```java -Object obj = new Object(); -``` -对应字节码,如下`new`指令作用是,创建一个对象会在堆上分配内存,并将内存的地址放到操作数栈上;注意这里有个`dup`把地址复制了一份,这是`new`对象的一个固定操作,因为`invokespecial #1 : ()V>`这个构造方法与普通非静态方法一样,会消耗掉一个操作数作为`this`。所以需要提前把地址备份一下,不然`new`完地址就丢了,下面会说`invoke`相关指令。 -``` -new #4 -dup -invokespecial #1 : ()V> -``` -![img](https://i.imgur.com/0bqdThZ.png) - -### 1.2.5 invoke -`invoke`是函数调用的指令,他主要有5种, -- `invokevirtual`普通的可访问的方法,需要依次把`对象`,`参数从左到右`放到栈顶。 -- `invokestatic`静态方法,需要依次把`参数从左到右`放到栈顶。 -- `invokespecial`特殊方法,构造方法,私有方法,父类中的方法,接口的default实现等,根据情况参考上面的操作数顺序。 -- `invokeinterface`接口方法,栈顶操作数顺序参考上面。 -- `invokedynamic`动态方法,一般是lambda表达式,栈顶操作数顺序参考上面。 - -### 1.2.6 指令小结 -上面我们对基本的指令有了一些了解,虽然还有很多指令没有讲到,但是有了这些知识,已经可以看懂字节码的逻辑了。感兴趣的话,最好自行去写一写,然后看下以下情况的字节码: -- 基本的加减乘除位运算等,字节码 -- `if` `for`判断与循环,分支逻辑的字节码 -- `try-catch` `athrow`抛出异常时候的字节码 - -# 2 asm的基础概念 -`asm`库的介绍,我有点不知道从何说起,好像好多知识都是关联的,找不到一个入口点。那干脆我们就从基础的一些概念讲起,在介绍概念的过程中,去拓展这些概念的使用场景,并举一些代码示例,这样更容易入门。那么完整的看完这第二章,我想应该对`asm`就有了大概得了解,是什么,能做什么,擅长做什么,起码这些就会清楚了。而真正落到更细的场景下的扩展,我们会在第三章实战。 - -首先就是`visitor`的概念,asm使用这个模式将对一个类的操作进行了拆解。 +在项目中引入,一起过一下各个实例。 ```xml @@ -281,17 +49,29 @@ public class MyRunnable implements Runnable { } ``` -## 2.1 ClassVisitor -根visitor就是`ClassVisitor`,来对整个类的内容进行遍历操作,下图是`visit`的顺序,这些visit函数都是在遍历过程中的`hook`钩子函数,比如刚开始遍历的时候就会触发钩子函数`visit(int,int,String,String,String,String[])`,这里的参数就是类的一些信息,我们以`visit` `visitField` `visitMethod`和`visitEnd`为例,使用这四个hook在特定时间点打印一行日志。 +# 1 asm的基础概念 +`asm`有两个主要的库`asm-core`或者就叫`asm`库,这个是基础的能力。`asm-tree`基于`core`,更加结构化和面向对象。 + +`asm`整体设计基于`visitor`模式,就是扫描(visit)字节码的过程中,不同的阶段和不同的操作都会调用一些`hook`函数,类似`react`生命周期函数,这些函数默认是空操作,我们可以利用这些`hook`,执行一些逻辑,最终完成我们想要的效果。 + +`ClassReader`是读取字节码的类,使用这个类读取了字节码才能进行后续操作,他可以接收`类名`也可以接收`byte[]`作为参数。 + +## 1.1 ClassVisitor +一般而言,通过`ClassReader`读取一个类的字节码,然后需要用一个`ClassVisitor`来遍历字节码。下图是`visit`的顺序,这些visit函数都是在遍历过程中的`hook`钩子函数,比如刚开始遍历的时候就会触发钩子函数`visit(int,int,String,String,String,String[])`,这里的参数就是类的一些信息, ![img](https://i.imgur.com/SudwOP5.png) +遍历过程中返回`FieldVisitor` `MethodVisitor`等的,会用返回值进行递归遍历,整个流程如下: + +![img](https://i.imgur.com/qNbQskt.png) + + +我们以`visit` `visitField` `visitMethod`和`visitEnd`为例,使用这四个hook在特定时间点打印一行日志。 ```java -ClassReader cr = new ClassReader("com.example.demo.MyRunnable"); +ClassReader cr = new ClassReader("com.example.demo.MyRunnable"); //使用ClassReader读取字节码 cr.accept(new ClassVisitor(ASM9) { @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { -// 33 = Opcodes.ACC_PUBLIC |Opcodes.ACC_SUPER,代表public并且非接口非抽象类 System.out.printf("Now visit class start, classFileVersion: %s, access:%s, className: %s, signature:%s, superClass:%s, interfaces:%s%n", version, access, name, signature, superName, Arrays.toString(interfaces)); } @@ -313,12 +93,9 @@ cr.accept(new ClassVisitor(ASM9) { } }, 0); ``` -![img](https://i.imgur.com/acg3m4P.png) - -对于简单的位置的钩子,返回值是`void`,就是通知一个简单的信息,而对于复杂位置的钩子,比如`visitMethod`,这里的入参是函数的基础信息,想要在函数代码中进行每行指令的解析,就需要对每一种类型的指令设置钩子,比如每一个`ldc`指令设置一个钩子,这样把这些钩子都放到`ClassVisitor`中,结构就太乱了。所以`visitMethod`方法的返回值是`MethodVisitor`。由这个新的`visitor`来定义方法内部的一些钩子函数,这样实现递归遍历。即下图中其他`xxxVisitor`作为返回值的方法,都是会拿到返回值后,再用返回值进行`visitor`递归遍历。因为`field`和`method`是最重要的,其他的都类似,所以这里只对这俩展开。 - -## 2.2 FieldVisitor -那接下来介绍`FieldVisitor`,他提供的钩子函数如下 +![img](https://i.imgur.com/1q715wO.png) +## 1.2 FieldVisitor +`FieldVisitor`他提供的钩子函数如下 ![img](https://i.imgur.com/Mhnr8HE.png) @@ -357,12 +134,12 @@ Now visit method, access:1, name:a, descriptor:()I, signature:null, exceptions:n Now visit method, access:8, name:, descriptor:()V, signature:null, exceptions:null Now visit class finished ``` -## 2.3 MethodVisitor +## 1.3 MethodVisitor 最后我们来看一下`MethodVisitor`也是最重要的一个`visitor`,这个`visitor`中的`hook`非常多,大体可以拆成参数元数据相关,和代码相关的两部分,如下: ![img](https://i.imgur.com/yA4Zpfi.png) -钩子很多,我们选取几个有代表性的,代码如下: +使用如下代码: ```java @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { @@ -429,8 +206,16 @@ Now visit method, access:8, name:, descriptor:()V, signature:null, excep - Method:, visit finished ... ``` -## 2.4 ClassWriter -上面的`visitor`都是用`ClassReader`读取字节码,然后`accept`一个`ClassVisitor`进行字节码的读取的,说到底就是只读的操作。`ClassWriter`是一个实现了`ClassVisitor`的内置的类,这个`visitor`有些特殊,他会在`visit`的过程中,把所有visit的内容记录到内存中,最后通过`toByteArray`方法,可以把所以记录下来的信息转换成一个类的字节码。一个简单的例子,是直接用`cw`作为`visitor`就会记录下所有的字节码到内存,然后`write`到文件中,内容与原`class`文件是一致的。 +## 1.4 Delegate +上面介绍了三种主要的`Visitor`会发现,他们除了有很多`visitXXX`的hook函数,还有个公共特点都有`getDelegate`函数,是返回另一个和自己同样类型的`Visitor`,这就是委托的设计理念。 + +当我定义`hook`函数的时候,默认的实现如下,`cv`就是委托者,即每个`VisitorA`里会嵌套另一个`VisitorB`。如果没有重写hook方法,默认的实现是会交给这个B,如果也没有B,就是啥也不干了。这个B,一般都是通过Visitor的构造方法的第二个参数传入的。 +这种设计就是用组合的方式实现继承的效果了。 + +![img](https://i.imgur.com/Wz5CaFQ.png) + +# 2 ClassWriter +`ClassWriter`是一个继承`ClassVisitor`的内置的类,这个`visitor`有些特殊,他会在`visit`的过程中,把所有visit的内容记录到内存中,最后通过`toByteArray`方法,可以把所以记录下来的信息转换成一个类的字节码。一个简单的例子,是直接用`cw`作为`visitor`就会记录下所有的字节码到内存,然后`write`到文件中,内容与原`class`文件是一致的。 ```java ClassReader cr = new ClassReader("com.example.demo.MyRunnable"); ClassWriter cw = new ClassWriter(0); @@ -442,11 +227,11 @@ o.close(); ``` ![img](https://i.imgur.com/wgReKwV.png) -这是简单的复刻所有类的细节,因为类的所有信息都会被`visitxx`给捕捉,而所有的`visit`在`cw`中都是记录下来,最后转成`byte[]`字节码。那我们就可以基于这个稍微做一些字节码改造了,比如下面几种: -### 2.4.1 删除方法 -下面的各种修改我们都需要new一个`ClassVisitor`构造参数第二个把`cw`塞进来,原因是`asm`的设计方式中,第二个构造参数是`delegator`也就是委托者,他的作用是如果我们没有在当前实现中定义`visitxxx`方法的实现,就会委托给`cw`。 +这是简单的复刻所有类的细节,因为类的所有信息都会被`visitxx`给捕捉,而所有的`visit`在`cw`中都是记录下来,最后转成`byte[]`字节码。那我们就可以基于这个稍微做一些字节码改造了。 +## 2.1 删除方法 +下面的各种修改我们都需要new一个`ClassVisitor`构造参数第二个把`cw(ClassWriter)`塞进来,上面提到过第二个构造参数是兜底用的,如果没有定义`visitXX`的实现,就会调用这个参数对应的方法,我们把`cw`塞进来,就可以实现我们没有操作的部分,都直接被`cw`记录下来。 -例如下面删除方法`a`,我们只需要重写`visitMethod`,判断是`a`的返回空,其他所有情况都会按照`super.XXX`,而`super`中的实现都是调用委托者对应的方法,而委托者就是`cw`会把所有的内容记录下来,最后生成`byte[]`。 +下面删除方法`a`,我们只需要重写`visitMethod`,判断是`a`的返回空,其他所有情况不需要管,都会默认由`cw`兜底记录下来,最后生成字节码。 ```java ClassReader cr = new ClassReader("com.example.demo.MyRunnable"); ClassWriter cw = new ClassWriter(0); @@ -470,8 +255,8 @@ o.close(); ![img](https://i.imgur.com/cxYSezF.png) 从删除方法的这个例子中,我们其实学到的不只是删除方法,字段、注解、设置是方法中的某一行指令,都可以根据这个方式进行剔除。 -### 2.4.2 新增方法 -新增方法,可以放到`visitEnd`中去实现,因为从`2.1`的图中,会发现`visitMethod`之后是`visitEnd`,所以可以在visitEnd的实现中,先执行一次额外的`cw.visitMethod`,这样就保持了原来的`visit`顺序,并且成功增加了一个方法,例如我们新增一个`public void b() {System.out.println("b");}`,代码如下 +## 2.2 新增方法 +新增方法,可以放到`visitEnd`中去实现,因为从前面流程图中,会发现`visitMethod`之后是`visitEnd`,所以可以在`visitEnd`的实现中,执行一次额外的`cw.visitMethod`,这样就保持了原来的`visit`顺序,并且成功增加了一个方法,例如我们新增一个`public void b() {System.out.println("b");}`,代码如下 ```java ClassReader cr = new ClassReader("com.example.demo.MyRunnable"); @@ -491,19 +276,24 @@ cr.accept(new ClassVisitor(ASM9, cw) { // 然后是Maxs和End mv.visitMaxs(2, 1); mv.visitEnd(); + super.visitEnd(); } }, 0); ``` -这段代码多出了一些新的知识,一个是`ClassWriter.visitMethod`方法会在内存中创建一个方法,并且返回一个`MethodWriter`类型,后者也是一个`MethodVisitor`的实现,我们用这个新返回的`mv`运行`visitXX`的时候,就会在`cw`的内存中进行写方法体的操作了;注意这里`mv`返回之后,我们依次运行了`visitCode`和其他一些方法内部的操作。并且在结束之前运行了`mv.visitMaxs(2, 0)`和`mv.visitEnd()`。在这之后还需要运行`super.visitEnd()`因为这里是`ClassVisitor`的`visitEnd`方法,不能给人直接删了。 +我们来理解一下这段代码,在整个类结束之前,插入了这样一段代码,第一步`MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "b", "()V", null, null)`,`visitMethod`本来是由遍历过程中的特定时机下被动触发的,这里我们直接主动触发了。这是因为我们要新增这样一个方法,而触发`visitXXX`会被`cw`记录下来用于最终生成字节码。下面的`visitCode`等是一样的,本来是被动触发的,因为要主动write,所以改为了主动调用。 -解释下为什么是`visitMaxs(2,1)`,2是最大的操作数栈,因为最多的时候栈上有`System.out`和`"b"`两个操作数,所以这里设置2即可,当然设置3不报错,但是设置1就会校验不通过,第二个参数1代表最大局部变量数,这里虽然没有设置过局部变量但是`this` `args`都会占用,虽然也没有入参但是this占1个。 +从`mv.visitCode`到`mv.visitEnd`是函数的代码部分,他跟上一篇讲的字节码指令完全对应,看过上一篇的很容易就理解这里的作用了。解释下为什么是`visitMaxs(2,1)`。 +- 第一个参数是最大的操作数栈,因为最多的时候栈上有`System.out`和`"b"`两个操作数,所以这里设置2即可,当然设置3不报错,但是设置1就会校验不通过。 +- 第二个参数1代表最大局部变量数,这里虽然没有设置过局部变量但是`this` `args`都会占用,虽然也没有入参但是this占1个。 + +最后的`super.visitEnd`是调用原来的,遍历结束,到此就结束了 `visitMaxs`和`visitFrame`(上面没有用到Frame)是非常容易算错的,例如我们将`visitMaxs`的值故意改错或删除,来看一下效果,会发现生成字节码是成功的,但是真正在使用这个类或这个方法的时候,就会报错。 增加以下代码来实际加载并使用这个类。 ```java - byte[] codes = cw.toByteArray(); +byte[] codes = cw.toByteArray(); ClassLoader cl = VisitorTest.class.getClassLoader(); Method define = ClassLoader.class.getDeclaredMethod("defineClass", @@ -515,72 +305,116 @@ System.out.println(Arrays.toString(c.getDeclaredMethods())); ``` ![img](https://i.imgur.com/CXAqIsq.gif) -因而如果不是极限的追求性能,可以交给`ASM`帮我们自动计算`maxs`和`frame`,如下图,此时`maxs`传0也不会报错,会自动计算,但是不能删除这行代码。 +因而如果不是极限的追求性能,可以交给`ASM`帮我们自动计算`maxs`和`frame`,如下图,此时`maxs`传0也不会报错,会自动计算,**但是不能删除这行代码**。 ```java ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); ``` ![img](https://i.imgur.com/NPzewgv.png) -我们接下来的代码都会使用`ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS`来避免一些验证性的问题。 -### 2.4.3 修改(增强)方法 -举一个常见的功能,在方法前后打印时间,然后相减计算方法的耗时。那就需要对方法进行修改,前后都增加一些代码,那这里就需要用到`common`库里的一个类`AdviceAdapter`了,这个类也实现了`MethodVisitor`,代码的注释说了他就是专门为了对方法进行环绕增强的一个内置`MethodVisitor` - -![img](https://i.imgur.com/t9OCOAi.png) +我们接下来的代码都会使用`ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS`这种write方式来避免一些验证性的问题。 +## 2.3 修改(增强)方法 +举一个常见的功能,在方法前后打印时间,然后相减计算方法的耗时。因为要引入新的局部变量,所以不能用默认的`压缩帧(compressed frame)`格式,而需要指定为`展开帧(expaned frame)`格式,前者字节码大小更紧凑,是基于前一帧的变化来维护的diff,后者则是每一个栈帧都是独立的描述自身内容,感兴趣的自行了解,这里只需要知道前者效率更高,但是没法添加局部变量,后者更适合我们去做字节码改动用。 -![img](https://i.imgur.com/q3rH3vD.png) - -这里因为要引入新的局部变量,所以不能用默认的`压缩帧(compressed frame)`格式,而需要指定为`展开帧(expaned frame)`格式,前者字节码大小更紧凑,是基于前一帧的变化来维护的diff,后者则是每一个栈帧都是独立的描述自身内容,感兴趣的自行了解,这里只需要知道前者效率更高,但是没法添加局部变量,后者更适合我们去做字节码改动用。 - -```java {30} -ClassReader cr = new ClassReader("com.example.demo.MyRunnable"); -ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); +我们接下来的代码也都会用这种read方式来避免一些验证性的问题,注意与上面write的位置是不一样的。 +```java +cr.accept(xxx, ClassReader.EXPAND_FRAMES); +``` +前后打印时间,然后相减得到函数运行时间。这是当前的思路,回顾`method`的浏览顺序,我们可以在`visitCode`的时候,植入计算当前时间存到变量`start`中。然后在`visitMax`之前植入再次计算当前时间,并减去`start`得到运行时长,最后打印。 +下面的`visitMethodInsn`=`this.visitMethodInsn`=`mv.visitMethodInsn`=`originalMV.visitMethodInsn`,最后会委托给`originalMV`这个`MethodWriter`进行写入的。 +```java cr.accept(new ClassVisitor(ASM9, cw) { @Override - public MethodVisitor visitMethod(int access, String name, - String descriptor, String signature, String[] exceptions) { - if (name.equals("a")) { - MethodVisitor mv = cw.visitMethod(access, name, descriptor, signature, exceptions); - return new AdviceAdapter(ASM9, mv, access, name, descriptor) { - private int startTimeVarIndex; - // 函数进入的时候,添加一行 long startTime = System.currentTimeMillis(); + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + MethodVisitor originalMV = super.visitMethod(access, name, descriptor, signature, exceptions); + if (name.equals("a")) { // 简单判断 + return new MethodVisitor(ASM9, originalMV) { + // 变量序号,暂定100,不能和已有的冲突 + int startTimeVarIndex = 100; @Override - protected void onMethodEnter() { - mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); - startTimeVarIndex = newLocal(Type.LONG_TYPE); - mv.visitVarInsn(LSTORE, startTimeVarIndex); + public void visitCode() { + super.visitCode(); + // 开始之后插入一段代码记录局部变量 + visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); + visitVarInsn(LSTORE, startTimeVarIndex); } - - // 函数退出的时候,添加一行 System.out.println(System.currentTimeMillis() - startTime); @Override - protected void onMethodExit(int opcode) { - mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); - mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); - mv.visitVarInsn(LLOAD, startTimeVarIndex); - mv.visitInsn(LSUB); - mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false); + public void visitInsn(int opcode) { + if (opcode == IRETURN) { + visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); + // return之前插入一段代码计算耗时 + visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); + visitVarInsn(LLOAD, startTimeVarIndex); + visitInsn(LSUB); + visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false); + } + super.visitInsn(opcode); } }; + } else { + return originalMV; } - return super.visitMethod(access, name, descriptor, signature, exceptions); } }, ClassReader.EXPAND_FRAMES); +``` +这里代码结构很清晰,就是在进入的时候,和返回之前插入相应代码,得到的代码如下 +```java + public int a() { + long var100 = System.currentTimeMillis(); + if (Math.random() > 0.5) { + System.out.println(System.currentTimeMillis() - var100); + return 1; + } else if (Math.random() == 0.5) { + System.out.println(System.currentTimeMillis() - var100); + return 0; + } else { + System.out.println(System.currentTimeMillis() - var100); + return -1; + } + } +``` +这里需要解释几点: +- 1 `start`变量的下标是随便编的`100`,如果函数中已经有100,会出现问题,所以这个实现不好,我们下面会说别的。 +- 2 为什么不在`vistMaxs`插入代码而要在`visitInsn`中判断是否是return,因为`visitMaxs`插入的代码已经是`return`之后,就走不到了。 +## 2.4 借助Adapter +上面例子中,变量的下标100是有隐患的,为了解决这种代码增强的场景,在`asm-common`包中提供了几个`Adapter`尤其是`AdviceAdapter`可以简化我们的代码,帮我们处理一些细节,`AdviceAdapter`有多层继承关系,我们只需要知道他继承了`MethodVisitor`,如下。并且提供了额外的方法前和方法后的两个`hook`,其实就和前面我们的代码实现的能力一样。但`AdviceAdapter`中提供了非常多的便利的方法。 -byte[] codes = cw.toByteArray(); -ClassLoader cl = VisitorTest.class.getClassLoader(); -Method define = ClassLoader.class.getDeclaredMethod("defineClass", - byte[].class, int.class, int.class); -define.setAccessible(true); -Class c = (Class) define.invoke(cl, codes, 0, codes.length); +![img](https://i.imgur.com/t9OCOAi.png) -// 调用方法,看是否打印了耗时 -c.getDeclaredMethod("a").invoke(c.newInstance()); -``` -打印 +![img](https://i.imgur.com/q3rH3vD.png) -![img](https://i.imgur.com/Baz8nmF.png) +实现相同的打印函数耗时的功能,就改为这样↓,可以关注一下不同的地方。 +```java {5} +public MethodVisitor visitMethod(int access, String name, + String descriptor, String signature, String[] exceptions) { + if (name.equals("a")) { + MethodVisitor mv = cw.visitMethod(access, name, descriptor, signature, exceptions); + return new AdviceAdapter(ASM9, mv, access, name, descriptor) { + private int startTimeVarIndex; + // 函数进入的时候,添加一行 long startTime = System.currentTimeMillis(); + @Override + protected void onMethodEnter() { + visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); + startTimeVarIndex = newLocal(Type.LONG_TYPE); + storeLocal(startTimeVarIndex); // 等价于 mv.visitVarInsn(LSTORE, startTimeVarIndex); + } + // 函数退出的时候,添加一行 System.out.println(System.currentTimeMillis() - startTime); + @Override + protected void onMethodExit(int opcode) { + visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); + visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); + loadLocal(startTimeVarIndex); + visitInsn(LSUB); + visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V", false); + } + }; + } + return super.visitMethod(access, name, descriptor, signature, exceptions); +} +``` +这段代码与之前效果完全一样,但是我们注意到`startTimeVarIndex`不需要猜一个值,而是用`newLocal`内置方法,他会自动计算当前的变量的个数给一个新的值。此外对于一些变量加载和存储也提供了更简洁的写法`storeLocal/loadLocal`,当然了你也可以用原来的写法,毕竟这是继承`MethodVisitor`类的。我们也不需要像原来一样运行`super.end()`这个很容易漏掉的代码,这里可以省略了。而且也不需要判断`IRETURN`了。 -一定要注意的是`methodEnter`只有一个进入的点,但是`methodExit`则会有多个退出的点,参数中`opcode`也可能有多种取值,比如正常的`return`,比如条件分支可能就有多个`return`。而且还有可能不是`return`而是抛出异常,他们都对应了不同的`opcode`。上面代码修改后,a方法的字节码反编译得到: +编译后结果如下`var100`成了`var1`,因为`newLocal`发现只有下标可以直接用1. ```java public int a() { long var1 = System.currentTimeMillis(); @@ -596,7 +430,7 @@ public int a() { } } ``` -可以看出不同的条件分支的`return`之前都会被插入这段代码,其实就是因为`methodExit`这个hook会在多个地方都会进入,他其实是每次调用`ireturn`或者`athrow`之前都会调用的。 +`onMethodExit`代表方法退出,一般有两种退出形式`return/athrow`,多个退出的出口,如上代码有多个判断分支都可以退出。可以看出不同的条件分支的`return`之前都会被插入这段代码,他其实是每次调用`ireturn`或者`athrow`之前都会调用的。 我们修改一下我们的`a`来抛出一个异常。 ```java {7} @@ -628,7 +462,7 @@ public int a() { ``` `athrow`就是对应的代码中显式的`throw`关键字,如果是某个子方法内部抛出运行时异常,则不会认为是`methodExit`,虽然好像也是一种方法退出的形式了,但是他是运行时的退出,字节码中这不是一种`methodExit`形式,这一点要注意。 -### 2.4.4 从0创建一个类 +## 2.5 从0创建一个类 先安装如下两个插件,两个功能是类似的,选一个就行 ![img](https://i.imgur.com/iJyboeH.png) @@ -658,8 +492,8 @@ public class VisitorTest implements Opcodes { } } ``` -## 2.5 asm-tree -上面的`visitor`是`asm`包或者叫`asm-core`包提供的核心能力,他为一切的解析重写提供了最基础的支持。`asm-tree`包也在上面`asm-common`中间接引用了,他基于核心 `asm` 包,提供了一种结构化的、基于树的字节码表示方法。`core`中是以`visitor`模式作为主心骨,对于拆分的较细,`tree`则是以`Node`为组织方式。 +# 3 asm-tree +上面的`visitor`是`asm`包或者叫`asm-core`包提供的核心能力,他为一切的解析重写提供了最基础的支持。`asm-tree`包也在上面`asm-common`中接引用了,他基于核心 `asm` 包,提供了一种结构化的、基于树的字节码表示方法。`core`中是以`visitor`模式作为主心骨,对于拆分的较细,`tree`则是以`Node`为组织方式,更加面向对象。 以一个具体的场景为例,之前的a方法,我们想要修改代码,把第一个`Math.random()`的值存到一个局部变量中,然后第二次直接用变量中的值 ```java @@ -731,17 +565,18 @@ cr.accept(new ClassVisitor(ASM9, cw) { ![img](https://i.imgur.com/5oARXHU.png) -但是这个写法的抽象程度较高,我们只是在`hook`中作局部的处理,没有一个全局的列表,比如没有提供所有的`insn`的列表,如果把所有的代码指令都抽象成一个对象,整个method就是一个指令的`List`,那我们只需要找到要修改的指令,在他的前后进行插入和修改即可。这就是`tree`给我们提供的`Node`,下面是实现相同功能的代码。 +如果把所有的代码指令都抽象成一个对象,整个method就是一个指令的`List`,那我们只需要找到要修改的指令,在他的前后进行插入和修改即可。这就是`tree`给我们提供的`Node`,下面是实现相同功能的代码,下面代码中我们直接使用`ClassNode`这个`ClassVisitor`来获取类中的所有信息,并且结构化,他与`ClassWriter`有点像,都是遍历所有信息记录下来,只不过记录的是结构化信息,而`ClassWriter`记录下来是为了之后转`byte[]`。 + +在`ClassNode`中包含了这个类的所有信息,基础信息、列信息、方法信息等等,方法信息是存到`MethodNode`中,而`MethodNode`又有`instructions`属性是一个链表存储了`AbstractInsnNode`,即所有的指令信息,可以看出层层封装,确实是包揽了整个类的所有信息。 + +![img](https://i.imgur.com/tJTXbMK.png) + ```java // 用tree提供的MethodNode,可以记录整个method的信息,面向对象的处理问题 ClassNode classNode = new ClassNode(); cr.accept(classNode, ClassReader.EXPAND_FRAMES); MethodNode methodNode = classNode.methods.stream().filter(it -> it.name.equals("a")).findAny().get(); -// 这两行是新增变量,同时加到变量表,但是不建议,因为变量表只用来调试,加入有可能有重名风险 -LocalVariableNode newLocalVar = new LocalVariableNode("d", "D", null, new LabelNode(), new LabelNode(), methodNode.maxLocals); -methodNode.localVariables.add(newLocalVar); - InsnList newInsnList = new InsnList(); boolean first = true; for (AbstractInsnNode instruction : methodNode.instructions) { @@ -754,28 +589,99 @@ for (AbstractInsnNode instruction : methodNode.instructions) { first = false; newInsnList.add(instruction); newInsnList.add(new InsnNode(DUP2)); - // 如果没有加入局部变量表的话,这里index直接取methodNode.maxLocals,加入多个依次+1 +2.... - newInsnList.add(new VarInsnNode(DSTORE, newLocalVar.index)); + // methodNode.maxLocals是当前局部变量数量,那么下一个的下标就是他 + newInsnList.add(new VarInsnNode(DSTORE, methodNode.maxLocals)); } else { // 之后运行的话,就读取局部变量的值 - newInsnList.add(new VarInsnNode(DLOAD, newLocalVar.index)); + newInsnList.add(new VarInsnNode(DLOAD, methodNode.maxLocals)); } continue; } } newInsnList.add(instruction); } -// 这两行非必须,因为InsnNode是链表结构,只要第一个节点塞到newInsnList了,后面的都带过去了。 -// random方法不可能是第一个节点,所以可以省略。否则记得加上这两行。 -// methodNode.instructions.clear(); -// methodNode.instructions.add(newInsnList); +methodNode.instructions.clear(); +methodNode.instructions.add(newInsnList); ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); classNode.accept(cw); ``` -我们会发现`tree`提供了`ClassNode`类型,这也是一个`ClassVisitor`,他的作用和`ClassWriter`有点像,都是将所有的操作记录下来,只不过他的记录形式是一种面向对象的,树状的记录形式。在`ClassNode`中以对象的形式记录了当前类的各种信息,所以一般而言`asm-tree`的使用姿势,都是直接用一个空的`ClassNode`去遍历一个类(上面代码前三行),然后这个node中就有了当前类的所有信息;接下来对他进行一些处理;最后将修改完成的`classNode.accept(cw)`就写入`cw`了。 +这种编程方式,逻辑和代码量差不多,但是面向对象的方式,会更容易理解,这里可以动手自己用`tree`的写法,重写一下前面打印耗时的函数。 +## 3.1 面向对象解决复杂场景 +这里介绍一个高阶场景,函数内连。 +### 3.1.1 简单内连 +例如`ByteBuddy`,允许我们使用注解`@Advice.OnMethodEnter`将一段静态函数的内容插入到函数运行之前。这里函数必须是静态的,不能有`this`的上下文依赖,是一个无状态函数。这个函数的代码会被内连到原函数的开头。 +```java +public static class ExampleAdvice { + @Advice.OnMethodEnter + public static void onEnter() { + System.out.println("Enter"); + } +} +``` +内连的意思是原函数`target()`第一行是`System.out.println("Enter");`而不是`ExampleAdvice.onEnter`,即把代码插入进去了。这种静态的无入参无返回值的函数内敛,是最简单的,思考一下,使用`ASM`只需要第一步把`onEnter`函数的字节码中`RETURN`指令删掉,第二步处理好局部变量下标,然后指令塞到函数开头即可。这里注意与之前直接插入指令不同的是,这里插入的是另一个函数的全部指令。 +```java +// 准备这样一个要插入的代码 +public class Demo { + public static void enter() { + int a = 100; + System.out.println("method inter a = " + a); + } +} +``` +接下来我们把上面这段代码插入到`a`方法的开始,分别用`tree`和`core`的写法: +```java +// tree写法 + +// 先封装一个获取ClassNode的函数 +public static ClassNode getMethod(String className) throws IOException { + ClassReader cr = new ClassReader(className); + ClassNode classNode = new ClassNode(); + cr.accept(classNode, ClassReader.EXPAND_FRAMES); + return classNode; +} + +public static void main(String[] args) { + ClassNode demo = getMethod("com.example.demo.Demo"); + ClassNode myRunnable = getMethod("com.example.demo.MyRunnable"); + + MethodNode enterMethod = demo.methods.stream().filter(m -> m.name.equals("enter")).findFirst().get(); + MethodNode aMethod = myRunnable.methods.stream().filter(m -> m.name.equals("a")).findFirst().get(); + + InsnList list = new InsnList(); + LabelNode insertFinish = new LabelNode(); + for (AbstractInsnNode instruction : enterMethod.instructions) { + // 细节1:行号是demo函数的对目标函数没有用 + if (instruction instanceof LineNumberNode) continue; + // 细节2:return会导致目标函数提前返回,需要删掉换成GOTO指令 + if (instruction.getOpcode()==RETURN) { + list.add(new JumpInsnNode(GOTO, insertFinish)); + continue; + } + // 细节3:局部变量的下标在demo和a中都是从0开始就冲突了,demo中改为从a.maxLocals开始 + if (instruction instanceof VarInsnNode) { + VarInsnNode varInsnNode = (VarInsnNode) instruction; + varInsnNode.var += aMethod.maxLocals; + } + // 细节4:除了VarInsnNode还有个指令也会访问局部变量下标 + if (instruction instanceof IincInsnNode) { + IincInsnNode insnNode = (IincInsnNode) instruction; + insnNode.var += aMethod.maxLocals; + } + list.add(instruction); + } + // 细节5:插入结束的label + list.add(insertFinish); + // 细节6:把这些指令放到原来的a方法指令之前 + aMethod.instructions.insertBefore(aMethod.instructions.getFirst(), list); + // 细节7:tryCatch代码块不是指令,需要专门添加过来 + aMethod.tryCatchBlocks.addAll(enterMethod.tryCatchBlocks); + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + myRunnable.accept(cw); +} +``` -![img](https://i.imgur.com/tJTXbMK.png) +# 下面还没整理好----- # 3 面对更复杂的场景 ## 3.1 完善函数监控 上面有个打印函数耗时的例子,那么现在我们来完善这个功能,除了打印耗时之外,还需要 diff --git a/app/prism-dracula.css b/app/prism-dracula.css index 75d46cf0..77fc9a58 100644 --- a/app/prism-dracula.css +++ b/app/prism-dracula.css @@ -170,6 +170,10 @@ div .pre-with-code { max-width: 80vw; } } + +.markdown-body main table { + max-width: calc(65vw - 10px); +} .markdown-body main p img { max-width: calc(65vw - 10px); } @@ -178,6 +182,11 @@ div .pre-with-code { max-width: calc(80vw - 10px); } } +@media (max-width: 999px) { + .markdown-body main table { + max-width: calc(80vw - 10px); + } +} .remark-code-container .remark-code-title { margin-left: 2rem;