33 深入理解Java Agent与字节码注入:原理与实践

图片[1]-33 深入理解Java Agent与字节码注入:原理与实践-速优课

本文导读

你可能听说过 Java Agent,也可能用过 Arthas、SkyWalking 这类基于字节码注入的诊断或监控工具。它们是怎么做到在不修改源代码的情况下,给程序动态添加功能的呢?答案就是 Java Agent 和字节码注入技术。

本文将带你深入了解 Java Agent 的工作原理,以及基于它的字节码注入技术。我们会从最简单的 premain 方法开始,一步步讲到 ClassFileTransformer、ASM 字节码操作,以及基于字节码注入的 Profiler 实现。

通过阅读本文,你将了解:

  • Java Agent 的两种启动方式:premain 与 agentmain
  • 如何通过 ClassFileTransformer 拦截类加载事件
  • 如何使用 ASM 框架进行字节码注入
  • redefine 与 retransform 的区别与应用场景
  • 基于字节码注入的 Profiler 会遇到哪些技术挑战
  • 什么是观察者效应,它如何影响性能数据的准确性

一、Java Agent 基础

1.1 premain:在 main 之前执行

说到 Java Agent,大家最先想到的应该就是 premain 方法了。顾名思义,premain 就是在 main 方法之前执行的方法。

package org.example;
​
public class MyAgent {
  public static void premain(String args) {
    System.out.println("premain");
  }
}

注意,premain 方法接收的是一个字符串参数,而不是像 main 方法那样的字符串数组。

要让这个方法以 Java Agent 的方式运行,我们需要把它打包成 jar 包,并在 MANIFEST.MF 中指定 Premain-Class

# 注意第一条命令会写入两行,最后有一个空行
$ echo 'Premain-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java -javaagent:myagent.jar HelloWorld
premain
Hello, World

可以看到,premain 方法确实在 Hello World 输出之前执行了。

1.2 agentmain:运行时动态加载

除了在启动时通过 -javaagent 参数指定,Java Agent 还可以通过 Attach API 在程序运行过程中动态加载。

import java.io.IOException;
import com.sun.tools.attach.*;
​
public class AttachTest {
  public static void main(String[] args)
      throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
    if (args.length <= 1) {
      System.out.println("Usage: java AttachTest <PID> /PATH/TO/AGENT.jar");
      return;
    }
    VirtualMachine vm = VirtualMachine.attach(args[0]);
    vm.loadAgent(args[1]);
  }
}

通过 Attach API 加载的 Agent,执行的不再是 premain 方法,而是 agentmain 方法:

public class MyAgent { 
  public static void agentmain(String args) {
    System.out.println("agentmain");
  }
}

相应地,manifest 文件中需要配置 Agent-Class

$ echo 'Agent-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/

运行效果:

$ java HelloWorld
Hello, World
$ jps
$ java AttachTest <pid> myagent.jar
agentmain
# 最后一句输出来自运行 HelloWorld 的 Java 进程

1.3 多个 Agent 的执行顺序

JVM 并不限制 Java Agent 的数量。你可以:

  • 在命令行中指定多个 -javaagent 参数
  • 通过 Attach API 加载多个 Agent

JVM 会按照定义顺序或 attach 的顺序逐个执行这些 Agent。


二、字节码注入详解

2.1 ClassFileTransformer:类加载拦截器

如果 Java Agent 只是在 main 前后打印一些字符串,那它也没什么特别的——这些逻辑完全可以写到 main 方法里。Java Agent 真正强大的地方在于它提供了 instrumentation 机制,允许我们拦截类加载事件,并修改类的字节码。

我们先来看一个简单的例子:

package org.example;
​
import java.lang.instrument.*;
import java.security.ProtectionDomain;
​
public class MyAgent {
  public static void premain(String args, Instrumentation instrumentation) {
    instrumentation.addTransformer(new MyTransformer());
  }
​
  static class MyTransformer implements ClassFileTransformer {
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
        ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
      System.out.printf("Loaded %s: 0x%X%X%X%X\n", className, classfileBuffer[0], classfileBuffer[1],
          classfileBuffer[2], classfileBuffer[3]);
      return null;
    }
  }
}

注意看,premain 方法多了一个 Instrumentation 类型的参数。通过它,我们可以注册类加载事件的拦截器。拦截器需要实现 ClassFileTransformer 接口,重写 transform 方法。

transform 方法的参数:

  • classfileBuffer:正在加载的类的原始字节码(byte 数组)
  • 返回值:修改后的字节码

在上面的例子中,我们只是打印了 class 文件的前四个字节——也就是 Java class 文件的魔数 0xCAFEBABE

如果 transform 方法返回 null 或者抛出异常,JVM 会使用原始的字节码继续加载。如果返回了新的 byte 数组,JVM 就会用这个修改后的字节码来加载类。

基于这个机制,我们可以实现字节码注入(bytecode instrumentation)——往正在加载的类中插入额外的字节码。

2.2 使用 ASM 注入字节码

直接操作字节码数组太麻烦了,通常我们会用字节码操作库来完成。最常用的就是 ASM 框架。

下面我们用 ASM 的 Tree API(面向对象的方式)来演示如何在方法入口注入代码:

package org.example;
​
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
​
public class MyAgent {
  public static void premain(String args, Instrumentation instrumentation) {
    instrumentation.addTransformer(new MyTransformer());
  }
​
  static class MyTransformer implements ClassFileTransformer, Opcodes {
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
        ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
      ClassReader cr = new ClassReader(classfileBuffer);
      ClassNode classNode = new ClassNode(ASM7);
      cr.accept(classNode, ClassReader.SKIP_FRAMES);
​
      for (MethodNode methodNode : classNode.methods) {
        if ("main".equals(methodNode.name)) {
          InsnList instrumentation = new InsnList();
          instrumentation.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
          instrumentation.add(new LdcInsnNode("Hello, Instrumentation!"));
          instrumentation.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false));
​
          methodNode.instructions.insert(instrumentation);
        }
      }
​
      ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
      classNode.accept(cw);
      return cw.toByteArray();
    }
  }
}

这段代码的逻辑很清晰:

  1. ClassReader 读取原始字节码,转换成 ClassNode 对象
  2. 遍历类中的所有方法(MethodNode
  3. 当遇到名为 main 的方法时,在方法入口处注入 System.out.println("Hello, Instrumentation!");
  4. ClassWriter 把修改后的 ClassNode 写回 byte 数组

运行效果:

$ java -javaagent:myagent.jar -cp .:/PATH/TO/asm-7.0-beta.jar:/PATH/TO/asm-tree-7.0-beta.jar HelloWorld
Hello, Instrumentation!
Hello, World!

可以看到,注入的代码在 Hello, World! 之前执行了。

2.3 redefine 与 retransform

除了在类加载时拦截,Java Agent 还提供了两个针对已加载类的操作:redefineretransform

redefine(重定义):

  • 舍弃类原本的字节码,替换成用户提供的新字节码
  • 比较危险,一般用于修复出错的字节码

retransform(重转换):

  • 重新调用所有已注册的 ClassFileTransformertransform 方法
  • 主要应用场景有两个:
    1. 处理提前加载的类:在 premainagentmain 执行之前,JVM 已经加载了不少类。这些类没有经过 transformer 处理,也就没有被注入。使用 retransform 可以对这些已加载的类重新进行注入。
    2. 移除部分注入:在有多个 Agent、多层注入的情况下,如果移除了某个 transformer,可以通过 retransform 从原始字节码开始重新注入,从而去掉被移除 transformer 的影响。

2.4 底层实现:JVMTI

Java Agent 的这些功能都是通过 JVMTI Agent(也就是 C Agent)来实现的。

JVMTI(JVM Tool Interface)是一个事件驱动的工具接口。通常我们会在 C Agent 的入口方法 Agent_OnLoad 处注册各种事件的钩子(hook)方法。当 JVM 触发这些事件时,就会调用对应的钩子方法。

JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);

比如,我们可以为 ClassFileLoadHook 事件设置钩子,在 C 层面拦截所有的类加载事件。Java Agent 本质上就是对 JVMTI 的 Java 层封装。


三、基于字节码注入的 Profiler

3.1 基本思路

字节码注入的应用非常广泛,比如代码覆盖率工具、各种性能分析工具(Profiler)都是基于它实现的。

基本思路是:定义一个运行时类,然后在特定的程序行为前后,注入对这个运行时类方法的调用,用来记录该行为的发生。

举个例子,我们来实现一个简单的对象分配统计工具。

首先是运行时统计类:

package org.example;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public class MyProfiler {
  public static ConcurrentHashMap<Class<?>, AtomicInteger> data = new ConcurrentHashMap<>();

  public static void fireAllocationEvent(Class<?> klass) {
    data.computeIfAbsent(klass, kls -> new AtomicInteger())
        .incrementAndGet();
  }

  public static void dump() {
    data.forEach((kls, counter) -> {
      System.err.printf("%s: %d\n", kls.getName(), counter.get());
    });
  }

  static {
    Runtime.getRuntime().addShutdownHook(new Thread(MyProfiler::dump));
  }
}

这个类维护了一个 ConcurrentHashMap,用来统计每个类创建了多少个实例。程序退出时通过 ShutdownHook 打印统计结果。

然后是 Java Agent 的注入逻辑:

package org.example;

import java.lang.instrument.*;
import java.security.ProtectionDomain;

import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;

public class MyAgent {

  public static void premain(String args, Instrumentation instrumentation) {
    instrumentation.addTransformer(new MyTransformer());
  }

  static class MyTransformer implements ClassFileTransformer, Opcodes {
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
        ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
      if (className.startsWith("java")    ||
          className.startsWith("javax")   || 
          className.startsWith("jdk")     ||
          className.startsWith("sun")     ||
          className.startsWith("com/sun") ||
          className.startsWith("org/example")) {
        // 跳过 JDK 类和 Profiler 自身的类
        return null;
      }

      ClassReader cr = new ClassReader(classfileBuffer);
      ClassNode classNode = new ClassNode(ASM7);
      cr.accept(classNode, ClassReader.SKIP_FRAMES);

      for (MethodNode methodNode : classNode.methods) {
        for (AbstractInsnNode node : methodNode.instructions.toArray()) {
          if (node.getOpcode() == NEW) {
            TypeInsnNode typeInsnNode = (TypeInsnNode) node;

            InsnList instrumentation = new InsnList();
            instrumentation.add(new LdcInsnNode(Type.getObjectType(typeInsnNode.desc)));
            instrumentation.add(new MethodInsnNode(INVOKESTATIC, "org/example/MyProfiler", "fireAllocationEvent",
                "(Ljava/lang/Class;)V", false));

            methodNode.instructions.insert(node, instrumentation);
          }
        }
      }

      ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
      classNode.accept(cw);
      return cw.toByteArray();
    }
  }
}

这段代码的逻辑是:

  1. 跳过 JDK 类和 Profiler 自身的类
  2. 遍历每个方法的每条指令
  3. 遇到 NEW 字节码时,在它后面插入对 MyProfiler.fireAllocationEvent() 的调用

3.2 技术难点一:避免无限递归

你可能注意到了,上面的代码特意跳过了 JDK 类和 Profiler 自身的类。为什么要这么做?

因为如果不跳过,很容易造成死循环调用,最终抛出 StackOverflowError

举个例子:假设我们在 PrintStream.println 方法入口注入了 System.out.println("blahblah"),而 out 本身就是 PrintStream 的实例。执行注入代码时又会调用 println,然后又触发注入,然后又调用 println…… 无限递归就这样发生了。

怎么解决这个问题?关键是设置一个线程私有的标识位,用来区分当前是在执行应用代码还是注入代码。

当即将执行注入代码时:

  • 先检查标识位,如果已经是注入代码上下文,就直接返回,不执行注入逻辑
  • 如果不是,就设置标识位,执行注入代码,执行完再恢复标识位

这样就能避免无限递归了。

3.3 技术难点二:命名空间隔离

字节码注入的另一个难点是命名空间问题

很多应用程序本身就依赖 ASM 这样的字节码库。如果我们的注入逻辑也依赖 ASM,就可能出现版本冲突——注入逻辑用的是新版本的 ASM,而应用程序用的是旧版本。

JDK 本身也使用了 ASM(比如用来生成 Lambda 表达式的适配器类)。JDK 的做法是把整个 ASM 库重命名,给所有类加上 jdk.internal 包名前缀。

但我们不好直接改 ASM 的包名,所以通常的做法是用自定义类加载器来隔离命名空间,让注入逻辑使用自己的类加载器加载自己的依赖,和应用程序的类加载器隔离开。

3.4 技术难点三:观察者效应

基于字节码注入的工具还有一个根本性的问题——观察者效应(Observer Effect):观测行为本身会影响被观测对象的状态。

举几个例子:

例 1:方法执行时间统计

假设 A 方法调用了 B 方法,两个方法都被注入了计时逻辑。那么统计 B 方法执行时间的注入代码本身的耗时,会被算进 A 方法的执行时间里。最终 A 方法的统计数据会比实际偏大。

例 2:对象分配统计与逃逸分析

我们知道,JIT 的逃逸分析可能会优化掉一些不会逃逸的对象创建。但字节码注入不会优化掉统计逻辑——即使对象创建被优化了,fireAllocationEvent 的调用依然存在。结果就是:我们统计到了一些实际上并没有发生的对象分配。

例 3:注入代码影响内联决策

注入的代码还可能影响方法内联的决策。比如某个构造器原本会被内联,对象不会逃逸;但注入了统计代码后,构造器可能就不满足内联条件了,导致对象逃逸。结果就是:原本能被逃逸分析优化掉的对象创建现在无法优化了,我们又统计到了原本不会发生的对象分配。

总而言之,使用字节码注入开发 Profiler 时,要辩证地看待收集到的数据。它反映的是被注入情况下程序的执行状态,而不是程序原本的真实执行状态。


四、面向方面编程(AOP)

说到字节码注入,就不得不提面向方面编程(Aspect-Oriented Programming,AOP)

AOP 的核心理念是:

  • 定义切入点(Pointcut):在哪些地方插入额外逻辑
  • 定义通知(Advice):插入什么额外逻辑
  • 程序控制流中所有匹配切入点的连接点(Joinpoint)都会执行通知代码

举个例子:定义一个”所有方法入口”的切入点,然后指定”打印方法名”的通知。那么每个具体的方法入口就是一个连接点,方法执行时都会打印自己的名字。

字节码注入是实现 AOP 的常用方式之一。比如 Spring AOP 的运行时增强、AspectJ 的编译时和类加载时增强,底层都是字节码操作。

前面我们实现的对象分配统计,其实也可以看作一种 AOP:

  • 切入点:所有 new 字节码之后
  • 通知:调用 MyProfiler.fireAllocationEvent(<Target>.class)

总结与思考

本文要点回顾

  1. Java Agent 有两种启动方式
    • premain:启动时通过 -javaagent 指定,在 main 之前执行
    • agentmain:运行时通过 Attach API 动态加载
  2. 字节码注入的核心是 ClassFileTransformer
    • 通过 Instrumentation.addTransformer() 注册拦截器
    • transform 方法接收原始字节码,返回修改后的字节码
    • 返回 null 或抛异常表示不修改
  3. ASM 是最常用的字节码操作库
    • Tree API 以面向对象的方式操作字节码
    • ClassReader 读取、ClassNode 表示、ClassWriter 写出
  4. redefine 和 retransform 的区别
    • redefine 直接替换整个字节码
    • retransform 重新运行所有 transformer
  5. 基于字节码注入的 Profiler 面临三大挑战
    • 无限递归:需要用线程私有标识位避免
    • 命名空间冲突:需要用自定义类加载器隔离
    • 观察者效应:注入本身会影响程序行为,数据仅供参考

思考与实践

  1. 你使用过哪些基于 Java Agent 或字节码注入的工具?它们分别解决了什么问题?
  2. 思考一下,如果要在方法出口处注入代码(包括正常返回和异常抛出两种情况),应该怎么实现?
  3. 观察者效应是字节码注入工具无法完全避免的问题,你觉得在实际使用中应该如何应对?

Java Agent 和字节码注入是一项非常强大的技术,掌握它不仅能帮助我们更好地理解各类诊断工具的工作原理,也能在需要时自己动手开发定制化的工具。

© 版权声明
THE END
喜欢就支持一下吧
点赞13
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!