Skip to the content.

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):

  1. 一个Java源文件可能包含一个以上的类,但一个class文件只会包含一个类:内部类会被编译成单独的class文件,而不会跟外层类在同一个class文件中;
  2. class文件不会包含注释;
  3. class文件不包含package(包名)和import的声明,class中的所有类型名称都是类型的完整名称;
  4. class文件中有一个常量池,存放了源文件中出现的所有常量(包括数字、字符串、类型字面量等),源代码中书写的常量在class文件中会替换为对常量池的索引;
  5. 类型在class中的表示方法跟源代码有很大差异,该问题涉及类型描述符、泛型、字节码、等因素;

上述源代码和class文件的差异,会导致基于源代码和基于class文件的分析技术有很大差异。例如,基于源代码的依赖分析需要解决类型推断2,而基于class文件的分析技术则不需要。类型推断是指,在一个代码片段中引用了类型TextUtils,分析工具需要推断这个TextUtils到底是android.text.TextUtils还是别的包名下的TextUtils

源代码的依赖发生变化或丢失,可能直接导致编译期错误。源代码中可能引入依赖的典型场景3有:

class文件的依赖发生变化或丢失,既可能导致程序构建期异常,也可能导致程序运行期异常:

class文件的依赖可能来自这些场景:

可以分析源代码中依赖的一些工具和技术

JCTree

JCTree是JDK自带的一套Java库,提供了以访问者模式读写Java AST(抽象语法树)的方法6JCTree随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)中使用JCTree11。结合TypeElement / TypeMirror 等API,可以遍历每一句代码及其语法结构。JCTree定义了抽象的语法树节点,Java的每一种语法结构通过JCTree的大量子类来描述。

depends

depends是一个用Java开发的命令行工具12,支持在Linux、Mac、Windows上运行。depends在GitHub上开源维护3

提取依赖关系是depends的唯一设计目标:

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.javaFilteredMethodVisitor.java)。Jarviz通过分析invoke指令获得函数调用信息,包括:invokestaticinvokespecialinvokevirtualinvokeinterfaceinvokedynamic,支持包含lambda表达式在内的各类函数调用(详见jarviz-lib的README)。

Jarviz由三部分组成20

  1. Java库(jarviz-lib):以jar为输入,依赖关系以.jsonl格式21输出;
  2. 图形化工具(jarviz-graph):一个node程序,以jarviz-lib输出的jsonl文件为输入,以html文件输出依赖图;
  3. 命令行工具(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开始,嵌套的调用MethodVisitorFieldVisitorAnnotationVisitor

基于ASM当然可以实现任意粒度、任意类型的依赖分析工具。

Java虚拟机使用class文件会经过加载、解析、链接、初始化等过程。上述过程会根据依赖关系而链式的触发,并在发现依赖不匹配或缺失的时候抛出 LinkageErrorClassNotFoundExceptionNoClassDefFoundErrorIncompatibleClassChangeError等异常5

实现依赖分析工具需要对Java字节码有基本的理解22,包括类结构(含常量池)、方法的结构、方法栈、指令集、等领域,因为依赖关系会在上述各领域中以描述符(Descriptor)或符号引用(Symbolic Reference)的形式体现。

依赖关系并不都通过Java指令集引入,而Java指令集也并不全部都会引入依赖,只有对象创建、方法调用、异常机制、类型校验、类成员访问、常量池操作、等指令可能引入依赖:

上述指令大多通过索引引用了常量池中的描述符或符号引用(详见 6.4. Format of Instruction Descriptions6.5. Instructions )。

上述指令中,有些指令需要以其他指令先执行为前提。比如部分类成员访问(如getfieldputfield)和方法调用(如invokeinterfaceinvokevirtual)需要先有对象实例,那么一定会先执行对象创建指令(如new),而对象创建、类型判断需要先执行ldc把类型的符号引用或描述符从常量池加载到操作数栈上。基于这类前提条件,上述各指令未能通过测试用力全部检验依赖带来的影响效果。

在方法调用指令中,invokedynamic会在编译lambda表达式时用到232425。lambda表达式从Java8才开始支持,各种虚拟机和编译器对lambda的支持方案各有差异,如Android的编译器会对Java8的各种语法降糖。这种差异会对invokedynamic相关的分析过程带来复杂度。这里不做进一步展开。

由于笔者未能掌握哪些代码可能会产生什么指令,哪些代码会生成各种class文件结构,哪种代码可能导致何种class的加载、校验、等过程,所以未能列举出完整的测试用例,未能彻底理解各种依赖关系实际在运行时可能带来什么异常。

参考资料

  1. ASM Guide (PDF)  2

  2. Java Parser应用介绍 - 腾讯云开发者社区-腾讯云  2

  3. GitHub - multilang-depends/depends: Depends is a fast, comprehensive code dependency analysis tool  2

  4. ASM Home Page  2

  5. Java Virtual Machine Specification - Chapter 5. Loading, Linking, and Initializing  2

  6. Java中的屠龙之术——如何修改语法树 - OSCHINA 

  7. TreeMaker - javadoc.io 

  8. 自定义Java注解处理器 

  9. 如何在Gradle了中自定义一个注解处理器 

  10. Lesson: Annotations - The Java Tutorials - ORACLE 

  11. 注解开发学习笔记 - CSDN博客 

  12. Depends简介与使用说明 

  13. GraphViz 

  14. PlantUML - 开源工具,使用简单的文字描述画UML图 

  15. JavaCC - The most popular parser generator for use with Java applications. 

  16. JAVACC使用 - 知乎 

  17. GitHub - javaparser/javaparser: Java 1-15 Parser and Abstract Syntax Tree for Java, including preview features to Java 13 

  18. JavaParser - Home 

  19. jdeps - Oracle 

  20. GitHub - ExpediaGroup/jarviz: Jarviz is dependency analysis and visualization tool designed for Java applications 

  21. Documentation for the JSON Lines text file format 

  22. The Java® Virtual Machine Specification Java SE 8 Edition  2

  23. 理解invokedynamic - 简书 

  24. MethodHandle (Java Platform SE 8 ) 

  25. Java语言的动态性-invokedynamic - CSDN