实战 JVM 字节码
文章目录
一.开始的契机
老板说:“app中的登录流程、注册转化、购买转化、分享转化、banner位的数据拿过来我看下,分析下用户的操作习惯,界面的停停留时间,用户的手机类型…"。
当我们需要了解页面加载性能时,可以通过埋点的方式记录页面阶段耗时、网络耗时、数据库加载耗时以及其他耗时点,配合数据分析平台,能直观地了解到页面的各种情况。
之前都是使用第三的sdk和服务,比如友盟、shareSDK、神策等第三方的服务来进行数据的采集。这样的话数据都是上传到第三方服务器上,所以数据得不到保密。
现在集团要求使用自己的服务,把这些数据保存到自己的服务器,然后对数据进行分析。这样既可以对数据整合,也可以保证数据的安全性。
任务:编写 Android 的全埋点库。实现集团的app数据采集。
二.方案的对比
1、手动在onClick等方法下粗鲁的代码埋点,嗯,是最最原始的一种方式了,没有比这个代码更加简洁的了,按需来加,缺点是,麻烦,得一个个加,漏掉没加,就只能等下一次了。
现在的情况,已经存在 app ,并且业务量很多,界面和流程都很复杂,如果要在所有需要代码插入的地方都进行修改,那么工程量是很大的。如果有很多 app ,工作量加起来更多。有没有一种方案,不需要对原来代码和业务耦合,就完全可以解决问题呢?比如我写玩一个 sdk,别人不需要任何代码的编写,只需要依赖一下这个库就可以?
2、AOP方式
AOP是什么呢,用简单的话来将,他就是面向切面编程的意思,可是面向切面编程又是什么呢,在具体点就是,你能够将方法看成一个面,方法执行之前,执行过程中,执行之后就是一个一个的点,所谓的切点,就是这么来的。
因此,自动埋点就对于aop来说就小菜一碟啦,不仅如此,因为可以在方法前后打桩,所以,对方法做耗时统计也是小菜一碟,AOP通常需要通过配置注解来实现
AOP方式的特点是,埋点简单,一个注解就可以搞定,缺点还是不能自动,还是要你写代码,一个注解别看他简单,这也是代码啊。
三.AOP的几种实现方式
- Java 中的动态代理,运行时动态创建 Proxy 类实例
- APT,注解处理器,编译时生成 .java 代码
- Javassist for Android:一个移植到Android平台的非常知名的操纵字节码的java库,对 class 字节码进行修改
- ASM操作字节码
1. 动态代理
|
|
代理对象的生成实际上是在运行时利用反射获取构造函数,通过加载构造函数在内存中生成的,其中生成的对象持有调用处理器InvocationHandler,最后会调用h.invoke()方法
这种方案,需要编写代码,代码的执行效率也很差,是在代码执行的过成功,才会生成具体的 class文件,然后加载,所以性能上是比较弱的。
2.编译时注解APT实现
全名Annotation Processing Tool,注解处理器。对源代码文件进行检测找出其中的Annotation,使用 Annotation 进行额外的处理。 APT在处理 Annotation 时可以根据源文件中的 Annotation 生成额外的源文件和其它的文件(文件具体内容由Annotation处理器的编写者决定),APT还会编译生成的源文件和原来的源文件,将它们一起生成class文件。
总结一句话,就是在编译时候,根据注解生成对应需要的文件,这样在app运行的时候就不会导致性能损耗。
注解处理器(AbstractProcess)+ 代码处理(javaPoet)+ 注册处理器(AutoService)
但是这样方式只能生成.java 文件,并不能直接修改.java 或.class 文件的代码。
|
|
3.Javassist 实现
Javassist 可以直接操作字节码,从而实现代码注入,所以使用 Javassist 的时机就是在构建工具 Gradle 将源文件编译成 .class 文件之后,将 .class 打包成 dex 文件之前。执行效率相对较差,但无需掌握字节码指令的知识,简单、快速,对使用者要求较低。
利用Javassist实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。其中最重要的是ClassPool、CtClass、CtMethod、CtField这四个类:
-
CtClass(compile-time class):编译时类信息,它是一个class文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个CtClass对象,用来表示这个类文件。
-
ClassPool:从开发视角来看,ClassPool是一张保存CtClass信息的HashTable,key为类名,value为类名对应的CtClass对象。当我们需要对某个类进行修改时,就是通过pool.getCtClass(“className”)方法从pool中获取到相应的CtClass。
-
CtMethod、CtField:这两个比较好理解,对应的是类中的方法和属性。
|
|
4.ASM 的方式
直接操作字节码指令,执行效率高,但涉及到JVM的操作和指令,要求使用者掌握Java类字节码文件格式及指令,对使用者的要求比较高。
asm方式在粗的方面和aop方式对比的话,有点像,但是可以说更加底层,他能够做道对.class文件的修改,什么意思,就是说,分析.class文件,找到那些需要埋点的方法,比如,onclick,onlongclick等等,让后进行黑科技,一顿操作,加点字节码进去,重新编一个.class文件,替换之前的文件。
这种方式的特点就是,实现起来比较复杂,没那么容易玩的,好处就是,可以实现埋点自动化。
ASM是Java中比较流行的用来读写字节码的类库,用来基于字节码层面对代码进行分析和转换。在读写的过程中可以加入自定义的逻辑以增强或修改原来已编译好的字节码,比如CGLIB用它来实现动态代理。ASM被设计用于在运行时对Java类进行生成和转换,当然也包括离线处理。
ASM短小精悍、且速度很快,从而避免在运行时动态生成字节码或转换时对程序速度的影响,又因为它体积小巧,可以在很多内存受限的环境中使用。
核心API
ASM Core API可以类比解析XML文件中的SAX方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用Core API。在Core API中有以下几个关键类:
- ClassReader:用于读取已经编译好的.class文件。
- ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
- 各种Visitor类:如上所述,CoreAPI根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的Visitor,比如用于访问方法的MethodVisitor、用于访问类变量的FieldVisitor、用于访问注解的AnnotationVisitor等。为了实现AOP,重点要使用的是MethodVisitor。
例子
利用ASM的CoreAPI来增强类。这里不纠结于AOP的专业名词如切片、通知,只实现在方法调用前、后增加逻辑,通俗易懂且方便理解。首先定义需要被增强的Base类:其中只包含一个process()方法,方法内输出一行“process”。增强后,我们期望的是,方法执行前输出“start”,之后输出"end”。
|
|
为了利用ASM实现AOP,需要定义两个类:一个是MyClassVisitor类,用于对字节码的visit以及修改;另一个是Generator类,在这个类中定义ClassReader和ClassWriter,其中的逻辑是,classReader读取字节码,然后交给MyClassVisitor类处理,处理完成后由ClassWriter写字节码并将旧的字节码替换掉。Generator类较简单,我们先看一下它的实现,如下所示,然后重点解释MyClassVisitor类。
|
|
MyClassVisitor继承自ClassVisitor,用于对字节码的观察。它还包含一个内部类MyMethodVisitor,继承自MethodVisitor用于对类内方法的观察,它的整体代码如下:
|
|
这样就可以在想要插入代码的地方插入你想要插入的代码。
四.字节码介绍
1.什么是字节码?
Java之所以可以“一次编译,到处运行”,一是因为JVM针对各种操作系统、平台都进行了定制,二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。因此,也可以看出字节码对于Java生态的重要性。之所以被称之为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中一般是用javac命令编译源代码为字节码文件,一个.java文件从编译到运行的示例如图1所示。
对于开发人员,了解字节码可以更准确、直观地理解Java语言中更深层次的东西,比如通过字节码,可以很直观地看到Volatile关键字如何在字节码上生效。另外,字节码增强技术在Spring AOP、各种ORM框架、热部署中的应用屡见不鲜,深入理解其原理对于我们来说大有裨益。除此之外,由于JVM规范的存在,只要最终可以生成符合规范的字节码就可以在JVM上运行,因此这就给了各种运行在JVM上的语言(如Scala、Groovy、Kotlin)一种契机,可以扩展Java所没有的特性或者实现各种语法糖。理解字节码后再学习这些语言,可以“逆流而上”,从字节码视角看它的设计思路,学习起来也“易如反掌”。
2.字节码结构
.java文件通过javac编译后将得到一个.class文件,比如编写一个简单的ByteCodeDemo类,如下图2的左侧部分:
编译后生成ByteCodeDemo.class文件,打开后是一堆十六进制数,按字节为单位进行分割后展示如图2右侧部分所示。上文提及过,JVM对于字节码是有规范要求的,那么看似杂乱的十六进制符合什么结构呢?JVM规范要求每一个字节码文件都要由十部分按照固定的顺序组成,整体结构如图3所示。接下来我们将一一介绍这十部分:
(1) 魔数(Magic Number)
所有的.class文件的前四个字节都是魔数,魔数的固定值为:0xCAFEBABE。魔数放在文件开头,JVM可以根据文件的开头来判断这个文件是否可能是一个.class文件,如果是,才会继续进行之后的操作。
有趣的是,魔数的固定值是Java之父James Gosling制定的,为CafeBabe(咖啡宝贝),而Java的图标为一杯咖啡。
(2) 版本号
版本号为魔数之后的4个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。上图2中版本号为“00 00 00 34”,次版本号转化为十进制为0,主版本号转化为十进制为52,在Oracle官网中查询序号52对应的主版本号为1.8,所以编译该文件的Java版本号为1.8.0。
(3) 常量池(Constant Pool)
紧接着主版本号之后的字节为常量池入口。常量池中存储两类常量:字面量与符号引用。字面量为代码中声明为Final的常量值,符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。常量池整体上分为两部分:常量池计数器以及常量池数据区,如下图所示。
常量池计数器(constant_pool_count):由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。图2中示例代码的字节码前10个字节如下图5所示,将十六进制的24转化为十进制值为36,排除掉下标“0”,也就是说,这个类文件中共有35个常量。
常量池数据区:数据区是由(constant_pool_count-1)个cp_info结构组成,一个cp_info结构对应一个常量。在字节码中共有14种类型的cp_info(如下图6所示),每种类型的结构都是固定的。
查看JVM反编译后的完整常量池
(4) 访问标志
常量池结束之后的两个字节,描述该Class是类还是接口,以及是否被Public、Abstract、Final等修饰符修饰。JVM规范规定了如下图9的访问标志(Access_Flag)。需要注意的是,JVM并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为Public Final,则对应的访问修饰符的值为ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
(5) 当前类名
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
(6) 父类名称
当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
(7) 接口信息
父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的n个字节是所有接口名称的字符串常量的索引值。
(8) 字段表
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息fields_info。
(9)方法表
字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示
方法的权限修饰符依然可以通过图9的值查询得到,方法名和方法的描述符都是常量池中的索引值,可以通过索引值在常量池中找到。而“方法的属性”这一部分较为复杂,直接借助javap -verbose将其反编译为人可以读懂的信息进行解读,如图13所示。可以看到属性中包括以下三个部分:
-
“Code区”:源代码对应的JVM指令操作码,在进行字节码增强时重点操作的就是“Code区”这一部分。
-
“LineNumberTable”:行号表,将Code区的操作码和源代码中的行号对应,Debug时会起到作用(源代码走一行,需要走多少个JVM指令操作码)。
-
“LocalVariableTable”:本地变量表,包含This和局部变量,之所以可以在每一个方法内部都可以调用This,是因为JVM将This作为每一个方法的第一个参数隐式进行传入。当然,这是针对非Static方法而言。
3.操作数栈和字节码
JVM的指令集是基于栈而不是寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往和硬件挂钩),但缺点在于,要完成同样的操作,基于栈的实现需要更多指令才能完成(因为栈只是一个FILO结构,需要频繁压栈出栈)。另外,由于栈是在内存实现的,而寄存器是在CPU的高速缓存区,相较而言,基于栈的速度要慢很多,这也是为了跨平台性而做出的牺牲。
4.工具介绍
五.代码实现 show me you code.
一般Android 中事件的点击,都是有固定的方法的,所以只要监听固定的方法,就可以在相对于的地方插入代码。
如:下面的代码,想要重载继承它的方法,但是我在我的代码中只是单纯的继承了它
在 asm 读取上面的时候,我要重写它的 onResume()/setUserVisibleHint(boolean var1)/onHiddenChanged(boolean var1)/onPause(),并在 super()后插入我特定的方法。
实现的效果如下
这样我们就实现字节码插入。
六.展望&QA
通过 asm 字节的插入,就可以完美实现上面的功能,其实还可以做很多很多的好用的实用功能。
- 对全局所有class插桩,做UI,内存,网络等等方面的性能监控
- 发现某个第三方依赖,用起来不爽,但是不想拿它的源码修改再重新编译,而想对它的class直接做点手脚
- 每次写打log时,想让TAG自动生成,让它默认就是当前类的名称,甚至你想让log里自动加上当前代码所在的行数,更方便定位日志位置
- Java自带的动态代理太弱了,只能对接口类做动态代理,而我们想对任何类做动态代理
七.源码地址
如果你喜欢我的文章,可以关注我的掘金、公众号、博客、简书或者Github!
简书: https://www.jianshu.com/u/a2591ab8eed2
GitHub: https://github.com/bugyun
Blog: https://ruoyun.vip
欢迎关注微信公众号
文章作者 若云
上次更新 2019-12-01