深入jacoco探针还原过程解析

1、必要前提知识

必要前提知识不在本文讨论范围内,需要了解jacoco的基本知识,jacoco的指令Instruction、ASM Label、opcode字节码基础知识、ASM访问者模式等。

2、前文

这里我只借助源码讨论一下探针还原的过程,至于jacoco怎么从指令Instruction计算出来覆盖率不在本文的讨论范围内。

从agent dump下来的exec文件只存有每个类的探针probes数据,probes并没有class的bytes流信息,只记录了探针被执行的情况。那么还原探针,计算出覆盖率就必须要依赖对应的class文件,理论上怎么插桩就应该怎么还原,插桩和还原都是同一个ClassProbesAdapter,访问顺序也是一样。探针还原大概的流程如下流程图。在这里插入图片描述
思考一个问题,比如一段代码: System.out.println(“test”);怎么判断这行代码是否执行过。jacoco的做法是在这行代码的下方插入一个探针,boolean[0]=true;,如果boolean[0]=true已经被执行,那么我们就可以断定在这个探针之前的这行代码System.out.println(“test”)肯定是被执行过了。

我们进一步进入到指令的颗粒度,这行代码有几行指令呢?

Label labe0 = new Label();
methodVisitor.visitLabel(labe0);
methodVisitor.visitLineNumber(15, labe0);
methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
methodVisitor.visitLdcInsn("test");
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

用ASM翻译后,可以看到这行代码一共有四行指令,那么在boolean[0]=true;已经被执行的情况下,这四行指令也肯定是被执行过了,也并不需要在每个指令后面都插入探针。
现在让我们再加入一行代码System.out.println(“test1”);如果labe1的指令被执行过了,而且labe0到labe1是连续的(successor=true,不存在goto switch throw等指令),我们是不是可以推断出labe0的指令也一样被执行过。jacoco使用Label.LabelInfo记录是否连续,并且标记lable是否jump target,是否multiTarget;当然这是非常理想的情况,因为System.out.println(“test”)即使这行代码已经执行了,但是可能会抛出异常,System.out.println(“test1”)就无法执行到,所以需要在两个label后面都需要插入探针。最理想情况下每行代码都不会抛出异常,那么只需要在每个流程分支branch的最后面插入一行代码就可以了,当然这是不可能的。

```java
 Label labe0 = new Label();
            methodVisitor.visitLabel(labe0);
            methodVisitor.visitLineNumber(15, labe0);
            methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            methodVisitor.visitLdcInsn("test");
            methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
	        Label labe1 = new Label();
            methodVisitor.visitLabel(labe1);
            methodVisitor.visitLineNumber(15, labe1);
            methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            methodVisitor.visitLdcInsn("test");
            methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

上面都是顺序执行的顺序,那么存在if等分支流程的情况下呢,label就不连续了,比如下面这段代码,怎么判断System.out.println(“test”)和System.out.println(“test1”);是否执行过了,因为存在if流程分支,goto指令,所以label和label之间就不连续了,System.out.println(“test1”)执行过并不能断定System.out.println(“test”)也被执行了。

第三个例子:

    public void test(int num) {
14    if(num>5){
15        System.out.println("test");
16    }else {
17        System.out.println("test1");
18     }
19
20  }
使用ASM翻译后
   Label label0 = new Label();
        methodVisitor.visitLabel(label0);
        methodVisitor.visitLineNumber(14, label0);
        methodVisitor.visitVarInsn(ILOAD, 1);
        methodVisitor.visitInsn(ICONST_5);
        Label label1 = new Label();
        methodVisitor.visitJumpInsn(IF_ICMPLE, label1);
        Label label2 = new Label();
        methodVisitor.visitLabel(label2);
        methodVisitor.visitLineNumber(15, label2);
        methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        methodVisitor.visitLdcInsn("test");
        methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        Label label3 = new Label();
        methodVisitor.visitJumpInsn(GOTO, label3);
        methodVisitor.visitLabel(label1);
        methodVisitor.visitLineNumber(17, label1);
        methodVisitor.visitFrame(Opcodes.F_SAME, 0, null, 0, null);
        methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        methodVisitor.visitLdcInsn("test1");
        methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        methodVisitor.visitLabel(label3);
        methodVisitor.visitLineNumber(20, label3);
        methodVisitor.visitFrame(Opcodes.F_SAME, 0, null, 0, null);
        methodVisitor.visitInsn(RETURN);
        Label label4 = new Label();
        methodVisitor.visitLabel(label4);
        methodVisitor.visitLocalVariable("this", "Lcom/sh/isoftstone/controller/LpController;", null, label0, label4, 0);
        methodVisitor.visitLocalVariable("num", "I", null, label0, label4, 1);
        methodVisitor.visitMaxs(2, 2);
        methodVisitor.visitEnd();

可以看到label2对应的代码行System.out.println(“test”)紧接着的label3指令包含了methodVisitor.visitJumpInsn(GOTO, label3),label3存在指令 methodVisitor.visitJumpInsn(GOTO, label3),goto是无条件条件跳转指令,也就是说label2和label3是一脉相承的,label2和label1不是一路人。但是label1和lable3也是一脉相承的,在label3前面插入指令并不能区分出来System.out.println(“test”)或者System.out.println(“test1”)哪个被执行过了。所以就必须采取策略,在goto指令之前插入探针。而jacoco正是这样所做的,具体后面会说到。

前面部分没什么好说的,各位看官老爷懂得都懂,重点是ClassProbesAdapter的匿名内部类MethodSanitizer中的visitEnd()方法中的LabelFlowAnalyzer.markLabels(this),这个方法标记了label是否jump Target,是否multiTarget,并且赋值info的successor。

先看visitLabel,这是每一行代码指令的入口。

public void visitLabel(final Label label) {
	if (first) {
		LabelInfo.setTarget(label);
	}
	if (successor) {
		LabelInfo.setSuccessor(label);
	}
}

下面以第三个例子说明怎么还原探针过程,ASM访问者模式开始顺序访问第一个label0,这时候first为真,所以LabelInfo.setTarget(label),第一个label,前面肯定不需要插桩,所以successor还是fasle,接着看setTarget(label)方法

public static void setTarget(final Label label) {
		final LabelInfo info = create(label);
		if (info.target || info.successor) {
			info.multiTarget = true;
		} else {
			info.target = true;
		}
	}

这个方法给label加入属性labelInfo,labelInfo记录了label是否是jump targt、multiTarget。如果label已经是target或者successor=true,那么就赋值multiTarget=true,否则赋值target=true。

接着访问指令
methodVisitor.visitLineNumber(14, label0);
methodVisitor.visitVarInsn(ILOAD, 1);
methodVisitor.visitInsn(ICONST_5),
访问指令时会赋值LabelInfo successor=true和first=false,注意这里LabelInfo successor=true了。

开始访问第二个label1,根据上面visitLabel(final Label label)分析,label1此时target=true,multiTarget = false,successor=true,LabelInfo successor=true.

开始访问label1的指令,label1只有一个指令methodVisitor.visitJumpInsn(IF_ICMPLE, label1)。

@Override
public void visitJumpInsn(final int opcode, final Label label) {
	LabelInfo.setTarget(label);
	if (opcode == Opcodes.JSR) {
		throw new AssertionError("Subroutines not supported.");
	}
	successor = opcode != Opcodes.GOTO;
	first = false;
}

visitJumpInsn首先执行setTarget方法,设置label1为target,因为successor=true,所以赋值multiTarget=true。因为IF_ICMPLE !=Opcodes.GOTO,所以此时label1的属性为target=true,multiTarget = true,successor=true,,LabelInfo successor=true.

开始访问第三个lable2,根据上面的visitLabel分析,此时label2的target=false,multiTarget = false, LabelInfo successor=true。访问lable2的指令, 设置successor=true

methodVisitor.visitLineNumber(15, label2);
methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
methodVisitor.visitLdcInsn("test");
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); 

开始访问第四个lable3,根据上面的visitLabel分析,lable3只有一个跳转指令methodVisitor.visitJumpInsn(GOTO, label3);无条件跳转到label3,此时GOTO==Opcodes.GOTO,所以此时target=true,multiTarget = true,successor=true,LabelInfo successor=false。根据needsProbe(final Label label)判断(这个后面会说到),此时lable3前面需要插入探针,实现在goto指令之前插入探针。

开始访问第五个labl1,根据上面的visitLabel分析,label1已经标记为了target了,再次visitLabel的时候,因为successor=false,所以visitLabel不进行任何操作。labl1此时target=true,multiTarget = true,successor=true。开始访问label1的指令,赋值successor=true;

    methodVisitor.visitFrame(Opcodes.F_SAME, 0, null, 0, null);
    methodVisitor.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
    methodVisitor.visitLdcInsn("test1");
    methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

为了节省篇幅,后面的label就不重复说明了。LabelFlowAnalyzer.markLabels(this)执行完成后,就完成了label的target、multiTarget和successor标记了。接下来就回到MethodProbesAdapter具体的还原过程。我们还是从visitLabel(final Label label)开始。

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.needsProbe(label),插桩也是同样的逻辑。

public static boolean needsProbe(final Label label) {
	final LabelInfo info = get(label);
	return info != null && info.successor && (info.multiTarget || info.methodInvocationLine);
}

labelInfo不能为空,且info.successor=true,且multiTarget或methodInvocationLine,访问label指令集只要存在一个visitMethodInsn那么lable就是方法调用行。到这里就清楚了,label是否插桩,是根据successor、multiTarget和methodInvocationLine属性判断的,label的插桩的策略就清楚了,如果label是连续的,且label都没有存在方法调用行,那么仅仅需要在最后一个label后面插入探针,这里可以测试下。

int a=888;
int b=5;
int c=8;
String test="test";

在这里插入图片描述
使用Arthurs反编译后,可以看到,多行label在不存在方法MethodInsn指令时,且连续时候,只需要在最后一个label插入即可,前面也说到了,假设赋值语句不会出现运行异常的情况这种插桩策略是没问题,但是,赋值语句也可能出现异常,比如以下代码

        Object obj = "Hello";
        int a=888;
        int b=5;
        int c=8;
        Integer num = (Integer) obj;

在这里插入图片描述
这时候就有问题了,即使label行Integer num = (Integer) obj前面的代码都执行了,但是在这一行运行时就会抛出异常,所以blArray[7]探针并无法记录到,这应该算是jacoco的一个bug把,不知道新版本解决没,我是基于0.8.7版本分析,暂且不表,我们继续下面的流程。补充,已经向官方提交issue,https://github.com/jacoco/jacoco/issues/1644,官方也接受了,后面版本可能会修改。

第一个label0因为successor=false,不满足还原条件,所以不需要还原。接着访问指令

methodVisitor.visitLineNumber(14, label0);
methodVisitor.visitVarInsn(ILOAD, 1);
methodVisitor.visitInsn(ICONST_5)

MethodProbesAdapter并没有重写visitVarInsn的方法,只重写了visitInsn,不满足条件,不需要还原。

case Opcodes.IRETURN:
case Opcodes.LRETURN:
case Opcodes.FRETURN:
case Opcodes.DRETURN:
case Opcodes.ARETURN:
case Opcodes.RETURN:
case Opcodes.ATHROW:

开始访问第二个label1,由于上面分析可知label1的属性target=true,multiTarget = true,successor=true,需要还原探针。进入probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId())方法。重点看 addBranch(final boolean executed, final int branch) 方法。

  public void addBranch(final boolean executed, final int branch) {
        branches++;
        if (executed) {
            propagateExecutedBranch(this, branch);
        }
    }

如果探针被执行过,那么进入propagateExecutedBranch(this, branch);label中的指令为什么可以被标记执行到,就是依赖这个方法。

   private static void propagateExecutedBranch(Instruction insn, int branch) {
        // No recursion here, as there can be very long chains of instructions
        while (insn != null) {
            if (!insn.coveredBranches.isEmpty()) {
                insn.coveredBranches.set(branch);
                break;
            }
            insn.coveredBranches.set(branch);
            branch = insn.predecessorBranch;
            insn = insn.predecessor;
        }
    }

如果指令不是null,则进入循环,给coveredBranches赋值,同时把 insn 指向上一个指令insn.predecessor,直到insn.predecessor为null。insn.predecessor是在访问指令的过程中赋值了,由addBranch方法中target.predecessor = this赋值。

public void addBranch(final Instruction target, final int branch) {
    branches++;
    target.predecessor = this;
    target.predecessorBranch = branch;
    if (!target.coveredBranches.isEmpty()) {
        propagateExecutedBranch(this, branch);
    }
}

后面就不再赘述了,过程都是差不多一样的,多看看源码就清楚了。写的有点凌乱,见谅。

在这里插入图片描述

  • 25
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

czx758

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值