JaCoCo計算程式碼覆蓋率原理

xin497發表於2020-04-06

介紹

官網地址: www.eclemma.org/jacoco/

JaCoCo 是一個非常常用的計算程式碼覆蓋率的工具. 達到的效果就是可以分析出在程式碼啟動到某個時間點那些程式碼是執行過的, 哪些程式碼是從沒執行的, 從而瞭解到程式碼測試的覆蓋程度. 支援類級別, 方法級別, 行級別的覆蓋率統計. 同時也支援分支級別的統計.

下圖是官網的截圖, 綠色代表已執行, 紅色代表未執行, 黃色代表執行了一部分, 下方還有在一個類, 一個包的覆蓋率的比例. 非常直觀了.

JaCoCo計算程式碼覆蓋率原理

實現原理

如果我們接到這個需求我們會怎麼實現呢? 一種最簡單的方式就是在每行程式碼上面都做一個標記, 標記這行程式碼是否被執行, 如果這個標記被執行了, 證明下行程式碼將會被執行. 其實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 是方法的結尾.

JaCoCo計算程式碼覆蓋率原理

  • 有條件jump (ifxx), 這種經常出現於if等有條件的跳轉語句. 這種一般會存在兩個分支需要覆蓋. 一個常見的if分支位元組碼的流程大概是這樣子的, 因為位元組碼是順序執行的, 所以還需要 goto 的幫助.
function() {
    指令1
    if (){
       指令3
    } else {
       指令4
    }
    指令5
}
複製程式碼

JaCoCo計算程式碼覆蓋率原理

下圖是探針插入的情況, 探針1和探針2分別在不同的地方

JaCoCo計算程式碼覆蓋率原理

其實條件分支還有另一種特殊的情況如下. 特殊在於沒有else, 指令3 可執行可不執行. 但就算條件為false, 也是一條路徑需要進行統計的. 但因為條件為false直接跳轉到探針5了, 因此加了探針2後藍色路徑需要加上goto跳過探針2. 這種實際處理起來會比較麻煩.

function() {
    指令1
    if (條件){
       指令3
    }
    指令5
}
複製程式碼

JaCoCo計算程式碼覆蓋率原理

JaCoCo用了一種更好的方案去加探針2. 那就是翻轉條件, 把 if 改成 ifnot . 不影響程式碼邏輯, 但加探針和goto都非常方便.

JaCoCo計算程式碼覆蓋率原理

統計執行的程式碼塊的執行情況

這個比較簡單, 只要在每行程式碼前都插入探針即可, 但這樣會有個問題. 也就是效能問題, 需要插入大量的探針. 那有沒有辦法優化一下呢? 如果幾行程式碼都是順序執行的, 那隻要在程式碼段前, 程式碼段後放置探針即可. 但還會有問題, 某行程式碼拋異常了怎麼辦? JaCoCo考慮到非方法呼叫的指令一般出現異常的概率比較低. 因此對非方法呼叫的指令不插入探針, 而對每個方法呼叫之前都插入探針. 這當然會存在問題, 例如 NullPointerExceptionorArrayIndexOutOfBoundsException 異常出現會導致在臨近的非方法呼叫的指令的覆蓋率會有異常.

下圖是在 a/0丟擲了異常, 但除了test1()上面的探針能捕獲 int a = 10; 這個語句之外其他都無法判定是否執行.

JaCoCo計算程式碼覆蓋率原理

JaCoCo計算程式碼覆蓋率原理

jacoco程式碼層面如何實現

主要使用了asm進行類的修改, 需要有些asm的知識儲備

對程式碼的修改點

看了上面的反編譯後的例子, 可以看到具體改了3個地方.

  1. 類增加了$jacocoData屬性
  2. 每個方法開頭都增加了一個boolean陣列的區域性變數, 並呼叫$jacocoInit進行賦值
  3. 類增加了$jacocoInit方法
  4. 對方法裡面的語句進行boolean陣列裡面元素的修改.

程式碼修改涉及到的類介紹

實現類的修改主要集中在下面幾個類 (互動圖只是突出重點的類, 省略的很多細節)

JaCoCo計算程式碼覆蓋率原理

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.

JaCoCo計算程式碼覆蓋率原理

後面還有一部分類, 是插入探針的重點類

JaCoCo計算程式碼覆蓋率原理

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

相關文章