一种基于class字节码的快速扫描技术

日常工作中有时候可能会遇到需要统计某个方法的使用地方,项目里有没有代码调用了某些违规函数,某类到底被哪些类给依赖了等等问题,这种需求通常会通过写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文件都是一样,譬如例子里面的

  • 版本号
    跟在魔法数后面的是副版本号(2字节长度)跟主版本号(2字节长度),Demo里面的是

    副版本号是0x00,主版本号是0x34,代表着是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 枚举类型标志

譬如上面的Demo是public类型,所以它的访问标志就是 ACC_PUBLIC | ACC_SUPER 对应的16进制值就是0x21了,如下:
  • 类索引 父类索引 接口索引
    在访问标志后面是本类索引 父类索引以及接口索引,0x05是本类的索引id,意思是在常量池里的第5个位置有本类的索引信息,对照着上面的常量池结构可以知道本类正是com/nls/lib/Test 同理在常量池里的第6个位置是本类的父类索引信息,这里是java/lang/Object 由于Demo里的Test类并没有实现任何接口,所以跟在后面的接口索引信息是空

  • 字段表
    跟在接口索引表后面是字段表,字段表记录了类里面定义的所有的字段信息。首先是2字节长度字段用来描述字段数,Demo里面的Test类只有一个字段,所以这里是0x01

    在class字节码内部用这样的结构来描述类里面定义的每一种字段类型

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/ConstructornewInstance方法,另外一种是调用 java/lang/ClassnewInstance方法,前面我们已经提到过了,当一个类引用了外部类的某个方法是,class常量池里会有一条与之对应的CONSTANT_Methodref_info常量,因此我们可以设计以下方案:

  • 把整个项目的所有class字节码读取到ClassPool里面
  • 遍历ClassPool里面的所有class对象
  • 遍历class常量池里面的CONSTANT_Methodref_info常量,名字是newInstance并且类名是ConstructorClass类的就是要查找目标类

由于原理跟代码跟上面的类搜索相似,这里直接给出核心代码

/**
 * 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;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269