1. 简介

在本教程中,我们将讨论 Java Instrumentation API。它提供了将字节码(Bytecode)添加到现有已编译 Java 类的功能。

我们还将讨论 Java Agent 以及如何使用它们来检测(Instrument)代码。

2. 项目设置

在整篇文章中,我们将使用 Maven 工具构建一个应用程序。

我们的应用程序将包含两个模块:

  1. ATM 应用:允许我们进行取款操作。
  2. Java Agent:使我们能够通过测量耗时来衡量 ATM 的性能。

Java Agent 将修改 ATM 字节码,从而使我们无需修改 ATM 应用源码即可测量取款时间。

我们的项目将具有以下结构(pom.xml 片段):

<groupId>com.baeldung.instrumentation</groupId>
<artifactId>base</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
    <module>agent</module>
    <module>application</module>
</modules>

在深入介绍检测细节之前,让我们先看看什么是 Java Agent。

3. 什么是 Java Agent

通常,Java Agent 只是一个特制的 Jar 文件。它利用 JVM 提供的 Instrumentation API 来更改 JVM 中加载的现有字节码。

为了使 Agent 正常工作,我们需要定义以下两种方法之一或全部:

  • premain – 将在 JVM 启动时使用 -javaagent 参数静态加载 Agent。
  • agentmain – 使用 Java Attach API 将 Agent 动态加载到正在运行的 JVM 中。

需要记住的一个概念是:JVM 实现(例如 Oracle、OpenJDK 等)可以提供一种动态启动 Agent 的机制,但这不是强制要求的。

首先,让我们看看如何使用现有的 Java Agent。之后,我们将研究如何从头开始创建一个 Agent,以便在字节码中添加所需的功能。

4. 加载 Java Agent

为了能够使用 Java Agent,我们必须首先加载它。我们有两种类型的加载方式:

  • 静态加载:使用 premain 通过 -javaagent 选项加载 Agent。
  • 动态加载:使用 agentmain 通过 Java Attach API 将 Agent 加载到已运行的 JVM 中。

接下来,我们将研究每种类型的加载方式并解释其工作原理。

4.1. 静态加载

在应用程序启动时加载 Java Agent 称为静态加载。在执行任何业务代码之前,静态加载会在启动时修改字节码。

请记住,静态加载使用 premain 方法,该方法将在任何应用程序代码运行之前执行。要使其运行,我们可以执行以下命令:

java -javaagent:agent.jar -jar application.jar

重要的是要注意,我们应该始终将 -javaagent 参数放在 -jar 参数之前。

以下是我们命令的输出日志:

22:24:39.296 [main] INFO - [Agent] In premain method
22:24:39.300 [main] INFO - [Agent] Transforming class MyAtm
22:24:39.407 [main] INFO - [Application] Starting ATM application
22:24:41.409 [main] INFO - [Application] Successful Withdrawal of [7] units!
22:24:41.410 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!
22:24:53.411 [main] INFO - [Application] Successful Withdrawal of [8] units!
22:24:53.411 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!

我们可以看到 premain 方法何时运行以及 MyAtm 类何时被转换。我们还看到了两个 ATM 取款交易日志,其中包含完成每个操作所花费的时间。

请记住,在我们原始的应用程序中,没有这个事务的完成时间统计,它是由我们的 Java Agent 添加的。

4.2. 动态加载

将 Java Agent 加载到已经运行的 JVM 的过程称为动态加载。 这通常使用 Java Attach API 来实现。

一个更复杂的场景是:当我们已经在生产环境中运行了 ATM 应用程序时,我们希望动态添加交易的总时间统计,而不会导致应用程序停机。

让我们写一小段代码来做到这一点,然后将此类称为 AgentLoader。为简单起见,我们将此类放在应用程序 Jar 文件中。因此,我们的应用程序 Jar 文件既可以启动我们的应用程序,又可以将我们的 Agent 附加到 ATM 应用程序:

VirtualMachine jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentFile.getAbsolutePath());
jvm.detach();

现在我们有了 AgentLoader,我们将启动我们的应用程序,以确保在事务之间的十秒钟暂停中,我们将使用 AgentLoader 动态地附加 Java Agent。

我们还要添加胶水代码,使我们可以选择启动应用程序或加载 Agent。我们将此类称为 Launcher,它将是我们的主要 Jar 文件入口类:

public class Launcher {
    public static void main(String[] args) throws Exception {
        if(args[0].equals("StartMyAtmApplication")) {
            new MyAtmApplication().run(args);
        } else if(args[0].equals("LoadAgent")) {
            new AgentLoader().run(args);
        }
    }
}

运行 StartMyAtmApplication

java -jar application.jar StartMyAtmApplication
22:44:21.154 [main] INFO - [Application] Starting ATM application
22:44:23.157 [main] INFO - [Application] Successful Withdrawal of [7] units!

附加 Java Agent

第一次操作后,我们将 Java Agent 附加到我们的 JVM:

java -jar application.jar LoadAgent
22:44:27.022 [main] INFO - Attaching to target JVM with PID: 6575
22:44:27.306 [main] INFO - Attached to target JVM and loaded Java agent successfully

检查应用程序日志

现在,将 Agent 附加到 JVM 后,我们将看到第二次 ATM 取款操作的总完成时间。

这意味着我们在应用程序运行时动态添加了我们的功能:

22:44:27.229 [Attach Listener] INFO - [Agent] In agentmain method
22:44:27.230 [Attach Listener] INFO - [Agent] Transforming class MyAtm
22:44:33.157 [main] INFO - [Application] Successful Withdrawal of [8] units!
22:44:33.157 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!

5. 创建一个 Java Agent

学习了如何使用 Agent 后,让我们看看如何创建 Agent。我们将研究 如何使用 Javassist 更改字节码,并将其与一些工具 API 方法结合使用。

由于 Java Agent 使用 Java Instrumentation API,因此在深入创建 Agent 之前,让我们看一下该 API 中一些最常用的方法及其作用:

  • addTransformer – 将一个转换器添加到仪表引擎。
  • getAllLoadedClasses – 返回由 JVM 当前加载的所有类的数组。
  • retransformClasses – 通过添加字节码来促进已加载类的检测(重新转换)。
  • removeTransformer – 注销提供的 Transformer。
  • redefineClasses – 使用提供的类文件重新定义提供的类集,这意味着该类将被完全替换,而不是像 retransformClasses 一样进行修改。

5.1. 创建 premainagentmain 方法

我们知道,每个 Java Agent 都至少需要一种 premainagentmain 方法。后者用于动态加载,而前者用于将 Java Agent 静态加载到 JVM。

让我们在 Agent 中定义它们两者,以便我们能够静态和动态加载该 Agent:

public static void premain(String agentArgs, Instrumentation inst) {
    LOGGER.info("[Agent] In premain method");
    String className = "com.baeldung.instrumentation.application.MyAtm";
    transformClass(className, inst);
}

public static void agentmain(String agentArgs, Instrumentation inst) {
    LOGGER.info("[Agent] In agentmain method");
    String className = "com.baeldung.instrumentation.application.MyAtm";
    transformClass(className, inst);
}

在每个方法中,我们都声明要更改的类,然后使用 transformClass 方法向下挖掘以转换该类。

以下是为帮助我们转换 MyAtm 类而定义的 transformClass 方法的代码。在此方法中,我们找到了要转换的类,并使用了 transform 方法。另外,我们将 Transformer 添加到仪器引擎中:

private static void transformClass(String className, Instrumentation instrumentation) {
    Class<?> targetCls = null;
    ClassLoader targetClassLoader = null;
    // see if we can get the class using forName
    try {
        targetCls = Class.forName(className);
        targetClassLoader = targetCls.getClassLoader();
        transform(targetCls, targetClassLoader, instrumentation);
        return;
    } catch (Exception ex) {
        LOGGER.error("Class [{}] not found with Class.forName");
    }
    // otherwise iterate all loaded classes and find what we want
    for(Class<?> clazz: instrumentation.getAllLoadedClasses()) {
        if(clazz.getName().equals(className)) {
            targetCls = clazz;
            targetClassLoader = targetCls.getClassLoader();
            transform(targetCls, targetClassLoader, instrumentation);
            return;
        }
    }
    throw new RuntimeException("Failed to find class [" + className + "]");
}
 
private static void transform(Class<?> clazz, ClassLoader classLoader, Instrumentation instrumentation) {
    AtmTransformer dt = new AtmTransformer(clazz.getName(), classLoader);
    instrumentation.addTransformer(dt, true);
    try {
        instrumentation.retransformClasses(clazz);
    } catch (Exception ex) {
        throw new RuntimeException("Transform failed for: [" + clazz.getName() + "]", ex);
    }
}

顺便说一句,让我们为 MyAtm 类定义转换器。

5.2. 定义我们的 Transformer

类转换器必须实现 ClassFileTransformer 接口并实现 transform 方法。

我们将使用 Javassist 将字节码添加到 MyAtm 类,并添加具有 ATM 取款事务总时间的日志:

public class AtmTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(
      ClassLoader loader,
      String className,
      Class<?> classBeingRedefined,
      ProtectionDomain protectionDomain,
      byte[] classfileBuffer) {
        byte[] byteCode = classfileBuffer;
        String finalTargetClassName = this.targetClassName.replaceAll("\\.", "/");
        if (!className.equals(finalTargetClassName)) {
            return byteCode;
        }
 
        if (className.equals(finalTargetClassName) && loader.equals(targetClassLoader)) {
            LOGGER.info("[Agent] Transforming class MyAtm");
            try {
                ClassPool cp = ClassPool.getDefault();
                CtClass cc = cp.get(targetClassName);
                CtMethod m = cc.getDeclaredMethod(WITHDRAW_MONEY_METHOD);
                m.addLocalVariable("startTime", CtClass.longType);
                m.insertBefore("startTime = System.currentTimeMillis();");
 
                StringBuilder endBlock = new StringBuilder();
 
                m.addLocalVariable("endTime", CtClass.longType);
                m.addLocalVariable("opTime", CtClass.longType);
                endBlock.append("endTime = System.currentTimeMillis();");
                endBlock.append("opTime = (endTime-startTime)/1000;");
 
                endBlock.append("LOGGER.info(\"[Application] Withdrawal operation completed in:" +
                                "\" + opTime + \" seconds!\");");
 
                m.insertAfter(endBlock.toString());
 
                byteCode = cc.toBytecode();
                cc.detach();
            } catch (NotFoundException | CannotCompileException | IOException e) {
                LOGGER.error("Exception", e);
            }
        }
        return byteCode;
    }
}

5.3. 创建 Agent Manifest 文件

最后,为了获得有效的 Java Agent,我们需要一个带有几个属性的 Manifest 文件。

因此,我们可以在 Instrumentation Package 官方文档中找到 Manifest 属性的完整列表。

在最终的 Java Agent Jar 文件中,我们将以下行添加到 Manifest 文件中:

Agent-Class: com.baeldung.instrumentation.agent.MyInstrumentationAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.baeldung.instrumentation.agent.MyInstrumentationAgent

我们的 Java 工具 Agent 现已完成。要运行它,请参阅本文的"加载 Java Agent"部分。

6. 结论

在本文中,我们讨论了 Java Instrumentation API。我们研究了如何将 Java Agent 静态和动态地加载到 JVM 中。

我们还研究了如何从头开始创建自己的 Java Agent。

可以 在 Github 上找到示例的完整实现。

说明:本文示例基于 Java 7 文档及传统 JVM 架构。在 Java 9 及更高版本中,由于模块化系统(Jigsaw)的引入,Attach API 及相关工具类(如 tools.jar)的可用性有所变化,可能需要额外配置模块依赖或使用兼容方案。