jacoco插桩源码,看这一篇就够了

2023-11-11 15:04

本文主要是介绍jacoco插桩源码,看这一篇就够了,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

知识储备

众所周知,jacoco的功能主要分成两块:

  • jacoco agent
  • jacoco cli

其中jacoco agent主要用来对业务方服务进行插装,而cli则提供一些工具对插桩数据进行处理,比如dump,merge,report等,今天我们着重通过源码来分析jacoco的插桩过程,在分析其插桩逻辑前,我们需要一定的知识储备,主要包括但不限于

ASM,Java Instrumentation等

Java ASM是一个Java字节码操控和分析框架。它能够用于动态生成代码,动态修改已有代码,执行静态代码分析等操作。ASM提供了一些基础的字节码转换和处理工具,开发人员可以在此基础上进行扩展,构建出复杂的编程工具和技术。

ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 从使用者的角度来看是数据(它们以 .class 文件的形式存在于磁盘上),但是从生产者或者消费者(比如类加载器)的角度来看,它就是一种产品。ASM 提供了一些类,以使 Java class 从类文件转移到内存中的过程中,可以“加工”这个产品。

Java ASM 的优点在于它功能强大并且高效,但是由于直接面对的是字节码,所以使用起来也比较复杂

简而言之,ASM就是一个对java字节码进行操作的工具,其核心功能主要采用访问者模式,对类、方法、属性、指令等进行遍历,然后访问或者修改。

Instrumentation是基于JVMTI技术,JVMTI代表Java Virtual Machine Tools Interface,是Java虚拟机提供的一组原生接口,供开发者构建各种工具和实用程序,如监控工具、调试器、分析器等。

JVMTI具有强大的能力,包括但不限于:

  1. 检查和修改类和对象的状态。
  2. 监视线程的创建和销毁。
  3. 监视类的加载和卸载。
  4. 监视虚拟机的垃圾回收。
  5. 设置断点和监听各种事件。
  6. 获取方法调用的栈轨迹。

JVMTI是Java虚拟机的一部分,因此可以在Java程序执行的任何阶段进行操作,包括启动阶段、运行阶段和退出阶段。

尽管JVMTI具有强大的功能,但由于它是一种底层接口,所以使用起来可能比较复杂,需要深入理解虚拟机和字节码的工作原理。而且,错误的使用可能会导致程序崩溃或其他未预期的行为。因此,建议只在开发调试和监控工具等专业领域使用JVMTI。

Java Instrumentation API是Java SE 5.0引入的一种强大的工具,可以用来修改已加载到JVM中的类的字节码。这个API允许Java开发人员在运行时查看和修改类和对象的状态。

Java Instrumentation API常用于一些高级的开发任务,比如性能监控、分析和优化、代码覆盖率分析、故障排查等等。例如,开发人员可以使用Java Instrumentation来监视程序的内存使用情况,或者插入额外的代码来追踪和记录方法的调用情况。

要使用Java Instrumentation,你需要创建一个特殊的"agent"类,并且在JVM启动时通过特定的命令行参数将其加载到JVM中。这个agent类需要实现特定的接口,并且可以定义一些premain或者agentmain方法来进行初始化工作。

虽然Java Instrumentation是一个强大的工具,但是也需要谨慎使用。修改运行时的类可能会引入一些不可预见的行为,甚至可能导致程序崩溃。因此,最好只在明确知道自己在做什么的情况下使用它。

没错,jacoco的agent能附着在业务服务上运行,其使用的就是Instrumentation,那么,我们首先来了解一下Instrumentation的使用方法

  • 首先,你需要创建一个Agent类,它应该包含一个名为premain的静态方法。JVM会在启动时调用这个方法,并通过该方法的参数提供一个Instrumentation对象:
public class MyAgent {public static void premain(String agentArgs, Instrumentation inst) {inst.addTransformer(new ClassFileTransformer() {@Overridepublic byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {// 使用ASM等字节码操作工具对classfileBuffer进行修改,// 例如插入在方法调用前后打印消息的代码。// 假设你已经有一个modifyByteCode方法来做这个工作:return modifyByteCode(classfileBuffer);}});}
}
  • 然后,你需要在JVM启动时指定你的Agent类。这可以通过-javaagent命令行参数实现:
java -javaagent:myagent.jar MyApplication

其中,myagent.jar是包含你的Agent类的JAR文件,MyApplication是你的应用程序的主类。请注意,你的JAR文件需要包含一个名为MANIFEST.MF的清单文件,其中应该包含一个Premain-Class属性来指定你的Agent类:

Premain-Class: MyAgent

在以上的示例中ClassFileTransformer是一个接口,实现这个接口,然后注册到Instrumentation上去,当类被加载到内存中的时候就会触发其transform方法,我们就可以在这个阶段对字节码进行修改,从而达到我们的目的。是不是就像观察者模式。

jacoco agent 源码分析

在这里插入图片描述

jacoco整个插桩过程如上图所示,PreMain注册了CoverageTransformer,当类加载到jvm的时候就会触达调用instrumenter方法,然后Instrumenter方法调用了类Instrumenter的instrument方法, 类Instrumenter的instrument方法调用了类ProbeArrayStrategyFactory的createFor方法,然后根据不同的jdk版本生成不同的策略,然后类Instrumenter的instrument方法调用了类ClassProbesAdapter的visitMethod方法, 接着类ClassProbesAdapter的visitMethod方法调用了类MethodInstrumenter的visitMethod方法, 类MethodInstrumenter的visitMethod方法调用了类ProbeInserter的insertProbe方法 类ProbeInserter的insertProbe方法调用了类ClassFieldProbeArrayStrategy的storeInstance方法 ClassProbesAdapte调用了ClassFieldProbeArrayStrategy的addMembers方法 完成了整个类的插桩。
看了上面的图是不是感觉有点绕,没关系我们根据源码逐步来分析。首先,我们指定了preMain的入口
在这里插入图片描述

	public static void premain(final String options, final Instrumentation inst)throws Exception {//解析参数final AgentOptions agentOptions = new AgentOptions(options);//创建Agent实例final Agent agent = Agent.getInstance(agentOptions);//创建Runtime实例final IRuntime runtime = createRuntime(inst);//启动Runtimeruntime.startup(agent.getData());//这里把自定义的CoverageTransformer加入到了inst的transformer列表中,即注册,当类加载的时候会回调CoverageTransformer里的instrument方法inst.addTransformer(new CoverageTransformer(runtime, agentOptions,IExceptionLogger.SYSTEM_ERR));}

CoverageTransformer实现了ClassFileTransformer接口,当类加载的时候会回调CoverageTransformer里的instrument方法

	public byte[] transform(final ClassLoader loader, final String classname,final Class<?> classBeingRedefined,final ProtectionDomain protectionDomain,final byte[] classfileBuffer) throws IllegalClassFormatException {// We do not support class retransformation:if (classBeingRedefined != null) {return null;}//过滤掉不需要插桩的类if (!filter(loader, classname, protectionDomain)) {return null;}try {//读取class类文件classFileDumper.dump(classname, classfileBuffer);//对class类文件进行字节码插桩,然后返回插桩后的字节码return instrumenter.instrument(classfileBuffer, classname);} catch (final Exception ex) {final IllegalClassFormatException wrapper = new IllegalClassFormatException(ex.getMessage());wrapper.initCause(ex);// Report this, as the exception is ignored by the JVM:logger.logExeption(wrapper);throw wrapper;}}

接着我们看Instrumenter类的instrument方法

	private byte[] instrument(final byte[] source) {final long classId = CRC64.classId(source);//创建ClassReaderfinal ClassReader reader = InstrSupport.classReaderFor(source);//创建ClassWriterfinal ClassWriter writer = new ClassWriter(reader, 0) {@Overrideprotected String getCommonSuperClass(final String type1,final String type2) {throw new IllegalStateException();}};//主要根据不同jdk版本生成不同的策略final IProbeArrayStrategy strategy = ProbeArrayStrategyFactory.createFor(classId, reader, accessorGenerator);final int version = InstrSupport.getMajorVersion(reader);final ClassVisitor visitor = new ClassProbesAdapter(new ClassInstrumenter(strategy, writer),InstrSupport.needsFrames(version));//遍历类文件进行插桩reader.accept(visitor, ClassReader.EXPAND_FRAMES);return writer.toByteArray();}

整体过程就是对class字节码进行读取,然后遍历修改,最后再写回,ProbeArrayStrategyFactory是一个探针数组策略工厂,根据不同的jdk版本生成不同的策略,正常情况下我们使用的都是ClassFieldProbeArrayStrategy策略
根据上面的代码,生成探针数组策略后,会创建一个ClassProbesAdapter,这个类是一个适配器,它主要适配了ClassInstrumenter类和ClassAnalyzer类(用来报告生成),适配器主要做了两件事,一个是遍历类的每个方法,一个是在方法结束时统计探针数量
这里需要注意,假设我们的原始类是这样

public class Example {private final String message;public Example(String message) {this.message = message;}public String getMessage() {return message;}
}

那么经过插桩后的类是这样的

public class Example {private final String message;private static transient final boolean[] $jacocoData;public Example(String message) {boolean[] arr = $jacocoInit();super();arr[0] = true;this.message = message;arr[1] = true;}public String getMessage() {$jacocoData[2] = true;return message;}private static boolean[] $jacocoInit() {boolean[] arr = new boolean[3];$jacocoData = arr;return arr;}
}

插桩有几个部分,首先是添加了属性字段 j a c o c o D a t a ,然后添加了初始化探针方法 jacocoData,然后添加了初始化探针方法 jacocoData,然后添加了初始化探针方法jacocoInit(),最后是对每个方法进行插桩,那个每个方法需要插多少个桩呢
jacoco agent的操作是,先对每个方法进行插桩,同时统计探针的数量,然后最后再类访问结束的时候,插入了属性和探针初始化方法
所有我们主要看ClassProbesAdapter类的visitMethod方法调用了

		final MethodProbesVisitor mv = cv.visitMethod(access, name, desc,signature, exceptions);

和类访问结束时调用了

	public void visitEnd() {cv.visitTotalProbeCount(counter);super.visitEnd();}

我们知道这里的cv在插桩阶段其实就是我们的ClassInstrumenter类
那么,我们再来看看ClassInstrumenter类的visitMethod方法和visitTotalProbeCount方法

public MethodProbesVisitor visitMethod(final int access, final String name,final String desc, final String signature,final String[] exceptions) {InstrSupport.assertNotInstrumented(name, className);final MethodVisitor mv = cv.visitMethod(access, name, desc, signature,exceptions);if (mv == null) {return null;}final MethodVisitor frameEliminator = new DuplicateFrameEliminator(mv);final ProbeInserter probeVariableInserter = new ProbeInserter(access,name, desc, frameEliminator, probeArrayStrategy);return new MethodInstrumenter(probeVariableInserter,probeVariableInserter);}

这里主要是创建了一个MethodInstrumenter,其包含了一个ProbeInserter探针插入器,而visitTotalProbeCount方法则是对属性和初始化方法的插入

   	public void visitTotalProbeCount(final int count) {probeArrayStrategy.addMembers(cv, count);}

看到这里,我们都还没有对探针进行操作,但是我们知道了两个核心类,一个是probeArrayStrategy,一个是ProbeInserter,ProbeInserter是针对每个方法进行探针插入的核心类,probeArrayStrategy是对类属性和初始化方法进行插入的核心类
MethodInstrumenter的方法调用入口同样是在ClassProbesAdapter类的visitMethod方法中

	final MethodProbesAdapter probesAdapter = new MethodProbesAdapter(methodProbes, ClassProbesAdapter.this);if (trackFrames) {final AnalyzerAdapter analyzer = new AnalyzerAdapter(ClassProbesAdapter.this.name, access, name, desc,probesAdapter);probesAdapter.setAnalyzer(analyzer);methodProbes.accept(this, analyzer);} else {// 这里调用的就是mv的钩子方法methodProbes.accept(this, probesAdapter);}

这里调用了我们从ClassInstrumenter返回的MethodProbesAdapter,MethodProbesAdapter同样是一个适配器,其插桩过程中的核心适配是MethodInstrumenter类
MethodProbesAdapter类的遍历中有个核心方法

	/*** 访问标签,Label是字节码中的位置指示,它可以作为跳转指令的目标位置,也可以用于标记异常处理区块的开始和结束等。** @param label 标签*/@Overridepublic 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);}@Overridepublic 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);}}@Overridepublic 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);}}

这里三个方法分别会调用MethodInstrumenter类的下面几个方法

	/*** 无条件插入探针** @param probeId 调查id*/@Overridepublic void visitProbe(final int probeId) {probeInserter.insertProbe(probeId);}/*** 在返回指令前插入探针** @param opcode  操作码* @param probeId 调查id*/@Overridepublic void visitInsnWithProbe(final int opcode, final int probeId) {probeInserter.insertProbe(probeId);mv.visitInsn(opcode);}/*** 在跳转指令(比如if语句)前插入探针** @param opcode  操作码* @param label   标签* @param probeId 调查id* @param frame   框架*/@Overridepublic void visitJumpInsnWithProbe(final int opcode, final Label label,final int probeId, final IFrame frame) {if (opcode == Opcodes.GOTO) {probeInserter.insertProbe(probeId);mv.visitJumpInsn(Opcodes.GOTO, label);} else {final Label intermediate = new Label();mv.visitJumpInsn(getInverted(opcode), intermediate);probeInserter.insertProbe(probeId);mv.visitJumpInsn(Opcodes.GOTO, label);mv.visitLabel(intermediate);frame.accept(mv);}}

概括一句就是会对方法起始行、语句跳转(if/switch)、语句返回前插入探针
具体的插桩是ProbeInserter类的insertProbe方法

public void insertProbe(final int id) {// For a probe we set the corresponding position in the boolean[] array// to true.mv.visitVarInsn(Opcodes.ALOAD, variable);// Stack[0]: [ZInstrSupport.push(mv, id);// Stack[1]: I// Stack[0]: [Zmv.visitInsn(Opcodes.ICONST_1);// Stack[2]: I// Stack[1]: I// Stack[0]: [Zmv.visitInsn(Opcodes.BASTORE);}
}

获取你看不懂,我这边给个简单的例子

插桩前代码public void simpleMethod() {// 这是我们要插入探针的地方System.out.println("Hello World");
}
插桩前指令
0: getstatic     #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc           #3 // String Hello World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return插桩后指令
0: aload_0               // 加载探针数组到操作数栈
1: iconst_0              // 将探针id(此处为0)压入操作数栈
2: iconst_1              // 将数值1压入操作数栈
3: bastore               // 将栈顶的数值1存储到探针数组的第0个位置
4: getstatic     #2      // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc           #3      // String Hello World
9: invokevirtual #4      // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: return插桩后反编译
boolean[] probes = new boolean[1];  // 假设只有一个探针
public void simpleMethod() {probes[0] = true;  // 插入的探针System.out.println("Hello World");
}

我们还要关注ProbeInserter类的visitCode方法

	@Overridepublic void visitCode() {accessorStackSize = arrayStrategy.storeInstance(mv, clinit, variable);mv.visitCode();}

它在方法开始前就会调用ClassFieldProbeArrayStrategy类的storeInstance方法

  public int storeInstance(final MethodVisitor mv, final boolean clinit,final int variable) {mv.visitMethodInsn(Opcodes.INVOKESTATIC, className,InstrSupport.INITMETHOD_NAME, InstrSupport.INITMETHOD_DESC,false);mv.visitVarInsn(Opcodes.ASTORE, variable);return 1;}

其实它就干了一件事,在方法开始行调用 $jacocoInit()方法,所以刚刚的示例插桩后应该是这样的

boolean[] probes = new boolean[1];  // 假设只有一个探针
public void simpleMethod() {$jacocoInit(); // 插入的探针初始化方法probes[0] = true;  // 插入的探针System.out.println("Hello World");
}

$jacocoInit()是在访问方法开始就插入的,probes[0] = true是在方法访问过程中插入的,这样就保证了探针数组的初始化和探针的插入顺序
ClassProbesAdapter类有个计数器,方法每插入一个探针,就会+1,汇总探针总数

public int nextId() {
return counter++;
}

可以看到MethodInstrumenter的visitLabel方法这里调用了之前的nextId方法

probesVisitor.visitProbe(idGenerator.nextId());

到这里我们已经了解完了jacoco对方法的插桩,下面我们再来分析,
ClassInstrumenter的visitTotalProbeCount方法调用了ClassFieldProbeArrayStrategy的addMembers方法

    public void addMembers(final ClassVisitor cv, final int probeCount) {createDataField(cv);createInitMethod(cv, probeCount);}private void createDataField(final ClassVisitor cv) {cv.visitField(InstrSupport.DATAFIELD_ACC, InstrSupport.DATAFIELD_NAME,InstrSupport.DATAFIELD_DESC, null, null);}private void createInitMethod(final ClassVisitor cv, final int probeCount) {final MethodVisitor mv = cv.visitMethod(InstrSupport.INITMETHOD_ACC,InstrSupport.INITMETHOD_NAME, InstrSupport.INITMETHOD_DESC,null, null);mv.visitCode();// Load the value of the static data field:mv.visitFieldInsn(Opcodes.GETSTATIC, className,InstrSupport.DATAFIELD_NAME, InstrSupport.DATAFIELD_DESC);mv.visitInsn(Opcodes.DUP);// Stack[1]: [Z// Stack[0]: [Z// Skip initialization when we already have a data array:final Label alreadyInitialized = new Label();mv.visitJumpInsn(Opcodes.IFNONNULL, alreadyInitialized);// Stack[0]: [Zmv.visitInsn(Opcodes.POP);final int size = genInitializeDataField(mv, probeCount);// Stack[0]: [Z// Return the class' probe array:if (withFrames) {mv.visitFrame(Opcodes.F_NEW, 0, FRAME_LOCALS_EMPTY, 1,FRAME_STACK_ARRZ);}mv.visitLabel(alreadyInitialized);mv.visitInsn(Opcodes.ARETURN);mv.visitMaxs(Math.max(size, 2), 0); // Maximum local stack size is 2mv.visitEnd();}

这里就是对类添加 j a c o c o D a t a 和初始化探针方法 jacocoData和初始化探针方法 jacocoData和初始化探针方法jacocoInit()的地方,至此,这个类的插桩过程就结束了,整体来说ClassProbesAdapte是一个非常核心的如果类,作为一个适配器,它不仅是整个插桩逻辑的入口,它同样是生成报告的入口,生成报告的时候,我们传递ClassAnalyzer类,然后再适配MethodAnalyzer类,在弹出插入的位置还原exec探针数据,从而拿到指令覆盖率,方法覆盖率,行覆盖率等数据,最后生成报告
了解jacoco的源码后我们就可以对插桩逻辑进行干预,比我我们不止想只存储探针数据,还想存储额外信息,就可以改变插桩的逻辑实现丰富化的结构等等功能。

这篇关于jacoco插桩源码,看这一篇就够了的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/390904

相关文章

Andrej Karpathy最新采访:认知核心模型10亿参数就够了,AI会打破教育不公的僵局

夕小瑶科技说 原创  作者 | 海野 AI圈子的红人,AI大神Andrej Karpathy,曾是OpenAI联合创始人之一,特斯拉AI总监。上一次的动态是官宣创办一家名为 Eureka Labs 的人工智能+教育公司 ,宣布将长期致力于AI原生教育。 近日,Andrej Karpathy接受了No Priors(投资博客)的采访,与硅谷知名投资人 Sara Guo 和 Elad G

JAVA智听未来一站式有声阅读平台听书系统小程序源码

智听未来,一站式有声阅读平台听书系统 🌟&nbsp;开篇:遇见未来,从“智听”开始 在这个快节奏的时代,你是否渴望在忙碌的间隙,找到一片属于自己的宁静角落?是否梦想着能随时随地,沉浸在知识的海洋,或是故事的奇幻世界里?今天,就让我带你一起探索“智听未来”——这一站式有声阅读平台听书系统,它正悄悄改变着我们的阅读方式,让未来触手可及! 📚&nbsp;第一站:海量资源,应有尽有 走进“智听

Java ArrayList扩容机制 (源码解读)

结论:初始长度为10,若所需长度小于1.5倍原长度,则按照1.5倍扩容。若不够用则按照所需长度扩容。 一. 明确类内部重要变量含义         1:数组默认长度         2:这是一个共享的空数组实例,用于明确创建长度为0时的ArrayList ,比如通过 new ArrayList<>(0),ArrayList 内部的数组 elementData 会指向这个 EMPTY_EL

如何在Visual Studio中调试.NET源码

今天偶然在看别人代码时,发现在他的代码里使用了Any判断List<T>是否为空。 我一般的做法是先判断是否为null,再判断Count。 看了一下Count的源码如下: 1 [__DynamicallyInvokable]2 public int Count3 {4 [__DynamicallyInvokable]5 get

工厂ERP管理系统实现源码(JAVA)

工厂进销存管理系统是一个集采购管理、仓库管理、生产管理和销售管理于一体的综合解决方案。该系统旨在帮助企业优化流程、提高效率、降低成本,并实时掌握各环节的运营状况。 在采购管理方面,系统能够处理采购订单、供应商管理和采购入库等流程,确保采购过程的透明和高效。仓库管理方面,实现库存的精准管理,包括入库、出库、盘点等操作,确保库存数据的准确性和实时性。 生产管理模块则涵盖了生产计划制定、物料需求计划、

Spring 源码解读:自定义实现Bean定义的注册与解析

引言 在Spring框架中,Bean的注册与解析是整个依赖注入流程的核心步骤。通过Bean定义,Spring容器知道如何创建、配置和管理每个Bean实例。本篇文章将通过实现一个简化版的Bean定义注册与解析机制,帮助你理解Spring框架背后的设计逻辑。我们还将对比Spring中的BeanDefinition和BeanDefinitionRegistry,以全面掌握Bean注册和解析的核心原理。

音视频入门基础:WAV专题(10)——FFmpeg源码中计算WAV音频文件每个packet的pts、dts的实现

一、引言 从文章《音视频入门基础:WAV专题(6)——通过FFprobe显示WAV音频文件每个数据包的信息》中我们可以知道,通过FFprobe命令可以打印WAV音频文件每个packet(也称为数据包或多媒体包)的信息,这些信息包含该packet的pts、dts: 打印出来的“pts”实际是AVPacket结构体中的成员变量pts,是以AVStream->time_base为单位的显

kubelet组件的启动流程源码分析

概述 摘要: 本文将总结kubelet的作用以及原理,在有一定基础认识的前提下,通过阅读kubelet源码,对kubelet组件的启动流程进行分析。 正文 kubelet的作用 这里对kubelet的作用做一个简单总结。 节点管理 节点的注册 节点状态更新 容器管理(pod生命周期管理) 监听apiserver的容器事件 容器的创建、删除(CRI) 容器的网络的创建与删除

red5-server源码

red5-server源码:https://github.com/Red5/red5-server

TL-Tomcat中长连接的底层源码原理实现

长连接:浏览器告诉tomcat不要将请求关掉。  如果不是长连接,tomcat响应后会告诉浏览器把这个连接关掉。    tomcat中有一个缓冲区  如果发送大批量数据后 又不处理  那么会堆积缓冲区 后面的请求会越来越慢。