5

介绍

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

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

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

实现原理

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

JaCoCo如何修改代码

JaCoCo的修改代码的方式有两种

  • 一种是on-the-fly, 也就是实时修改代码, 原理是使用java agent技术, 是这次着重介绍的.
  • 一种是offline , 也就是由于特殊原因导致无法使用on-the-fly, 例如环境不支持使用java agent等原因.

JaCoCo插入了什么?

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

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 进行反编译得到, 如下:

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%.

文中提到的控制流分析和探针的插入策略能高效的记录指令和分支的覆盖情况. 在所有类都被JaCoCo注入的情况下大小大概会增加30%, 由于探针执行并不需要任何方法调用, 只是执行本地的指令, 因此被注入的应用执行时间开销一般会小于10%.


简简单单
18 声望1 粉丝