-
java代码覆盖率jacoco原理及案例分析
-
目录:https://blog.csdn.net/liuxueyi521/article/details/87898581
-
实战案例:视频内容1:https://edu.csdn.net/course/detail/19104
- Java方法的控制流分析
实现支持语句(C0)以及分支覆盖范围(C1)的覆盖工具需要详细分析Java方法的内部控制流。由于JaCoCo的体系结构,这种分析发生在已编译类文件的字节码上。本文档描述了JaCoCo在运行时将探针插入控制流并分析实际代码覆盖率的策略。Marc R. Hoffmann,2011年11月
- Java字节码的控制流程图
作为起点,我们采用以下包含单个分支点的示例方法:
- public static void example (){
- a ();
- if (cond ()){
- b ();
- } else {
- c ();
- }
- d ();
- }
Java编译器将从此示例方法创建以下字节码。Java字节码是线性指令序列。控制流程使用条件或无条件操作码等跳转指令 实现。跳转目标在技术上是与目标指令的相对偏移。为了更好的可读性,我们使用符号标签(,)代替(ASM API也使用这样的符号标签): IFEQGOTOL1L2
- 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
- 返回
上面的字节码中可能的控制流程可以用图表表示。节点是字节代码指令,图形的边缘表示指令之间可能的控制流程。该示例的控制流程显示在此图的左侧框中:
- 流动边缘
由Java字节代码定义的Java方法的控制流程图可以具有以下边缘。每条边连接源指令和目标指令。在某些情况下,源指令或目标指令不存在(方法入口和出口的虚拟边)或无法精确指定(异常处理程序)。
类型 | 资源 | 目标 | 备注 |
条目 | - | 方法中的第一条指令 | |
序列 | 指令,除了 | 后续指示 | |
跳 |
| 目标指令 |
|
EXHANDLER | 处理程序范围内的任何指令 | 目标指令 | |
出口 |
| - | |
EXEXIT | 任何指示 | - | 未处理的异常。 |
当前的JaCoCo实现忽略了由隐式异常和方法条目引起的边缘。这意味着我们考虑SEQUENCE,JUMP,EXIT。
- 探针插入策略
探针是可以在现有指令之间插入的附加指令。它们不会改变方法的行为,但会记录它们已被执行的事实。可以认为探针放置在控制流图的边缘上。从理论上讲,我们可以在控制流图的每个边缘插入一个探针。由于探测器实现本身需要多个字节码指令,这会多次增加类文件的大小,并显着降低已检测类的执行速度。幸运的是,这不是必需的,实际上我们每个方法只需要一些探针,具体取决于方法的控制流程。例如,没有任何分支的方法仅需要单个探测。
如果已经执行了探测,我们知道已经访问了相应的边缘。从这个边缘我们可以得出其他前面的节点和边:
- 如果访问了边,我们知道该边的源节点已被执行。
- 如果节点已被执行且节点仅是一个边缘的目标,则我们知道已经访问了该边缘。
递归地应用这些规则允许确定方法的所有指令的执行状态 - 假设我们在正确的位置具有探针。因此,JaCoCo插入探针
- 在每个方法退出(返回或抛出)和
- 在目标指令是多个边缘的目标的每个边缘。
我们记得,探针只是一小部分需要在控制流边缘插入的附加指令。下表说明了在不同边缘类型的情况下如何添加此额外指令。
类型 | 之前 | 后 | 备注 |
序列 |
|
| 在简单序列的情况下,将探针简单地插入两个指令之间。 |
JUMP(无条件) |
|
| 由于在任何情况下都执行无条件跳转,我们也可以在GOTO指令之前插入探针。 |
JUMP(有条件的) |
|
| 向条件跳转添加探测器有点棘手。我们反转操作码的语义,并在条件跳转后立即添加探测。随后的 |
出口 |
|
| 实际上离开方法的RETURN和THROW语句的本质是我们在这些语句之前添加探测。 |
现在让我们看看这个规则如何应用于上面的示例代码段。我们看到该 INVOKE d()
指令是唯一具有多个传入边缘的节点。因此,我们需要在这些边上放置探针,并在唯一的出口节点上放置另一个探针。结果显示在上图的右侧框中。
- 线之间的额外探测
到目前为止描述的探针插入策略不考虑例如从调用的方法抛出的隐式异常。如果两个探测器之间的控制流被未使用throw
语句显式创建的异常中断,则其间的所有指令都被视为未被覆盖。这会导致意外的结果,尤其是当指令块跨越多行源代码时。
因此,只要后续行包含至少一个方法调用,JaCoCo就会在两行的指令之间添加额外的探测。这限制了从方法调用到单行源的隐式异常的影响。该方法仅适用于使用调试信息(行号)编译的类文件,并且不考虑除方法调用(例如NullPointerException
或ArrayIndexOutOfBoundsException
)之外的其他指令的隐式异常 。
- 探索实施
代码覆盖率分析是一个运行时指标,它提供被测软件的执行细节。这需要详细记录已执行的指令(指令覆盖)。对于分支覆盖,还必须记录决策的结果。在任何情况下,执行数据都由所谓的探测器收集:
甲探针是可以被插入到一个Java字节码方法的指令序列。执行探测时,会记录此事实,并可由coverage运行时报告。探针不得更改原始代码的行为。
探测的唯一目的是记录它至少执行过一次。探测器不记录它被调用的次数或收集任何时间信息。后者超出了代码覆盖率分析的范围,更多的是在性能分析工具的目标中。通常需要将多个探针插入每个方法中,因此需要识别探针。此外,探针实现和它依赖的存储机制需要是线程安全的,因为多线程执行是java应用程序的常见场景(尽管不适用于普通单元测试)。探针不得对方法的原始代码产生任何副作用。他们也应该增加最小的开销。
因此,总结执行探针的要求:
- 记录执行
- 鉴定不同的探针
- 线程安全
- 对应用程序代码没有副作用
- 最小的运行时开销
JaCoCo使用boolean[]
每个类的数组实例实现探测。每个探针对应于此数组中的条目。无论何时执行探测,都将条目设置为true
以下四个字节码指令:
ALOAD探针
阵列xPUSH
探针
ICONST_1 BASTORE
请注意,此探测代码是线程安全的,不会修改操作数堆栈或修改局部变量,也是线程安全的。它也不会通过外部调用离开该方法。唯一的先决条件是探测器阵列可用作局部变量。为此,在每个方法的开头,需要添加额外的检测代码以获取与所属类关联的数组实例。为了避免代码重复,初始化被委托给一个静态私有方法$jacocoinit()
,该方法 被添加到每个非接口类中。
上面的探测代码的大小取决于探测器阵列变量的位置和探测器标识符的值,因为可以使用不同的操作码。如下表所示,每个探测的开销范围在4到7个字节的附加字节码之间:
可能的操作码 | 闵。大小[字节] | 最大。大小[字节] |
总: | 4 | 7 |
| 1 | 2 |
| 1 | 3 |
| 1 | 1 |
| 1 | 1 |
1探测器数组是参数后的第一个变量。如果方法参数不消耗超过3个时隙,则可以使用1字节操作码。
- 2用于ID 0到5,2字节的操作码的ID多达127个,3个字节的操作码为IDS高达32768以上32767 ID中的值的1字节的操作码需要额外的常量存储库项。对于普通类文件,它不太可能需要超过32,000个探测器。