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);
}
}
后面就不再赘述了,过程都是差不多一样的,多看看源码就清楚了。写的有点凌乱,见谅。