探讨 JavaAgent原理,实现方法执行耗时统计
2024-08-03 13:34 阅读(276)

前言

为了能监控程序执行信息,我们通常会借助Spring的AOP机制或依靠SpringMVC中的拦截器(Interceptor)来在请求进入到应用层时进行拦截,从而实现程序执行信息的采集。虽然上述实现方式各有不同,但上述技术有一个共同点,即上述方式有一个共同点那便是其都是应用层的角度实现切入,从而实现数据信息的收集。

而对于数据信息采集而言,越是深入到应用底层越能实现对数据采集的精细化采集和控制,而对于底层数据信息的收集在Java应用程序汇总,完全可以借助JavaAgent来进行采集。

JavaAgent是什么

JavaAgent也称 Java 代理,其本质是一个特殊的 jar 包。但与普通jar包不同的是JavaAgent不是独立运行的,而是需要附加到目标 JVM进程中以发挥其功能。

进一步来看,当应用启动时 JavaAgent会附着到目标应用的JVM上,从而实现对JVM运行数据的采集工作。而JavaAgent在这里其实扮演了一个中间人的角色。从目标 JVM 的角度来看,JavaAgent 就像是一个代理,帮助我们获取所需的运行指标。

这样讲可能比较晦涩,接下来让我们通过一个简单来进行说明。例如,当我们使用 IntelliJ IDEA 的调试功能时,当我们在 IntelliJ IDEA 中启动调试模式时,实际上 IntelliJ IDEA 会使用 JavaAgent 来增强 JVM的行为,以实现调试功能。

具体来看,IntelliJ IDEA 会通过 Java Agent 在 JVM 启动时附加一些特殊的逻辑,这些逻辑允许 IntelliJ IDEA 控制和监视 JVM的执行过程。具体来看, IntelliJ IDEA 在启动 JVM 时通过 -javaagent 参数加载一个特定的 JavaAgent。而这个 JavaAgent 实际上是由 IntelliJ IDEA 自身,当相关的JavaAgen加载成功后,相关的JavaAgent即可在类加载之前对字节码进行修改,从而实现调试功能。例如,断点管理、单步执行、变量追踪、堆栈跟踪等调试操作。

事实上, JavaAgent是JDK提供给开发者的一种可以对已有class代码进行运行时注入修改的能力。 借助JavaAgent技术我们可以对特定的类进行字节码修改, 从而在方法执行前后注入特定的逻辑,以实现对类执行的增强和修改。

知晓了JavaAgent的基本概念后,我们接下来便对JavaAgent的工作原理进行分析。

JavaAgent工作原理

JavaAgent的使用基本可以归结加载和执行两个阶段。具体来看,JavaAgent需要通过-javaagent 命令行参数来实现对JavaAgent加载。而-javaagent可接收一个指向JavaAgent的路径,其指向 JavaAgent 的 JAR 文件。 进一步,对于JavaAgent的启动而言,其内部需定义一个 premain 方法,作为JavaAgent的主要入口。 其中premain方法签名如下:


public static void premain(String agentArgs, 
                    Instrumentation inst) 

不难发现,对于premain方法而言其主会通过传入 Instrumentation 对象,来保证Java Agent 对Instrumentation API的各种方法的方法文,例如:通过Instrumentation API 的addTransformer从而添加一个 ClassFileTransformer进而实现来转换类的字节码。

而ClassFileTransformer 则是Java中用于修改类文件字节码的接口。其主要用于在类加载到JVM时对类的字节码进行修改。对于ClassFileTransformer 而言,其只有一个方法transform,该方法允许你在类被加载到JVM之前对其字节码进行修改。方法签名如下:

java 代码解读复制代码

  
  public byte[] transform(ClassLoader loader,
      String className, Class<?> classBeingRedefined, 
      ProtectionDomain protectionDomain, 
      byte[] classfileBuffer)

在 transform 方法中,你可以使用字节码操作库(如 ASM、Byte Buddy 等)来修改字节码,例如插入新的指令或方法调用。

换言之,如果要想期待给类的Class文件添加一下自定义逻辑的话,我们需要借助ClassFileTransformer来完成。

实践JavaAgent

明白了JavaAgent的原理后,接下来我们便来自己Coding一个统计指定方法耗时的JavaAgent。

具体来看,如果我们要手动编写一个JavaAgent大致需要如下几步



实现 ClassFileTransformer接口,重写transform方法



正如之前介绍的那样, 如果我们想对字节码文件进行修改,我们需要借助 ClassFileTransformer 接口的 transform 方法,从而保证可以在类加载到 JVM 之前对其字节码进行修改。

如果我们要对方法执行耗时进行统计的话,最简单的方式无异于在方法开始前进行计时,当方法结束时再次进行计时,两个时间相减即为方法执行所耗时长。因此transform方法的逻辑如下:

public class CostTransformer implements ClassFileTransformer {


    private final String targetClassNameSuffix = "UserController";

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        // 这里我们限制下,只针对目标包下进行耗时统计
        if (!className.contains(targetClassNameSuffix)) {
            return classfileBuffer;
        }
        CtClass cl = null;
        try {
            ClassPool classPool = new ClassPool();
            classPool.appendSystemPath();
            CtClass ctClass = classPool.getCtClass("com.example.controller.UserController");
            CtMethod method = ctClass.getDeclaredMethods("testCostTime")[0];
            // 所有方法,统计耗时;请注意,需要通过`addLocalVariable`来声明局部变量
            method.addLocalVariable("start", CtClass.longType);
            method.insertBefore("start = System.currentTimeMillis();");
            String methodName = method.getLongName();
            method.insertAfter("System.out.println(\"监控信息(方法执行耗时):" + methodName + " cost: \" + (System" +
                    ".currentTimeMillis() - start));");
            return ctClass.toBytecode();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return classfileBuffer;
    }
}

在上述代码中,我们通过method.insertBefore在方法执行前插入一个start变量,用户记录方法执行开始时间。然后,借助method.insertAfter在方法执行末尾插入耗时计算逻辑。



编写代理入口类



对于JavaAgent而言其代理入口类通常包含 premain 或 agentmain 方法,并在相关方法内完成 ClassFileTransformer的注册。因此,为了确保我们编写的ClassFileTransformer能成功被JavaAgent所加载,所以我们需要在premain 方法内部完成相关注册。相关逻辑如下:

java 代码解读复制代码

public class MyAgent {

    public static void premain(String agentArgs, Instrumentation inst) {

        inst.addTransformer(new CostTransformer());
    }
}

(注: agentmain通常是在JavaAgent附着启动时所需,本文我们主要介绍JavaAgent启动时加载的方式,即我们主要介绍 premain的使用 )



配置Jar信息,完成Jar打包



通常JavaAgent都是通过Jar的形式进行运行,因此我们需要将我们上述的代码打包成一个Jar包,在打包之前我们需要配置一个 MANIFEST.MF 文件以指定JavaAgent的启动类:

Manifest-Version: 1.0
Premain-Class: MyAgent
Agent-Class: MyAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true

至此,我们整个项目接口如下所示:

然后借助Maven来生成相应的Jar包,本次笔者这里打成的Jar包名称为exec-timer。完成Jar的打包后,即可在应用启动时指定加载相应路径下的Jar包,从而完成JavaAgent的启动。具体如下:

(配置VM相关参数,指定加载target路径下的exec-timer的jar)

(应用启动后,成功记录出UserController下testCostTime方法执行时长)


注:本次Coding我们所使用的依赖如下:


   <dependency> 
       <groupId>org.ow2.asm</groupId> 
       <artifactId>asm-all</artifactId> 
       <version>5.0.3</version>    
   </dependency>

<dependency> 
    <groupId>org.javassist</groupId>                    
    <artifactId>javassist</artifactId>     
    <version>3.20.0-GA</version>   
</dependency>  

总结

本文主要对JavaAgent的原理和使用方式进行了分析介绍,并结合具体案例对JavaAgent的使用进行详细的分析,具体来看,如果我们要编写一个JavaAgent具体需要完成如下步骤:



实现 ClassFileTransformer 接口以修改类的字节码。



在代理类的 premain 或 agentmain 方法中注册 ClassFileTransformer。



打包代理 JAR,并在 MANIFEST.MF 中指定代理类。



使用 -javaagent 选项启动 Java 应用程序,或使用Java Attach API在运行时附加代理。