日常工作中有时候可能会遇到需要统计某个方法的使用地方,项目里有没有代码调用了某些违规函数,某类到底被哪些类给依赖了等等问题,这种需求通常会通过写
python
脚步去扫描整个项目代码,这种方式优点是简单,但缺点也十分的明显,就是效率很低,因为脚步通常都是一行行的文本内容扫描然后通过正则去匹配,即便是遇到了注释或空行也照样会进行匹配,显然的效率极低,其实对于这类问题还可以使用字节码扫描的方式去实现,效率十分的高,即便是要在十几万个类中扫描某方法也仅仅是几秒的时间就能完成了。
前言
所谓字节码扫描就是通过读取class文件,解析class文件结构,最后通过检索class的内部结构,如常量池、方法表、局部变量表等等,去找我们想要的信息。举个列子,譬如我们想要知道class A 被哪些类引用了,只需要扫描所有class常量池的CONSTANT_Class_info
结构就可以了,又譬如我们想知道某方法(譬如是getUserInfo)它被哪些代码依赖了,也是可以通过扫描常量池里面的CONSTANT_Methodref_info
结构可以获取到。在增量编译系统里,假如A类被修改了,那么使用到A类的代码也得被找出来重新编译,早期的Gradle版本实现增量编译功能,就是通过这种扫描class字节码结构的方式去查找类依赖关系的(18年看过Gradle 3.x的源码是这样实现的,现在的版本不清楚有没有修改过)。
认识class内部结构
假设我们有下面Demo代码
package com.nls.lib;
/**
* Create by nls on 2022/1/19
* description: Test
*/
public class Test {
public int a;
public void test1() {
System.out.println("123");
}
}
编译成class文件后,我们用二进制编辑工具打开是这样的
一般人都看不懂这串16进制数据,实际上这些16进制字节流数据是按照一定的格式去组合的,它对应的格式如下:
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
其中u4代表的是4字节长度,u2,代表的是2字节长度,如此类推。
JDK也提供了javap
工具,可以把上面的16进制二进制流转换成可读的class结构,命令如下:
javap -verbose Test.class
-
魔法数
class文件最开始4字节内容是魔法数,固定内容CAFE BABE
,所有class文件都是一样,譬如例子里面的 -
版本号
副版本号是0x00,主版本号是0x34,代表着是
跟在魔法数后面的是副版本号(2字节长度)跟主版本号(2字节长度),Demo里面的是jdk1.8.0
-
常量池
跟在版本号后面的是常量池长度,用2字节长度标识,Demo这里是0x21,转换成10进制是33,意思是常量池里面有33种数据
每种常量类型对应着不一样的数据结构,但不管是哪种常量,它的第一个字节都是tag字段,用来表示此常量是什么类型,譬如Demo里面常量池里的第一个常量tag字段是0x0A,转换成10进制是10,对应的就是CONSTANT_Methodref_info
常量,格式如下:
CONSTANT_Methodref_info {
u1 tag; //10
u2 class_index; //类索引
u2 name_and_type_index; //字段名索引
}
第一个字段是类型,固定为10,第二个字段是类索引id,通过这个索引id我们可以找到声明此方法的类,这里的类索引号为0x6,代表着此方法的类信息在常量池里的第6个位置,方法名索引是0x13,转换成10进制就是19,代表着此方法的方法名信息在常量池里面的第19个位置。对照上面的常量池结构可以找到此CONSTANT_Methodref_info
常量描述的正是构造函数init
方法。当类引用了一个外部方法时,常量池里就会多一条CONSTANT_Methodref_info
常量数据。
跟在第一个CONSTANT_Methodref_info
常量后面的是0x9,对应的是CONSTANT_Fieldref_info
常量,格式如下:
CONSTANT_Fieldref_info {
u1 tag; //9
u2 class_index; //类索引
u2 name_and_type_index; //方法名索引
}
每个字段的含义跟上面的CONSTANT_Methodref_info
结构类似,其中0x14转换成10进制是20,0x15转换成10进制21,代表的也是常量池里面的索引id,对照着上面的常量池表我们可以得出这条CONSTANT_Fieldref_info
常量数据描述的正是System
类的out
字段。当类引用了一个外部字段时,常量池里就会多一条CONSTANT_Fieldref_info
常量数据。
通过这种方式就可以把常量池里面的所有常量都解析出来了,这里只是抛砖引玉,介绍一下常量池的解析方式,剩下的常量解析这里就不再一一分析了。
-
访问标志
跟在常量池后面是类访问标志,Java提供了下面几种标志类型:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | public 类型 |
ACC_FINAL | 0x0010 | final 类型 |
ACC_SUPER | 0x0020 | JDK1.0.2之后编译出来的类默认会带上此标志 |
ACC_INTERFACE | 0x0200 | 接口标志 |
ACC_ABSTRACT | 0x0400 | abstract抽象类型标志 |
ACC_SYNTHETIC | 0x1000 | 非代码生成标志 |
ACC_ANNOTATION | 0x2000 | 注解类型标志 |
ACC_ENUM | x4000 | 枚举类型标志 |
-
类索引 父类索引 接口索引
在访问标志后面是本类索引 父类索引以及接口索引,0x05是本类的索引id,意思是在常量池里的第5个位置有本类的索引信息,对照着上面的常量池结构可以知道本类正是com/nls/lib/Test
同理在常量池里的第6个位置是本类的父类索引信息,这里是java/lang/Object
由于Demo里的Test类并没有实现任何接口,所以跟在后面的接口索引信息是空 -
字段表
在class字节码内部用这样的结构来描述类里面定义的每一种字段类型
跟在接口索引表后面是字段表,字段表记录了类里面定义的所有的字段信息。首先是2字节长度字段用来描述字段数,Demo里面的Test类只有一个字段,所以这里是0x01
field_info {
u2 access_flags; //访问类型
u2 name_index; // 字段名索引
u2 descriptor_index; //字段签名索引
u2 attributes_count; // 属性数
attribute_info attributes[attributes_count]; //属性表
}
字段的访问类型又有以下几种
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | public 类型 |
ACC_PRIVATE | 0x0002 | private 类型 |
ACC_PROTECTED | 0x0004 | protected 类型 |
ACC_STATIC | 0x0008 | 静态类型 |
ACC_FINAL | 0x0010 | final 类型 |
ACC_VOLATILE | 0x0040 | volatile 类型 |
ACC_TRANSTENT | 0x0080 | transient 类型 |
ACC_SYNCHETIC | 0x1000 | 编译器自动产生 |
ACC_ENUM | ACC_ENUM | 枚举类型 |
Demo的Test类的a字段类型为public 对应的值就是0x01,跟在后面的是名字索引跟签名索引等等信息,代表的就是在常量池里面的索引号,如下:
对照着上面的常量池表结构,索引id 7的位置是a,就是本字段名字,索引id 8的位置是
I
代表着是本字段是int
类型,类里面每增加一个字段字段表里面就会多一条field_info
结构数据
-
方法表
跟在字段表后面的是方法表,方法表记录的是本类的所有方法信息,先用2字段长度记录方法表大小,这里是0x02,代表着Demo里面有两个方法(除了代码里面的test1
方法 还有编译器自动生成的init
方法)
在class字节码里方法的定义结构定义如下:
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
方法结构定义是跟字段结构的定义是一样的,这里重点介绍一下方法结构里面的属性表,方法是有方法实体的,方法里面的代码会以Code
属性被存放在属性表里。Code
属性结构定义如下:
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
其中code
字段数组存放的就是编译后的方法实体代码了,code
里面也是一些列的字节流,这些16进制的字节流对应着一条条的jvm
指令
实战
通过上面的分析,我们对class的内部结构有了一定的了解了,Java会把编译后的类成员、类方法、以及方法实体整理归类好并且放到同一个地方去,这大大的方便了代码的检索任务,譬如需要搜索类成员只需要遍历class字节码的字段表就可以了,需要搜索某方法只需要遍历class字节码的方法表就可以了,需要搜索类依赖了哪些外部类只需要遍历常量池里的CONSTANT_Class_info
常量就可以 了。
Case one 类依赖扫描
手y在做32位uid转64位任务时,需要扫描出哪些地方使用了Uint32
类,前面介绍class文件结构时我们已经介绍了,当类依赖了某个外部类时,常量池里就会有一条与之对应的CONSTANT_Class_info
常量,因为我们可以设计以下方案:
- 把整个项目的所有class字节码读取到
ClassPool
里面 - 遍历
ClassPool
里面的所有class对象 - 遍历class常量池里面的
CONSTANT_Class_info
常量,名字是Uint32
就是要查找目标类
首先我们需要读取.class文件,然后按照上面介绍的class文件结构,逐字节的把class内容解析出来,class文件结构比较复杂,这里我们可以使用ASM的ClassReader或proguard的ProgramClassReader等等现成的解析逻辑。
ClassPool
本质上就是个Map结构,把读取出来的class对象以key value的形式保存起来
public class ClassPool
{
private final TreeMap<String, Clazz> classes = new TreeMap<>();
public void addClass(Clazz clazz)
{
addClass(clazz.getName(), clazz);
}
public void classesAccept(ClassVisitor classVisitor)
{
Iterator iterator = classes.values().iterator();
while (iterator.hasNext())
{
Clazz clazz = (Clazz)iterator.next();
clazz.accept(classVisitor);
}
}
}
最后是遍历所有class对象的常量池结构,拿到CONSTANT_Class_info
常量后比较它的名字是否Uint32
即可,代码大致如下:
/**
* Create by nls on 2022/5/30
* description: Uint32ClassMatcher
*/
class Uint32ClassMatcher(private val visitor: ClassVisitor) : ClassVisitor, ConstantVisitor {
override fun visitAnyConstant(clazz: Clazz?, constant: Constant?) {
}
override fun visitAnyClass(clazz: Clazz) {
clazz.constantPoolEntriesAccept(this)
}
override fun visitClassConstant(clazz: Clazz, classConstant: ClassConstant) {
val className = classConstant.getName(clazz)
if (className == "com/yy/mobile/yyprotocol/core/Uint32" ||
className == "tv/athena/live/streambase/services/core/Uint32"
) {
visitor.visitAnyClass(clazz)
}
}
}
//调用地方如下
fun execute(classPool: ClassPool) {
val memberMatcher = Uint32MemberMatcher()
val start = System.currentTimeMillis()
classPool.accept(AllClassVisitor(FilterClassVisitor(Uint32ClassMatcher(this))))
val end = System.currentTimeMillis()
println("scan finish total ${classPool.size()}, match: ${matchClassPool.size()}, cost: ${end-start}")
//matchClassPool.accept(AllClassVisitor(memberMatcher))
//memberMatcher.print()
}
最终扫描了总共四万多个类,匹配的类有一千多条,整个扫描过程也是仅仅花了不到300毫秒的时间,效率可以说是极高的
Case two 方法依赖扫描
在研究proguard优化时需要知道项目里哪些地方使用了反射去实例化类对象,反射实例化类有两种方式,一种是调java/lang/reflect/Constructor
的 newInstance
方法,另外一种是调用 java/lang/Class
的 newInstance
方法,前面我们已经提到过了,当一个类引用了外部类的某个方法是,class常量池里会有一条与之对应的CONSTANT_Methodref_info
常量,因此我们可以设计以下方案:
- 把整个项目的所有class字节码读取到
ClassPool
里面 - 遍历
ClassPool
里面的所有class对象 - 遍历class常量池里面的
CONSTANT_Methodref_info
常量,名字是newInstance
并且类名是Constructor
或Class
类的就是要查找目标类
由于原理跟代码跟上面的类搜索相似,这里直接给出核心代码
/**
* Create by nls on 2022/5/29
* description: ReflectionClassMatcher
*/
class ReflectionClassMatcher : ClassMatcher {
override fun match(clazz: Clazz, refConstant: MethodrefConstant): Boolean {
val className = refConstant.getClassName(clazz)
val methodName = refConstant.getName(clazz)
if (className == "java/lang/reflect/Constructor" || className == "java/lang/Class") {
return methodName == "newInstance"
}
return false
}
}
方法引用常量里面会有方法名跟类名的索引,我们只需要判断下类名跟方法名便能找到自己想要的,最终执行效果如下,扫描了四万多个类,四十多万个方法,耗时才300多毫秒,效率是相当的惊人的
Case three 依赖链扫描
前面介绍的两种扫描方式都比较简单,都是通过直接扫描常量池就可以达到效果了,但扫描常量池只能得到有依赖某个外部类,某个外部方法等信息,却并不能知道外部类或外部方法是被本类的哪些方法引入进来的,下面我们来分析下这种场景该如何进行扫描。
上面的Demo类test1
方法依赖了println
方法,我们反编译看下test1
方法的内部指令
jvm
的指令集里,方法调用会用到invoke
系列指令(invokestatic invokespecial invokeinterface invokevirtual invokedynamic) 指令后面的操作数便是需要调用的方法在常量池里的索引。前面介绍class文件结构时我们已经提到过了,方法体编译后的代码会以二进制流的形式被保存到Code
属性里,因此我们可以设计以下方案:
- 扫描class常量池找到调用方法并且记录下它的索引id
- 扫描class的方法表找到类的所有方法
- 扫描方法表里每个方法的Code属性
- 遍历Code属性里面的所有指令集,找出invoke指令跟指令操作数
- 指令操作数为第一步扫描出来的索引id,那么就建立一条调用关系并且记录
- 一直的递归继续扫
手y的频道模版入口是LiveTemplateView::onCreate
,但是在调用到onCreate
前面有一套很复杂的上下滑框架,假如我们并不熟悉那套框架的代码逻辑,又想快速的找到进频道的调用逻辑是怎么样的,这时候我们就可以通过class扫描的方法把调用链给扫描出来
第一步我们先遍历常量池,找到引用方法的索引id
override fun visitProgramClass(programClass: ProgramClass) {
kotlin.run {
//1.遍历常量池,找到目标调用方法在常量池里的索引id.
programClass.constantPool.forEachIndexed { index, constant ->
constant?.accept(programClass, this)
if (methodFind) {
methodRefIndex = index
return@run
}
}
}
//省略部分代码
}
第二步遍历方法表以及每个方法的Code属性
override fun visitProgramClass(programClass: ProgramClass) {
//省略部分代码
//2.遍历方法表,事实上类里面可能有多个方法都调用了外部引入的方法,
//这里为了方便演示,找到一处调用就return. 只检测一条的调用链。
kotlin.run {
programClass.methods.forEach {
//跳过桥接方法,免得引起死循环.
if (it.accessFlags and AccessConstants.BRIDGE == 0) {
it.accept(programClass, this)
if (methodFind) {
listener.onFindMethod(programClass.name, it.getName(programClass))
return@run
}
}
}
}
}
override fun visitProgramMethod(programClass: ProgramClass, programMethod: ProgramMethod) {
//3.遍历方法属性表
programMethod.attributesAccept(programClass, this)
}
override fun visitCodeAttribute(clazz: Clazz, method: Method, codeAttribute: CodeAttribute) {
//4.我们只管Code属性,其他属性不管它.
codeAttribute.instructionsAccept(clazz, method, this)
}
第三步遍历Code属性里面的所有指令,我们只关心常量指令,如果常量指令的操作数为第一步索引到的id,那么此方法就是调用方法
override fun visitConstantInstruction(
clazz: Clazz,
method: Method,
codeAttribute: CodeAttribute,
offset: Int,
constantInstruction: ConstantInstruction
) {
//5. 我们只管常量指令,其他指令不管它.
if (constantInstruction.constantIndex == methodRefIndex) {
methodFind = true
}
}
最后把找出来的类名方法名作为新的参数一直递归扫描就可以把整个调用链给检索出来了,效果如下:
总结
虽然某些场景下扫描项目代码可以通过写脚本的方式去实现,但基于class字节码的扫描方式会更加快,效率更加高,而且能做的事情更加广,如敏感方法使用,无用代码扫描等等,这些都是脚本方式无法实现的。
附录
-
常量池常量结构
CONSTANT_Class_info常量
CONSTANT_Class_info {
u1 tag;//7
u2 name_index;
}
CONSTANT_Fieldref_info常量
CONSTANT_Fieldref_info {
u1 tag; //9
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_Methodref_info常量
CONSTANT_Methodref_info {
u1 tag; //10
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_InterfaceMethodref_info常量
CONSTANT_InterfaceMethodref_info {
u1 tag; //11
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_String_info常量
CONSTANT_String_info {
u1 tag; //8
u2 string_index;
}
CONSTANT_Integer_info常量
CONSTANT_Integer_info {
u1 tag; //3
u4 bytes;
}
CONSTANT_Float_info常量
CONSTANT_Float_info {
u1 tag; //4
u4 bytes;
}
CONSTANT_Long_info常量
CONSTANT_Long_info {
u1 tag; //5
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_Double_info常量
CONSTANT_Double_info {
u1 tag; //6
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_NameAndType_info常量
CONSTANT_NameAndType_info {
u1 tag; //12
u2 name_index;
u2 descriptor_index;
}
CONSTANT_Utf8_info常量
CONSTANT_Utf8_info {
u1 tag; //1
u2 length;
u1 bytes[length];
}
CONSTANT_MethodHandle_info常量
CONSTANT_MethodHandle_info {
u1 tag; //15
u1 reference_kind;
u2 reference_index;
}
CONSTANT_MethodType_info常量
CONSTANT_MethodType_info {
u1 tag; //16
u2 descriptor_index;
}
CONSTANT_InvokeDynamic_info常量
CONSTANT_InvokeDynamic_info {
u1 tag; //18
u2 bootstrap_method_attr_index;
u2 name_and_type_index;
}
网友评论