jacoco 计算代码覆盖率原理

介绍

官网地址: http://www.eclemma.org/jacoco/html

jacoco 是一个很是经常使用的计算代码覆盖率的工具. 达到的效果就是能够分析出在代码启动到某个时间点那些代码是执行过的, 哪些代码是从没执行的, 从而了解到代码测试的覆盖程度.
支持类级别, 方法级别, 行级别的覆盖率统计. 同时也支持分支级别的统计.java

下图是官网的截图, 绿色表明已执行, 红色表明未执行, 黄色表明执行了一部分, 下方还有在一个类, 一个包的覆盖率的比例. 很是直观了.
image.png

实现原理

若是咱们接到这个需求咱们会怎么实现呢? 一种最简单的方式就是在每行代码上面都作一个标记, 标记这行代码是否被执行, 若是这个标记被执行了, 证实下行代码将会被执行. 其实jacoco的原理也差很少是如此. 至于这个标记是在哪里插入的, 插入了什么, 如何根据标记计算覆盖率等问题就是本文重点.数组

jacoco如何修改代码

jacoco的修改代码的方式有两种app

  • 一种是on-the-fly, 也就是实时修改代码, 原理是使用java agent技术, 是此次着重介绍的.
  • 一种是offline , 也就是因为特殊缘由致使没法使用on-the-fly, 例如环境不支持使用java agent等缘由. (!!!文档介绍)

jacoco插入了什么?

下面是一个例子. 针对下面的代码, jacoco作了什么呢, 咱们来根据jacoco修改后的字节码再进行反编译, 看看修改了什么less

public class JacocoTest {

    public static void main(String[] args) {
        int a = 10;
        a = a+20;
        System.out.println();
        if (a > 10) {
            test1();
        } else {
            test2();
        }
        System.out.println();
    }

    public static void test1() {
        System.out.println("");
    }

    public static void test2() {
        System.out.println("");
        throw new RuntimeException("");
    }
}

jacoco加工后的代码可经过修改jacoco源码输出修改后文件, 并经过反编译工具如 CFR 进行反编译获得, 以下:ide

public class JacocoTest {
    private static transient /* synthetic */ boolean[] $jacocoData;

    public JacocoTest() {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        arrbl[0] = true;
    }

    public static void main(String[] arrstring) {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        int a = 10;
        ++a;
        arrbl[1] = true;
        System.out.println();
        if (++a > 10) {
            arrbl[2] = true;
            JacocoTest.test1();
            arrbl[3] = true;
        } else {
            JacocoTest.test2();
            arrbl[4] = true;
        }
        System.out.println();
        arrbl[5] = true;
    }

    public static void test1() {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        System.out.println("");
        arrbl[6] = true;
    }

    public static void test2() {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        System.out.println("");
        arrbl[7] = true;
        arrbl[8] = true;
        throw new RuntimeException("");
    }

    private static /* synthetic */ boolean[] $jacocoInit() {
        boolean[] arrbl = $jacocoData;
        boolean[] arrbl2 = arrbl;
        if (arrbl != null) return arrbl2;
        Object[] arrobject = new Object[]{4473305039327547984L, "com/xin/test/JacocoTest", 9};
        UnknownError.$jacocoAccess.equals(arrobject);
        arrbl2 = $jacocoData = (boolean[])arrobject[0];
        return arrbl2;
    }
}

一目了然, jacoco的操做和预测的是差很少的, 标记是使用了一个boolean数组, 只要执行过对应的路径就对boolean数组进行赋值, 最后对boolean进行统计便可得出覆盖率. 这个标记官方有个名字叫探针 (Probe)工具

但有个问题: 为何不是全部执行语句后面都有一个探针呢?
这个涉及到探针的插入策略的问题, 官方文档有介绍, 本文也会介绍到.性能

探针插入策略

怎么插入探针能够统计覆盖率的吗?
对于插入策略可分为下面三个问题测试

  • 如何统计某个方法是否被触发
  • 如何统计不一样分支的执行状况
  • 若是统计执行的代码块的执行状况

方法是否被触发

这个比较容易处理, 只须要在方法头或者方法尾加就好了.优化

  • 方法尾加:
    这种处理比较麻烦, 可能有多个return或者throw.能说明方法被执行过, 且说明了探针上面的方法被执行了, 同时也说明了下个语句准备.
  • 方法头加: 处理很简单, 但只能说明方法有进去过.

探针上面是否被执行很重要, 所以jacoco选择在方法结尾处统计.

不一样分支的执行状况

不一样的分支指遇到了例如if判断语句, for判断语句, while, switch等, 会跳到不一样代码块执行, 中间可能会漏执行部分代码. 由于jacoco是针对字节码工做的, 所以这类跳转指令对应的字节码为 GOTO, IFx, TABLESWITCH or LOOKUPSWITCH, 统称为JUMP类型

这种JUMP类型也有两种不一样的状况, 一种是不须要条件jump, 一种是有条件jump

  • 无条件jump (goto), 这种通常出如今continue, break 中, 直接跳转.这种不须要覆盖不跳转的分支和跳转语句. jacoco会在jump以前加个探针, 其实和上面对"方法进行触发"的原理比较接近, 能够当作是 goto 是方法的结尾.

image.png

  • 有条件jump (ifxx), 这种常常出现于if等有条件的跳转语句. 这种通常会存在两个分支须要覆盖. 一个常见的if分支字节码的流程大概是这样子的, 由于字节码是顺序执行的, 因此还须要 goto 的帮助.
function() {
    指令1
    if (){
       指令3
    } else {
       指令4
    }
    指令5
}

image.png

下图是探针插入的状况, 探针1和探针2分别在不一样的地方
image.png

其实条件分支还有另外一种特殊的状况以下. 特殊在于没有else, 指令3 可执行可不执行. 但就算条件为false, 也是一条路径须要进行统计的. 但由于条件为false直接跳转到探针5了, 所以加了探针2后蓝色路径须要加上goto跳过探针2. 这种实际处理起来会比较麻烦.

function() {
    指令1
    if (条件){
       指令3
    }
    指令5
}

image.png

jacoco用了一种更好的方案去加探针2. 那就是翻转条件, 把 if 改为 ifnot . 不影响代码逻辑, 但加探针和goto都很是方便.
image.png

统计执行的代码块的执行状况

这个比较简单, 只要在每行代码前都插入探针便可, 但这样会有个问题. 也就是性能问题, 须要插入大量的探针. 那有没有办法优化一下呢?
若是几行代码都是顺序执行的, 那只要在代码段前, 代码段后放置探针便可. 但还会有问题, 某行代码抛异常了怎么办?
jacoco考虑到非方法调用的指令通常出现异常的几率比较低. 所以对非方法调用的指令不插入探针, 而对每一个方法调用以前都插入探针.
这固然会存在问题, 例如 NullPointerExceptionorArrayIndexOutOfBoundsException 异常出现会致使在临近的非方法调用的指令的覆盖率会有异常.

下图是在 a/0抛出了异常, 但除了test1()上面的探针能捕获 int a = 10; 这个语句以外其余都没法断定是否执行.
image.png

image.png

jacoco代码层面如何实现

主要使用了asm进行类的修改, 须要有些asm的知识储备

对代码的修改点

看了上面的反编译后的例子, 能够看到具体改了3个地方.

  1. 类增长了$jacocoData属性
  2. 每一个方法开头都增长了一个boolean数组的局部变量, 并调用$jacocoInit进行赋值
  3. 类增长了$jacocoInit方法
  4. 对方法里面的语句进行boolean数组里面元素的修改.

代码修改涉及到的类介绍

实现类的修改主要集中在下面几个类 (交互图只是突出重点的类, 省略的不少细节)

image.png

CoverageTransformer: 就是链接java agent的类, 继承了 java.lang.instrument.ClassFileTransformer, 是java agent的典型使用.

Instrumenter: 相似于一个门面, 提供类修改的方法, 没有太多具体实现的逻辑. 输出jacoco修改后的文件也是改了这个类的代码.

IProbeArrayStrategy: 是boolean数组的生成策略类. 用于实现上面1 $jacocoData属性,2 (增长boolean数组并赋值) 和3 \$jacocoInit方法. 由于设计到class的处理和method的处理, 所以在这二者的处理类里面都能看到他的身影.

因为针对不一样的状况,如class的jdk版本号, 是不是接口仍是普通类, 是不是内部类等生成不一样属性和方法, 所以有不一样的实现, 由下面的 ProbeArrayStrategyFactory 工厂进行建立.

ProbeArrayStrategyFactory: 是一个工厂, 负责生成IProbeArrayStrategy.

image.png

后面还有一部分类, 是插入探针的重点类
image.png

ClassProbesAdapter: 这个看名字就知道是个适配器, 没有太多的逻辑. 我的感受这里的设计有点不合理.
缘由是: 适配器模式更适合那些调用类和被调用类二者没什么联系, 只能经过依赖调用被调用类, 但又想解耦被调用类, 所以弄了一个适配器做为中间人屏蔽调用类对被调用类的依赖. 但ClassProbesAdapter 和 被调用类 原本就同父的, 都是依赖ClassVisitor, 只是处理内部类和普通类上面有一些区别, 适配器也没有什么本身特有的流程. 所以使用模板模式更合适, 可读性也更好一些.

ClassInstrumenter: 这个就是上面提到的ClassProbesAdapter的代理的类了, 具体处理逻辑在这里, 其实也没有太多的逻辑, 由于IProbeArrayStrategy 已经把类级别的事情作了,ClassInstrumenter 调用一下就能够了. 而且还要建立方法处理器.
ClassInstrumenter 实际上是一个具体实现, 继承 ClassProbesVisitor, 还有另外一个实现是 ProbeCounter 做用是统计全部探针的数量, 但不作任何处理, 在ProbeArrayStrategyFactory 里面负责统计完以后生成不一样的实现类. 例如探针数为0, 则用NoneProbeArrayStategy便可.

MethodProbesAdapter: 也是一个适配器, 做用是找到那些指令须要插入探针的, 再调用MethodInstrumenter来插入.

MethodInstrumenter: 这个是解决如何插探针的问题. 大部分状况可能直接插入就能够了, 但少部分状况须要作些额外处理才能插入.

ProbeInserter: 这个负责生成插入探针的代码, 例如 插入 arrbl[2] = true; 且由于在方法头增长了一个局部变量, 所以还要处理一些class文件修改层面的事情, 例如剩余代码对局部变量的引用都要+1, StackSize 等都要进行修改. 这个须要了解class文件的格式和字节码一些基础知识.

对方法插入具体的实现

针对上文说到的探针插入策略, 主要介绍就几个点的实现:

  1. 方法尾插入探针
  2. goto 前插入探针, ifxx 后插入探针 (都属于跳转就放一齐了)
  3. 在方法调用前插入探针, 非方法调用不插入探针.

方法尾插入探针

在字节码级别有两个指令是说明到了方法尾的, 那就是 xRETURN or THROW. 是最简单的插入方式.

MethodProbesAdapter
@Override
    public void visitInsn(final int opcode) {
        switch (opcode) {
        case Opcodes.IRETURN:
        case Opcodes.LRETURN:
        case Opcodes.FRETURN:
        case Opcodes.DRETURN:
        case Opcodes.ARETURN:
        case Opcodes.RETURN:
        case Opcodes.ATHROW:
            probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
            break;
        default:
            probesVisitor.visitInsn(opcode);
            break;
        }
    }
MethodInstrumenter
@Override
    public void visitInsnWithProbe(final int opcode, final int probeId) {
        probeInserter.insertProbe(probeId);
        mv.visitInsn(opcode);
    }

goto 前插入探针, ifxx 后插入探针

MethodProbesAdapter
@Override
    public void visitJumpInsn(final int opcode, final Label label) {
        if (LabelInfo.isMultiTarget(label)) {
            probesVisitor.visitJumpInsnWithProbe(opcode, label,
                    idGenerator.nextId(), frame(jumpPopCount(opcode)));
        } else {
            probesVisitor.visitJumpInsn(opcode, label);
        }
    }

LabelInfo.isMultiTarget(label) 这个方法有点特殊, 也说明了不是全部的 jump 都须要加的探针的. 也算是一个小优化吧.
在处理方法前会对方法进行一个控制流分析, 具体逻辑在org.jacoco.agent.rt.internal_43f5073.core.internal.flow.LabelFlowAnalyzer
只有对于一些有可能从多个路径到达的指令(包括正常的顺序执行或者jump跳转)才会须要加探针. 有时候编译器会作一些优化, 致使新增了goto, 例如 一个执行

boolean b = a > 10;

编译出来的代码是

L6 {
             iload1
             bipush 10
             if_icmple L7
             iconst_1 //推1 到栈帧
             goto L8
         }
         L7 {
             iconst_0 //推0 到栈帧
         }
         L8 {
             istore2 //栈帧出栈并把值保存在变量中
         }

goto L8 这个goto加探针就没什么意义, 由于L8段只来自于此指令, 不会从别的地方过来了. 加探针是为了区分不一样分支. 但goto L8 到L8段并无分支. 所以不必加探针了. 固然也不是全部goto都不用加探针. 加入L8段有其余路径能够过来, 那就有必要是从哪一个分支过来的. 这个其实也是jacoco统计的一个点, 分支的执行状况而不只仅是代码覆盖率. 我能够把代码都覆盖了, 但不必定把分支都覆盖了.

MethodInstrumenter
@Override
    public void visitJumpInsnWithProbe(final int opcode, final Label label,
            final int probeId, final IFrame frame) {
        if (opcode == Opcodes.GOTO) {
            //若是是goto则在goto前插入
            probeInserter.insertProbe(probeId);
            mv.visitJumpInsn(Opcodes.GOTO, label);
        } else {
           //若是是其余跳转语句则须要翻转if 且加入探针和goto.
            final Label intermediate = new Label();
            mv.visitJumpInsn(getInverted(opcode), intermediate);
            probeInserter.insertProbe(probeId);
            mv.visitJumpInsn(Opcodes.GOTO, label);
            mv.visitLabel(intermediate);
            frame.accept(mv);
        }
    }

在方法调用前插入探针, 非方法调用不插入探针

一样通过LabelFlowAnalyzer分析以后标记了哪一个指令段是方法调用的

LabelFlowAnalyzer
@Override
    public void visitInvokeDynamicInsn(final String name, final String desc,
            final Handle bsm, final Object... bsmArgs) {
        successor = true;
        first = false;
        markMethodInvocationLine();
    }

    private void markMethodInvocationLine() {
        if (lineStart != null) {
            LabelInfo.setMethodInvocationLine(lineStart);
        }
    }

只要知道作了标记, 就很容易作处理了.

MethodProbesAdapter
@Override
    public void visitLabel(final Label label) {
        if (LabelInfo.needsProbe(label)) {
            if (tryCatchProbeLabels.containsKey(label)) {
                probesVisitor.visitLabel(tryCatchProbeLabels.get(label));
            }
            probesVisitor.visitProbe(idGenerator.nextId());
        }
        probesVisitor.visitLabel(label);
    }
LabelInfo
public static boolean needsProbe(final Label label) {
        final LabelInfo info = get(label);
        return info != null && info.successor
                && (info.multiTarget || info.methodInvocationLine);
    }

对实现只分析了一部分比较核心的, 还有对trycatch, switch等的处理可本身去探索.

性能影响

jacoco文档有介绍

The control flow analysis and probe insertion strategy described in this document allows to efficiently record instruction and branch coverage. In total classes instrumented with JaCoCo increase their size by about 30%. Due to the fact that probe execution does not require any method calls, only local instructions, the observed execution time overhead for instrumented applications typically is less than 10%.
相关文章
相关标签/搜索