Jacoco基于编译后的字节码来分析方法控制流,分析指令覆盖率和分支覆盖率时需要控制流信息。
Java字节码控制流图:
public static void example() {
a();
if (cond()) {
b();
} else {
c();
}
d();
}
public static example()V
INVOKESTATIC a()V
INVOKESTATIC cond()Z
IFEQ L1
INVOKESTATIC b()V
GOTO L2
L1: INVOKESTATIC c()V
L2: INVOKESTATIC d()V
RETURN
Java编译器将从该示例方法创建以下字节码。 Java字节码是线性的指令序列。控制流使用条件IFEQ或无条件GOTO操作码之类的跳转指令来实现。跳转目标在技术上是相对于目标指令的相对偏移。为了提高可读性,我们改用符号标签(L1,L2)(ASM API也使用此类符号标签)
上述代码的流程图(右侧为插入探针的控制流图):
由Java字节码定义的Java方法的控制流程图可能具有以下边。每个边将源指令与目标指令连接起来。在某些情况下,源指令或目标指令不存在(方法进入和退出的虚拟边)或无法完全指定(异常处理程序)
当前的JaCoCo实现忽略由隐式异常和方法入口,这些我们表示为SEQUENCE,JUMP,EXIT。
插桩策略
探针是可以插入现有指令之间的其它指令,它们不会改变方法的行为,而是记录它们已经被执行,可以认为探针放置在控制流程图的边缘,从理论上讲,我们可以在控制流程图的每个边缘插入一个探针,由于探针实现本身需要多个字节码指令,这将使类文件的大小增加数倍,并显著降低所检测类的执行速度。幸运的是,这不是必需的,实际上,根据方法的控制流程,每个方法仅需要几个探针。例如,没有任何分支的方法仅需要单个探针,这样做的原因是,从某个探针开始,我们可以追溯执行路径,并且通常可以获取多条指令的覆盖率信息。
如果执行了探测,我们知道已经访问了相应的边。从这个边缘我们可以追溯到其它先前的节点和边缘。
- 如果一个边被执行了,那这条边的节点被执行了;
- 如果一个节点被执行了,并且这个节点只有一个入边,那这条边也执行了;
假定我们在正确的位置进行了探测,则递归地应用这些规则可以确定方法的所有指令的执行状态。所以Jacoco在以下场景应用探针: - 每个方法的出口(返回、抛出异常)
- 目标指令有多个入边时在每条边插入;
探针只是需要在控制流边缘插入的一小部分附加指令,下表说明了不同情况下如何添加此额外指令。
边的其它插桩情况
到目前为止描述的探针插入策略未考虑例如从调用的方法引发的隐式异常。如果两个探针之间的控制流被未使用throw语句明确创建的异常中断,则介于两者之间的所有指令均被视为未涵盖。尤其是当指令块跨越源代码的多行时,结果不是预期的。
因此,只要下一行包含至少一个方法调用,JaCoCo就会在两行的指令之间添加一个额外的探针。这将隐式异常的影响从方法调用限制到源代码的单行。该方法仅适用于带有调试信息(行号)编译的类文件,并且不考虑方法调用以外的其他指令的隐式异常(例如NullPointerException或ArrayIndexOutOfBoundsException)。
探针的实现
代码覆盖率分析是一种运行时度量,可提供被测软件的执行详细信息。指令覆盖率和分支覆盖率都是通过探测器收集的:
探针是插入java方法中的字节码指令,探针的执行会被覆盖率执行器记录并报告,探针不能影响原代码的行为。
探针只用来记录代码被执行过,并不会记录执行了多少次和耗时(可通过专门的性能分析工具获取)。一个方法中往往会插入多个探针,所以需要识别探针,探针的实现和存储机制必须保证在java多线程场景是是线程安全的,探针必须不能对原代码造成影响,并且具有小的开销。
探针的特性概括如下:
- 记录执行;
- 识别不同的探针;
- 线程安全;
- 不能对源码造成影响;
- 最小开销;
Jacoco通过为每个类创建一个bool数组来实现探针,每个探针对应于该数组中的一个数据项,当探针被执行时,通过以下指令把数组中对应的变量设置为true:
ALOAD probearray
xPUSH probeid
ICONST_1
BASTORE
这个探针代码是线程安全的,没有修改操作数栈或本地变量,也没有通过调用其它方法来退出,唯一的前提条件是探针阵列作为局部变量。为此,在每种方法的开头,需要添加其他工具代码以获得与所属类关联的数组实例。为避免代码重复,将初始化委托给静态私有方法$ jacocoinit(),该方法将添加到每个非接口类中。
探测代码的大小取决于探测阵列变量的位置和探测标识符的值,因为可以使用不同的操作码。如下表所示,每个探测器的开销介于4到7个字节的附加字节码之间:
1探针数组是参数之后的第一个变量。如果方法参数消耗的时隙不超过3个,则可以使用1字节的操作码。
2 1个字节可以表示0到5的操作码,2个字节最大操作码id为127,3字节最大32767,ID值为32768或更大需要附加的常量池。对于普通的类文件,几乎不可能需要超过32,000个探针。
性能
插桩后的整个类文件会增大30%,由于不需要调用方法执行探针,插桩后的应用程序执行时间增加10%。
引用