1. 简介

本文将深入探讨 Java Instrumentation API,它允许我们在不修改源码的前提下,动态修改已编译的 Java 类字节码。这在性能监控、APM 工具开发、热修复等场景中非常实用。

我们还会介绍 Java Agent 的概念,以及如何利用它实现字节码增强(instrumentation)。整个过程无需改动原有业务代码,简单粗暴却极其强大。

2. 项目结构与目标

我们通过一个实际案例来演示:构建一个 ATM 取款应用,并通过 Java Agent 动态为其添加性能耗时统计功能。

整个项目包含两个模块:

  • application:ATM 应用主程序,提供取款功能
  • agent:Java Agent 模块,负责在运行时修改 ATM 类的字节码,插入耗时统计逻辑

✅ 核心优势:无需修改 ATM 源码,即可为其添加方法执行时间监控

项目 Maven 结构如下:

<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 在类加载时或运行时修改其字节码。

一个 Agent 要生效,必须实现以下两个方法之一(或两者都实现):

  • premain:在 JVM 启动时通过 -javaagent 参数静态加载
  • agentmain:在 JVM 运行时通过 Java Attach API 动态 attach

⚠️ 注意:动态 attach 的能力依赖于 JVM 实现(如 OpenJDK、Oracle JDK 支持),并非所有 JVM 都提供。

接下来我们先看如何使用 Agent,再手搓一个。

4. 加载 Java Agent

Agent 支持两种加载方式:静态加载和动态加载。

4.1 静态加载(Static Load)

在应用启动时通过 -javaagent 参数加载,字节码修改发生在任何业务代码执行之前

使用方式:

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 类被成功 transform
  • 原本没有耗时日志的 ATM 应用,现在自动输出了每笔交易耗时

这就是字节码增强的威力。

4.2 动态加载(Dynamic Load)

将 Agent attach 到一个正在运行的 JVM 进程中,实现“热插拔”功能。适用于生产环境临时诊断性能问题,无需重启服务。

场景

ATM 应用已在生产运行,我们想临时开启耗时监控。

实现方式

使用 Java Attach API 编写一个 AgentLoader

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

为了简化演示,我们将 AgentLoader 放在 application 模块中。

再写一个启动类 Launcher,用于区分是启动应用还是加载 Agent:

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);
        }
    }
}

启动应用

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!

动态 attach Agent

在第一次交易后,执行:

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

查看效果

后续交易自动带上耗时日志:

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!

✅ 动态生效!这就是 agentmain 的作用。

5. 手写一个 Java Agent

现在我们从零实现一个 Agent,核心是使用 Javassist 操作字节码。

5.1 Instrumentation API 核心方法

Agent 依赖 java.lang.instrument.Instrumentation 接口,常用方法有:

  • addTransformer:注册类转换器
  • getAllLoadedClasses:获取当前 JVM 所有已加载类
  • retransformClasses:对已加载类重新转换(需开启 Can-Retransform-Classes
  • removeTransformer:移除转换器
  • redefineClasses:完全替换类定义(慎用,会丢失原有类状态)

5.2 实现 premainagentmain

为了让 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 方法负责查找目标类并触发转换:

private static void transformClass(
  String className, Instrumentation instrumentation) {
    Class<?> targetCls = null;
    ClassLoader targetClassLoader = null;
    try {
        targetCls = Class.forName(className);
        targetClassLoader = targetCls.getClassLoader();
        transform(targetCls, targetClassLoader, instrumentation);
        return;
    } catch (Exception ex) {
        LOGGER.error("Class [{}] not found with Class.forName", className);
    }
    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); // true 表示支持 retransform
    try {
        instrumentation.retransformClasses(clazz);
    } catch (Exception ex) {
        throw new RuntimeException("Transform failed for: [" + clazz.getName() + "]", ex);
    }
}

5.3 实现 ClassFileTransformer

转换器必须实现 ClassFileTransformer 接口,核心是 transform 方法:

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("withdrawMoney");
                
                // 插入开始时间
                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 during transformation", e);
            }
        }
        return byteCode;
    }
}

✅ 踩坑提示:retransformClasses 需要在 addTransformer 时传入 true,否则会抛 UnmodifiableClassException

5.4 编写 Agent Manifest 文件

Agent JAR 必须在 MANIFEST.MF 中声明关键属性:

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

📌 注意:

  • Can-Retransform-Classes: true 是动态修改字节码的前提
  • 类名必须写全路径

至此,Agent 开发完成。使用方式见第 4 节。

6. 总结

本文系统介绍了 Java Instrumentation 的核心机制:

  • ✅ 静态加载:通过 -javaagent 在启动时增强
  • ✅ 动态加载:通过 Attach API 实现运行时 attach
  • ✅ 字节码操作:使用 Javassist 在方法前后插入逻辑
  • ✅ 无侵入监控:业务代码零改动实现 APM 功能

Instrumentation 是 JVM 高级特性,掌握后可开发出强大的诊断工具。实际项目中,像 SkyWalking、Pinpoint 等 APM 框架底层都依赖此技术。

完整代码示例见 GitHub 仓库


原始标题:Guide to Java Instrumentation