Bootstrap

Java Agent

“真正拉开开发者差距的,从来不是代码量!我是曾续缘,今天带你用架构师思维拆解技术方案,关注我的人都在提升认知维度🧠”

JVMTI

JVMTI(Java Virtual Machine Tool Interface)是一种提供对 Java 虚拟机内部状态访问的原生Native 编程接口,它允许外部工具和代理程序以原生代码的形式与JVM进行交互。这使得开发人员不仅能够调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能。需要注意的是,并非所有的JVM实现都支持JVMTI。

JVMTI 是 Java 平台调试体系(Java Platform Debugger Architecture,JPDA)的一部分,位于 JPDA 的最底层。这种机制自 J2SE 5.0 引入,取代了早期的 JVMDI 和 JVMPI 接口,提供了更全面和高效的工具接口。

JVMTI 提供了一组 C 语言 API,允许代理查询和修改 JVM 的状态。这些 API 包括 jvmtiEnv 接口,代理可以通过它访问 JVM 的内部信息。

要使用 JVMTI ,我们需要编写一个 C 或 C++ 程序来实现我们的工具逻辑。这个程序将使用 JVMTI API 与 JVM 交互。接下来需要编译我们的代理代码生成一个共享库(在 Linux 上是 .so 文件,在 Windows 上是 .dll 文件)。

在启动 JVM 时通过 -agentlib 参数指定代理库的位置。

或者使用 jattach 命令或通过 JVM 的 attach 机制来动态加载代理。

由于 JVMTI 主要是基于 C/C++ 的,因此对于习惯使用 Java 开发的开发者来说,调试可能较为困难。

Instrumentation接口

Instrumentation是Java 6引入的API,位于 java.lang.instrument 包下,旨在简化JVMTI的一些复杂性,并提供更高级别的抽象来支持Java代理(Java agents)。

Instrumentation提供了一组更易于使用的函数,使得Java开发者可以更容易地监控和修改JVM,而不需要深入了解JVMTI的细节。例如,Instrumentation接口中的addTransformer方法实际上是通过JVMTI的函数来实现的。

要使用Instrumentation,需要创建一个包含 premain 方法或 agentmain 方法的类,这些方法是 Java Agent 的入口点,Instrumentation接口可作为方法的参数,JVM 会在适当的时候调用它们。

重要方法

以下是一些Instrumentation接口中常用的方法:

  • addTransformer(ClassFileTransformer transformer, boolean canRetransform): 添加一个类文件转换器。
  • retransformClasses(Class<?>... classes): 重新转换指定的类。
  • redefineClasses(ClassDefinition... definitions): 重新定义指定的类。
  • getAllLoadedClasses(): 返回所有已加载的类。
  • getObjectSize(Object objectToSize): 返回指定对象及其引用对象的大小。
  • appendToBootstrapClassLoaderSearch(JarFile jarfile): 将指定的jar文件添加到引导类加载器的搜索路径。
  • appendToSystemClassLoaderSearch(JarFile jarfile): 将指定的jar文件添加到系统类加载器的搜索路径。

Java Agent

Java Agent是一种特殊类型的Java程序,它在JVM(Java虚拟机)启动时或运行时被加载,用于监控和操作JVM内部的一些状态和行为。

这个Java程序是一个jar文件,包含Agent Class和MANIFEST.MF。

Agent Class

Agent Class是指包含premainagentmain方法的Java类。这个类是Java Agent的核心,它负责定义当Agent被加载到JVM时应该执行的操作。

通常,一个Agent Class只包含一个premain方法或一个agentmain方法,或者两者都有。

Agent Class必须打包在一个jar文件中,并且这个jar文件需要在MANIFEST.MF文件中指明Agent Class。

premain 方法

premain方法是在JVM启动过程中,在应用程序的main方法执行之前被调用的方法。它的目的是在应用程序的任何类被加载之前,对JVM进行初始化设置。

premain方法有两种可能的签名:

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

或者

public static void premain(String agentArgs);

如果两个版本都存在,优先使用带有Instrumentation参数的方法。

agentmain方法

agentmain方法允许在JVM运行时动态地加载Agent。这意味着应用程序已经启动并且可能已经运行了一段时间,然后Agent被附加到JVM上。

agentmain方法的签名如下:

public static void agentmain(String agentArgs, Instrumentation inst);

或者

public static void agentmain(String agentArgs);

如果两个版本都存在,优先使用带有Instrumentation参数的方法。

MANIFEST.MF文件

MANIFEST.MF文件是一个多行文本文件,位于jar文件的META-INF目录下。它包含关于jar文件和其内容的元数据。

以下是MANIFEST.MF文件的一个基本示例,它指明了Premain-Class和Agent-Class属性:

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

以下是每个属性的说明:

  • Manifest-Version: 指示manifest文件的版本,通常是1.0。
  • Premain-Class: 指定包含premain方法的类的全限定名。这个类会在JVM启动时加载Agent之前被调用。
  • Agent-Class: 指定包含agentmain方法的类的全限定名。这个类会在JVM运行时动态加载Agent时被调用。
  • Can-Redefine-Classes: 如果设置为true,则允许重新定义类。这是在Instrumentation接口中使用redefineClasses方法所必需的。
  • Can-Retransform-Classes: 如果设置为true,则允许重新转换类。这是在Instrumentation接口中使用retransformClasses方法所必需的。

创建Agent的步骤

创建 Agent Class

首先,我们需要编写一个 Java 类,该类包含 premain 方法(如果希望在 JVM 启动时加载)或 agentmain 方法(如果希望在 JVM 运行时加载)。

例如,下面是一个简单的 Agent Class 示例:

import java.lang.instrument.Instrumentation;

public class MyAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("MyAgent premain method called with args: " + agentArgs);
        // 在这里可以添加字节码操作或其他初始化逻辑
        // 注册字节码转换器
        inst.addTransformer(new MyTransformer());
    }

    public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println("MyAgent agentmain method called with args: " + agentArgs);
        // 如果实现了 agentmain 方法,那么它会在 JVM 运行时动态加载 agent 时被调用
    }
}
public class MyTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        // 对字节码进行操作
        return transformedBytes;
    }
}

编译 Java 类

编译上面的 Java 类,生成 .class 文件。假设我们的源代码文件名为 MyAgent.java,可以使用以下命令进行编译:

javac MyAgent.java

这将会生成 MyAgent.class 文件。

创建 MANIFEST.MF 文件

创建一个目录结构来存放编译后的类文件和MANIFEST.MF文件。例如:

myagent/
├── com/
│   └── example/
│       └── MyAgent.class
└── META-INF/
    └── MANIFEST.MF

创建一个 MANIFEST.MF 文件,并在其中指定 Agent Class 名称。这个文件应该放在 JAR 文件的 META-INF/MANIFEST.MF 路径下。内容如下:

Manifest-Version: 1.0
Agent-Class: com.example.MyAgent
Premain-Class: com.example.MyAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Set-Native-Method-Prefix: false

在这里,Agent-Class 行指定了 Agent Class 的完全限定名(包括包名)。

打包为 JAR 文件

使用 jar 命令将所有的 .class 文件以及 MANIFEST.MF 文件打包成一个 JAR 文件。例如:

jar cf MyAgent.jar MyAgent.class META-INF/

这将创建一个名为 MyAgent.jar 的 JAR 文件,其中包含了 Agent Class 以及 MANIFEST 文件。

启动Agent

现在我们有了一个包含正确配置的 JAR 文件,可以通过 -javaagent 参数来启动 JVM 并加载你的 Java Agent。例如:

java -javaagent:/path/to/MyAgent.jar=option=value -jar yourapp.jar

在这里,/path/to/MyAgent.jar 是我们的 JAR 文件的路径,option=value 是传递给 premain 方法的参数。

Java Agent的加载分为两种情况:在JVM启动时加载和在JVM运行时动态加载。以下是这两种情况的详细说明。

JVM启动时加载

在JVM启动时加载Agent是通过-javaagent命令行选项来实现的。

在启动JVM时,使用-javaagent选项指定Agent jar文件的位置。

java -javaagent:myagent.jar -jar MyApp.jar

或者,如果你是在命令行启动Java应用程序:

java -javaagent:myagent.jar com.example.MyApp

在这里,myagent.jar是包含Agent的jar文件,MyApp.jarcom.example.MyApp是你要启动的应用程序。

可以通过-javaagent选项多次指定多个Agent。

JVM运行时动态加载

动态加载Agent允许我们在JVM已经运行的情况下加载Agent,而不需要重启JVM。

我们的Agent类应该实现一个agentmain方法,用于在Agent被动态加载时执行。

public class MyAgent {
    public static void agentmain(String agentArgs, Instrumentation inst) {
        // 执行操作
    }
}

按照启动时加载Agent的步骤,创建包含Agent-Class属性的MANIFEST.MF文件,并将Agent类打包到jar文件中。

使用com.sun.tools.attach包中的VirtualMachine类来attach到目标JVM并加载Agent。

import com.sun.tools.attach.*;
public class AgentLoader {
    public static void main(String[] args) throws Exception {
        // 获取目标JVM的PID
        String pid = "1234"; // 需要替换为目标JVM的PID
        // Attach到目标JVM
        VirtualMachine vm = VirtualMachine.attach(pid);
        // 加载Agent
        vm.loadAgent("/path/to/myagent.jar", "arguments");
        // Detach
        vm.detach();
    }
}

Attach 接口

Attach 接口是 Java 虚拟机(JVM)提供的一个用于动态加载 Java Agent 的机制。通过 Attach 接口,可以在 Java 虚拟机(JVM)运行时附加一个 Java Agent。

JDK 自带的一些命令,如 jstack(用于打印线程栈)、jps(用于列出Java进程)和 jmap(用于进行内存dump),都是通过Attach API实现的。

进程间通信

Attach API利用进程间通信机制(Inter-Process Communication, IPC)来实现外部进程与JVM之间的交互。以下是进程间通信的过程:

  • Socket连接:Attach API通过创建一个socket连接到目标JVM的Attach Listener线程。这个连接是建立在TCP或UNIX域socket之上的,具体取决于操作系统。
  • 指令发送:一旦连接建立,外部进程就可以通过这个socket发送指令给JVM。这些指令可以是加载Java Agent、获取JVM信息、触发垃圾回收等。
  • 结果返回:JVM在接收到指令后,会执行相应的操作,并将结果返回给外部进程。这种通信是双向的,确保了外部进程可以与JVM进行有效的交互。

信号监听线程

在JVM内部,有一个名为“Signal Dispatcher”的线程,它在JVM启动时被创建,专门用于监听操作系统发出的信号。

当操作系统向JVM进程发送特定信号时,Signal Dispatcher线程会捕获这些信号并进行处理。例如,在某些操作系统中,发送特定的信号可以触发JVM执行附加(attach)或分离(detach)操作。

在Attach机制中,当外部进程想要连接到JVM时,它可能会通过操作系统发送一个信号给JVM。JVM接收到这个信号后,会唤醒Attach Listener线程,准备接受即将到来的socket连接请求。

Attach工作流程

  1. 发现JVM实例:外部进程通过操作系统的进程管理功能来查找目标JVM的PID。
  2. 发送信号:外部进程通过操作系统发送一个信号给目标JVM。这个信号会由Signal Dispatcher线程接收。
  3. 建立连接:Signal Dispatcher线程唤醒Attach Listener线程,该线程随后会监听来自外部进程的socket连接请求。
  4. 验证和授权:一旦socket连接建立,JVM会进行安全检查,确保外部进程有权限执行后续操作。
  5. 执行操作:通过验证的外部进程现在可以通过Attach API发送指令,JVM执行这些指令并返回结果。

Agent工作流程

Java Agent 利用 Instrumentation API 和 ClassFileTransformer 实现类加载时的字节码修改,从而达到 AOP、性能监控、安全检查等目的。

  1. JVM启动:当JVM启动时,它会加载一个java agent的class文件。
  2. 执行premain()方法:在java agent的class被加载之后,会执行该类中的premain()方法。这是agent的主要入口点。
  3. Instrumentation API的使用:在premain()方法内部,会调用Instrumentation API来对字节码进行操作。
  4. 添加ClassFileTransformer:通过Instrumentation API,可以注册一个ClassFileTransformer实例,用于转换类的字节码。
  5. JVMTI监听事件:JVMTI(Java Virtual Machine Tool Interface)会被用来监听类加载事件。
  6. 回调ClassFileTransformer.transform()方法:每当一个新的类被加载时,之前注册的ClassFileTransformer的transform()方法就会被回调。
  7. 修改未加载的类:在transform()方法中,可以对尚未加载到内存中的类进行修改或替换。
  8. AppClassLoader或其他ClassLoader加载类:经过可能的修改后,类最终由AppClassLoader或其他ClassLoader加载到JVM中。
  9. 使用已加载的类:一旦类被成功加载,就可以在应用程序中使用这些类。
  10. 其他ClassLoader加载类:除了AppClassLoader之外,还有其他的ClassLoader可能参与类的加载过程。
  11. 每次加载新类前回调:对于每个新的类加载尝试,都会再次触发ClassFileTransformer的transform()方法的回调。
  12. 返回修改后的类:transform()方法处理完毕后,会将修改后的类返回给JVM继续后续的加载和使用过程。
  13. 结束premain()方法并执行main()方法:premain()方法结束后,程序的主线程会开始执行main()方法。

在整个过程中,Instrumentation API 起到了关键的作用,它负责协调 Agent 和应用程序之间的关系,使 Agent 可以在类加载时进行干预。ClassFileTransformer 是一个重要的组件,它实现了字节码的转换逻辑,可以根据需求修改类的字节码。而Instrumentation 接口实际上是基于 JVMTI 实现的。

参考连接:

https://juejin.cn/post/7157684112122183693#heading-4

参考文章:https://cengxuyuan.cn

;