官網地址: http://www.eclemma.org/jacoco/html
jacoco 是一個很是經常使用的計算代碼覆蓋率的工具. 達到的效果就是能夠分析出在代碼啓動到某個時間點那些代碼是執行過的, 哪些代碼是從沒執行的, 從而瞭解到代碼測試的覆蓋程度.
支持類級別, 方法級別, 行級別的覆蓋率統計. 同時也支持分支級別的統計.java
下圖是官網的截圖, 綠色表明已執行, 紅色表明未執行, 黃色表明執行了一部分, 下方還有在一個類, 一個包的覆蓋率的比例. 很是直觀了.
若是咱們接到這個需求咱們會怎麼實現呢? 一種最簡單的方式就是在每行代碼上面都作一個標記, 標記這行代碼是否被執行, 若是這個標記被執行了, 證實下行代碼將會被執行. 其實jacoco的原理也差很少是如此. 至於這個標記是在哪裏插入的, 插入了什麼, 如何根據標記計算覆蓋率等問題就是本文重點.數組
jacoco的修改代碼的方式有兩種app
下面是一個例子. 針對下面的代碼, 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)工具
但有個問題: 爲何不是全部執行語句後面都有一個探針呢?
這個涉及到探針的插入策略的問題, 官方文檔有介紹, 本文也會介紹到.性能
怎麼插入探針能夠統計覆蓋率的嗎?
對於插入策略可分爲下面三個問題測試
這個比較容易處理, 只須要在方法頭或者方法尾加就好了.優化
探針上面是否被執行很重要, 所以jacoco選擇在方法結尾處統計.
不一樣的分支指遇到了例如if判斷語句, for判斷語句, while, switch等, 會跳到不一樣代碼塊執行, 中間可能會漏執行部分代碼. 由於jacoco是針對字節碼工做的, 所以這類跳轉指令對應的字節碼爲 GOTO
, IFx
, TABLESWITCH
or LOOKUPSWITCH
, 統稱爲JUMP類型
這種JUMP類型也有兩種不一樣的狀況, 一種是不須要條件jump, 一種是有條件jump
function() { 指令1 if (){ 指令3 } else { 指令4 } 指令5 }
下圖是探針插入的狀況, 探針1和探針2分別在不一樣的地方
其實條件分支還有另外一種特殊的狀況以下. 特殊在於沒有else, 指令3 可執行可不執行. 但就算條件爲false, 也是一條路徑須要進行統計的. 但由於條件爲false直接跳轉到探針5了, 所以加了探針2後藍色路徑須要加上goto跳過探針2. 這種實際處理起來會比較麻煩.
function() { 指令1 if (條件){ 指令3 } 指令5 }
jacoco用了一種更好的方案去加探針2. 那就是翻轉條件, 把 if 改爲 ifnot . 不影響代碼邏輯, 但加探針和goto都很是方便.
這個比較簡單, 只要在每行代碼前都插入探針便可, 但這樣會有個問題. 也就是性能問題, 須要插入大量的探針. 那有沒有辦法優化一下呢?
若是幾行代碼都是順序執行的, 那隻要在代碼段前, 代碼段後放置探針便可. 但還會有問題, 某行代碼拋異常了怎麼辦?
jacoco考慮到非方法調用的指令通常出現異常的機率比較低. 所以對非方法調用的指令不插入探針, 而對每一個方法調用以前都插入探針.
這固然會存在問題, 例如 NullPointerException
orArrayIndexOutOfBoundsException
異常出現會致使在臨近的非方法調用的指令的覆蓋率會有異常.
下圖是在 a/0拋出了異常, 但除了test1()上面的探針能捕獲 int a = 10; 這個語句以外其餘都沒法斷定是否執行.
主要使用了asm進行類的修改, 須要有些asm的知識儲備
看了上面的反編譯後的例子, 能夠看到具體改了3個地方.
實現類的修改主要集中在下面幾個類 (交互圖只是突出重點的類, 省略的不少細節)
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.
後面還有一部分類, 是插入探針的重點類
ClassProbesAdapter: 這個看名字就知道是個適配器, 沒有太多的邏輯. 我的感受這裏的設計有點不合理.
緣由是: 適配器模式更適合那些調用類和被調用類二者沒什麼聯繫, 只能經過依賴調用被調用類, 但又想解耦被調用類, 所以弄了一個適配器做爲中間人屏蔽調用類對被調用類的依賴. 但ClassProbesAdapter 和 被調用類 原本就同父的, 都是依賴ClassVisitor, 只是處理內部類和普通類上面有一些區別, 適配器也沒有什麼本身特有的流程. 所以使用模板模式更合適, 可讀性也更好一些.
ClassInstrumenter: 這個就是上面提到的ClassProbesAdapter的代理的類了, 具體處理邏輯在這裏, 其實也沒有太多的邏輯, 由於IProbeArrayStrategy 已經把類級別的事情作了,ClassInstrumenter 調用一下就能夠了. 而且還要建立方法處理器.
ClassInstrumenter 實際上是一個具體實現, 繼承 ClassProbesVisitor, 還有另外一個實現是 ProbeCounter 做用是統計全部探針的數量, 但不作任何處理, 在ProbeArrayStrategyFactory 裏面負責統計完以後生成不一樣的實現類. 例如探針數爲0, 則用NoneProbeArrayStategy便可.
MethodProbesAdapter: 也是一個適配器, 做用是找到那些指令須要插入探針的, 再調用MethodInstrumenter來插入.
MethodInstrumenter: 這個是解決如何插探針的問題. 大部分狀況可能直接插入就能夠了, 但少部分狀況須要作些額外處理才能插入.
ProbeInserter: 這個負責生成插入探針的代碼, 例如 插入 arrbl[2] = true; 且由於在方法頭增長了一個局部變量, 所以還要處理一些class文件修改層面的事情, 例如剩餘代碼對局部變量的引用都要+1, StackSize 等都要進行修改. 這個須要瞭解class文件的格式和字節碼一些基礎知識.
針對上文說到的探針插入策略, 主要介紹就幾個點的實現:
在字節碼級別有兩個指令是說明到了方法尾的, 那就是 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); }
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%.