20220908-Java依赖分析学习小结.md
前言:这是一篇学习小结
年中的一项工作任务需要在安卓应用构建过程中把不确定的代码片段及其依赖的代码打包成一个插件,遂学习了跟Java依赖分析有关的知识点和技术。奈何笔者精力有限,很多知识点未能理解透彻,相关技术未能对做充分的调研和对比。本文仅就学习中涉及的知识点做个小结,待日后有精力了再选择合适的切入点深入学习。
界定Java依赖分析问题
对Java依赖的理解
在百度百科中对依赖和依赖关系的解释是:
- 依靠,不能自立或自给;
- 以某事物或现象为条件且不可分离;
- 一个活动或事物的变更会影响到另一个活动或事物;
在Java中依赖关系存在于泛化、实现、组合、聚合、关联、调用、等关系中。当A依赖了(depends on)B时,也可以称B为A的依赖(dependency)。Java依赖的粒度可以是组件(如jar、aar、project)、包(package)、类(class)、甚至方法(method)。
代码之间的依赖可能时接口层面的依赖,也可能是实现逻辑层面的依赖。前者依赖关系可以通过Java代码源文件或代码编译后的二进制(.class)文件着手分析。后者的依赖关系需要比前者更复杂的技术来完成分析,或者很难实现完备可靠的分析。本文仅关心前者。
基于源代码和基于class文件的依赖分析有什么差异
基于源代码的依赖分析通常只聚焦于源代码之间的依赖关系,这类分析可能不需要执行编译或至少不用执行完整的编译。基于class文件的依赖分析可以把工程中的所有参与编译的源代码和项目依赖的jar文件都纳入分析但需要事先编译源代码,并分析工程的依赖配置。
Java源代码编译成class文件后,代码结构会发生一些变化(详阅 ASM Guide : 2.1.1. Overview1):
- 一个Java源文件可能包含一个以上的类,但一个class文件只会包含一个类:内部类会被编译成单独的class文件,而不会跟外层类在同一个class文件中;
- class文件不会包含注释;
- class文件不包含package(包名)和import的声明,class中的所有类型名称都是类型的完整名称;
- class文件中有一个常量池,存放了源文件中出现的所有常量(包括数字、字符串、类型字面量等),源代码中书写的常量在class文件中会替换为对常量池的索引;
- 类型在class中的表示方法跟源代码有很大差异,该问题涉及类型描述符、泛型、字节码、等因素;
上述源代码和class文件的差异,会导致基于源代码和基于class文件的分析技术有很大差异。例如,基于源代码的依赖分析需要解决类型推断2,而基于class文件的分析技术则不需要。类型推断是指,在一个代码片段中引用了类型TextUtils
,分析工具需要推断这个TextUtils
到底是android.text.TextUtils
还是别的包名下的TextUtils
。
源代码的依赖发生变化或丢失,可能直接导致编译期错误。源代码中可能引入依赖的典型场景3有:
- 方法调用;
- 类型转换;
- 对象创建;
- 对象的聚合、组合;
- 类型的继承、实现;
- Import;
- 方法入参和返回值;
- 异常声明和抛出;
- 等等;
class文件的依赖发生变化或丢失,既可能导致程序构建期异常,也可能导致程序运行期异常:
- 构建期可能会使用Proguard或基于字节码的AOP工具(如ASM4等)修改class文件,这些工具修改class文件时通常对依赖的双方做同步修改,如果依赖双方不匹配,这些工具可能会抛出异常;
- class文件被虚拟机加载到内存时会经历校验、解析、链接等过程,如果依赖不能成功通过这些过程,虚拟机可能会根据情况抛出
LinkageError
/ClassNotFoundException
/NoClassDefFoundError
/IncompatibleClassChangeError
等异常5;
class文件的依赖可能来自这些场景:
- 类:继承、实现;
- 成员变量:变量类型;
- 方法结构:入参类型;
- 代码指令
- 方法调用
- 对象创建(含数组创建)
- 类型校验、类型判断
- 读写类成员变量
- 引用类型常量
- labmda表达式引用外部变量
- try-catch的异常类型
- 注解
可以分析源代码中依赖的一些工具和技术
JCTree
JCTree
是JDK自带的一套Java库,提供了以访问者模式读写Java AST(抽象语法树)的方法6。JCTree
随JDK部署:
> unzip -v "$(dirname "$(type -p java)")/../lib/tools.jar"
...
... com/sun/tools/javac/tree/JCTree.class
...
... com/sun/tools/javac/tree/TreeMaker.class
...
JCTree
的javadoc可以在javadoc.io上查询7。
开发者可以在APT(注解处理器8910)中使用JCTree
11。结合TypeElement
/ TypeMirror
等API,可以遍历每一句代码及其语法结构。JCTree
定义了抽象的语法树节点,Java的每一种语法结构通过JCTree
的大量子类来描述。
depends
depends是一个用Java开发的命令行工具12,支持在Linux、Mac、Windows上运行。depends在GitHub上开源维护3。
提取依赖关系是depends的唯一设计目标:
- 支持分析多种语言的源代码:Java、C/C++、Ruby、等,具备支持其他语言的扩展能力;
- depends支持将依赖关系以多种格式输出:json、xml、excel、dot和plantuml,其中dot和plantuml可以分别用GraphViz13和PlantUML14实现可视化;
- depends支持提取十多种主要的依赖类型,详阅Supported Dependency Types and Examples;
JavaCC
JavaCC是一个开源的生成器工具,可以根据编写语法规则生成一个语法解释器1516。运行JavaCC需要安装JRE,而JavaCC生成的语法解释器也是Java可执行程序。
JavaParser
JavaParser是基于JavaCC开发的开源Java库17,支持Java AST的分析和操作。
JavaParser可以分析单个的代码文件,也可以分析代码片段。JavaParser提供了强大的类型推断能力2。JavaParser支持从aar、jar查找类型信息完成类型推断。JavaParser可以推断表达式和函数的类型,支持推断继承关系。
基于JavaParser可以实现依赖分析、代码度量、代码规范检查、等。
关于JavaParser的使用教程、手册、javadoc,可以在JavaParser官网18的新手教程中获取。
可以分析class文件中依赖的一些工具和技术
jdeps
jdeps是(1.8及更高版本的)JDK自带的依赖分析命令行工具19,支持分析class文件和jar文件,可以按类粒度或包粒度输出依赖情况。
> type jdeps
jdeps is hashed (/Library/Java/JavaVirtualMachines/jdk1.8.0_221.jdk/Contents/Home/bin/jdeps)
jdeps的命令行参数:
> jdeps
用法: jdeps <options> <classes...>
其中 <classes> 可以是 .class 文件, 目录, JAR 文件的路径名,
也可以是全限定类名。可能的选项包括:
……
jdeps的基本用法:
> jdeps com/example/AstApplication.class
AstApplication.class -> /Library/Java/JavaVirtualMachines/jdk1.8.0_221.jdk/Contents/Home/jre/lib/rt.jar
AstApplication.class -> 找不到
com.example (AstApplication.class)
-> android.app 找不到
-> android.os 找不到
-> java.lang
依赖库(如android.jar)需要通过-cp
参数指定:
> jdeps -cp "${ANDROID_HOME}/platforms/android-30/android.jar" com/example/AstApplication.class
AstApplication.class -> /Users/phantomqi/Library/Android/sdk/platforms/android-30/android.jar
com.example (AstApplication.class)
-> android.app android.jar
-> android.os android.jar
-> java.lang android.jar
通过-verbose
参数把依赖粒度设置为类:
> jdeps -cp "${ANDROID_HOME}/platforms/android-30/android.jar" -verbose:class com/example/AstApplication.class
AstApplication.class -> /Users/phantomqi/Library/Android/sdk/platforms/android-30/android.jar
com.example.AstApplication (AstApplication.class)
-> android.app.Application android.jar
-> android.os.Handler android.jar
-> java.lang.String android.jar
jdeps可以通过目录或使用通配符输入多个class文件:
# 以目录为输入
jdeps -verbose:class com/example/
# 支持用通配符指定路径(如涉及内部类的场合)
jdeps com/example/AstApplication*.class
jdeps的其他用法:
# 递归分析:一直分析到java.lang.Object为止
jdeps -cp "${ANDROID_HOME}/platforms/android-30/android.jar" -verbose:class -R com/example/AstApplication*.class
# 限定依赖范围:只输出com/example包名的依赖关系
jdeps -cp "${ANDROID_HOME}/platforms/android-30/android.jar" -verbose:class -R -p com.example com/example/AstApplication.class
# 指定多个依赖包和指定多个分析范围
jdeps -cp "${ANDROID_HOME}/platforms/android-30/android.jar:." -verbose:class -R -p com.example -p android.os -p android.app com/example/AstApplication.class
jdeps支持输出dot格式:
# 生成dot格式
> jdeps -cp "${ANDROID_HOME}/platforms/android-30/android.jar:." -verbose:class -R -dotoutput ../dot com/example/AstApplication*.class
> ls ../dot
AstApplication$1.class.dot
AstApplication$InnerClass.class.dot
AstApplication$InnerInterface.class.dot
AstApplication$StaticInnerClass.class.dot
AstApplication.class.dot
android.jar.dot
summary.dot
Jarviz
Jarviz是基于ASM开发的开源套件,聚焦于通过函数调用引入的依赖关系(见FilteredClassVisitor.java和FilteredMethodVisitor.java)。Jarviz通过分析invoke指令获得函数调用信息,包括:invokestatic
、invokespecial
、invokevirtual
、invokeinterface
和invokedynamic
,支持包含lambda表达式在内的各类函数调用(详见jarviz-lib的README)。
Jarviz由三部分组成20:
- Java库(jarviz-lib):以jar为输入,依赖关系以.jsonl格式21输出;
- 图形化工具(jarviz-graph):一个node程序,以jarviz-lib输出的jsonl文件为输入,以html文件输出依赖图;
- 命令行工具(jarviz-cli):封装jarviz-lib和jarviz-graph;
ASM
ASM是一套通用的Java字节码分析和修改框架4,遵循Java虚拟机规范22解析class文件。相比其他Java字节码框架,ASM以高性能而出众。截至目前最新的版本为“4 April 2022: release of ASM 9.3”。ASM被用于Groovy编译器、Kotlin编译器、Gradle执行器等,近年也被广泛用于Android Gradle Transform实现AOP。
ASM提供了两套API1:面向事件的 core api 和面向对象的 tree api,其中 tree api 是 core api 的高级封装。core api 以访问者模式提供class文件的读、写、变换、分析等操作。解析一个类需要从ClassVisitor
开始,嵌套的调用MethodVisitor
、FieldVisitor
、AnnotationVisitor
。
基于ASM当然可以实现任意粒度、任意类型的依赖分析工具。
Java虚拟机使用class文件会经过加载、解析、链接、初始化等过程。上述过程会根据依赖关系而链式的触发,并在发现依赖不匹配或缺失的时候抛出 LinkageError
、 ClassNotFoundException
、 NoClassDefFoundError
、 IncompatibleClassChangeError
等异常5。
实现依赖分析工具需要对Java字节码有基本的理解22,包括类结构(含常量池)、方法的结构、方法栈、指令集、等领域,因为依赖关系会在上述各领域中以描述符(Descriptor)或符号引用(Symbolic Reference)的形式体现。
依赖关系并不都通过Java指令集引入,而Java指令集也并不全部都会引入依赖,只有对象创建、方法调用、异常机制、类型校验、类成员访问、常量池操作、等指令可能引入依赖:
- 对象创建:
anewarray
、multianewarray
、new
- 类型判断:
checkcast
、instanceof
- 类成员访问:
getfield
、getstatic
、putfield
、putstatic
- 方法调用:
invokedynamic
、invokeinterface
、invokespecial
、invokevirtual
- 常量池引用:
ldc
、ldc_w
上述指令大多通过索引引用了常量池中的描述符或符号引用(详见 6.4. Format of Instruction Descriptions 和 6.5. Instructions )。
上述指令中,有些指令需要以其他指令先执行为前提。比如部分类成员访问(如getfield
和putfield
)和方法调用(如invokeinterface
和invokevirtual
)需要先有对象实例,那么一定会先执行对象创建指令(如new
),而对象创建、类型判断需要先执行ldc
把类型的符号引用或描述符从常量池加载到操作数栈上。基于这类前提条件,上述各指令未能通过测试用力全部检验依赖带来的影响效果。
在方法调用指令中,invokedynamic
会在编译lambda
表达式时用到232425。lambda表达式从Java8才开始支持,各种虚拟机和编译器对lambda的支持方案各有差异,如Android的编译器会对Java8的各种语法降糖。这种差异会对invokedynamic
相关的分析过程带来复杂度。这里不做进一步展开。
由于笔者未能掌握哪些代码可能会产生什么指令,哪些代码会生成各种class文件结构,哪种代码可能导致何种class的加载、校验、等过程,所以未能列举出完整的测试用例,未能彻底理解各种依赖关系实际在运行时可能带来什么异常。
参考资料
-
GitHub - multilang-depends/depends: Depends is a fast, comprehensive code dependency analysis tool ↩ ↩2
-
Java Virtual Machine Specification - Chapter 5. Loading, Linking, and Initializing ↩ ↩2
-
JavaCC - The most popular parser generator for use with Java applications. ↩
-
GitHub - javaparser/javaparser: Java 1-15 Parser and Abstract Syntax Tree for Java, including preview features to Java 13 ↩
-
GitHub - ExpediaGroup/jarviz: Jarviz is dependency analysis and visualization tool designed for Java applications ↩
-
The Java® Virtual Machine Specification Java SE 8 Edition ↩ ↩2