探秘Java:“润物细无声”的Java Agent

877 阅读7分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

人生苦短,不如养狗

一、JVM的“代理”——Java Agent

  在日常开发当中我们经常会需要编写一些和业务相关性不高的监测代码,比如方法出入口处的日志打印、方法执行耗时统计等。对于Java程序来说,最方便不过的就是使用Spring当中的AOP来完成对应的监测程序编写。那么在Spring框架诞生之前,一个纯粹的Java应用程序应该如何编写相应的监测程序呢?下面就来介绍一个JDK自带的工具—— Java Agent

  如果说AOP是代码层面的代理程序,那么Java Agent就可以说是JVM层面的代理程序了。通过使用JVM提供的API(即JVM TI,这里是通过 Instrumentation类 来完成对应的调用),Java Agent能够修改/替换已经被加载的class文件,借此我们能够在原有代码的基础之上添加一下非业务的监控代码,比如方法耗时统计。

JVM TI(JVM TOOL INTERFACE,JVM工具接口) 是JVM提供的一套对JVM进行操作的工具接口,或者用一种更亲切的说话,JVM提供给秀儿们的后门方法。通过JVMTI,开发者可以实现对JVM的多种操作,它通过接口注册各种事件勾子,在JVM事件触发时,同时触发预定义的勾子,以实现对各个JVM事件的响应,事件包括类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、GC开始和结束、方法调用进入和退出、临界区竞争与等待、VM启动与退出等等。

  和AOP类似,Java Agent的注入也存在 “切点” ,也就是执行的时机,需要注意的是Java Agent的执行时机和它的启动方式密切相关。JDK当中提供两种方式来启动Java Agent,分别是静态加载和动态Attch。具体如下:

  • 静态加载 : 在JDK1.5中,Java Agent只能通过静态加载的方式进行启动,也就是我们经常看到使用 -javaagent:xxx.jar 的方式,通过命令行来进行启动,使用这种方式来加载Java Agent需要保证在 Agent 当中提供了静态公共的 premian 方法。通过静态加载的方式启动的Java Agent注入的切点会在 main 方法执行之前,但 premain 方法和 main 方法均从属于同一个线程,即 main 线程;
  • 动态Attach : 从JDK1.6开始,Instrumentation 支持了运行时动态修改类定义,此时的Java Agent可以通过 Attach API 在JVM进程运行时动态载入。这里的Attach API实际上是JDK提供的一种JVM进程间通信的能力,通过这种能力开发者可以在目标JVM进行启动之后再通过Attach API将Java Agent模块动态载入到指定的JVM进程当中。通过动态加载的方式启动Java Agent需要保证在 Agent 当中提供了静态公共的 agentmain 方法;

  无论是使用静态加载,还是使用动态Attach的方式,都只是单纯提供了切入到目标Java进程的方式和切点,想要真正对class文件进行操作,还需要使用到 Instrumentation类Instrumentation类 的底层实际上依赖于上文所说的JVM TI,借助JVM TI提供的后门 Instrumentation类 提供了对已加载的class文件进行修改、重定义的能力,而这些能力则是通过 ClassFileTransformer 的实现类暴露出来。注意,被注册到 Instrumentation 当中的类文件转换器会在 类加载 或者 重定义 的时候被调用,除了自身所依赖的类以外,所有未来需要的类定义都会被转换器查看到。下面我们就用一个例子来简单看一下Java Agent是如何使用的。

二、一个简单的例子

  无论是静态启动方式,还是动态Attach方式,都需要创建一个Agent类。

DemoAgent

/**
 * Demo Agent
 *
 * @author brucebat
 * @version 1.0
 * @since Created at 2021/9/27 4:45 下午
 */
public class DemoAgent {

    /**
     * 在主线程启动之前进行处理
     *
     * @param agentArgs       代理请求参数
     * @param instrumentation 插桩
     */
    public static void premain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("premain: 这是一个实验用的DemoAgent");
        System.out.println("premain: " + Thread.currentThread().getName() + ", threadId: " + Thread.currentThread().getId());
        System.out.println("premain, 当前线程是否为保护线程: " + Thread.currentThread().isDaemon());
        instrumentation.addTransformer(new DefineTransformer("premain"));
    }

    public static void agentmain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("agnetmain: 这是一个实验用的DemoAgent, 线程名称为: " + Thread.currentThread().getName());
        System.out.println(Thread.currentThread().getThreadGroup().getName() + ", threadId: " + Thread.currentThread().getId());
        System.out.println("当前线程是否为保护线程: " + Thread.currentThread().isDaemon());
        instrumentation.addTransformer(new DefineTransformer("agentmain"));
    }

    /**
     * 这里会针对所有加载进JVM当中的class文件进行转化
     */
    static class DefineTransformer implements ClassFileTransformer {

        private final String name;

        public DefineTransformer(String name) {
            this.name = name;
        }

        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
            System.out.println(name + " load Class:" + className + ", ThreadName: " + Thread.currentThread().getName() +
                    ", ThreadGroupName: " + Thread.currentThread().getThreadGroup().getName() + ", 是否为保护线程: " + Thread.currentThread().isDaemon());
            // 这里只是提供了一个入口可以去感知到对应的class文件,
            // 如果想要对class文件进行实际的变更可以使用javaassist或者Byte Code等工具,
            // 也可以直接针对上面的字节数据进行修改
            return classfileBuffer;
        }
    }

}

  这里编写了两个方法,一个是 premain 方法,一个是 agentmain 方法,并且还编写了一个类文件转换器。为了生成包含对应Agent类的模块,我们还需要在 MANIFEST.MF 当中进行对应信息的定义:

Manifest-Version: 1.0
Premain-Class: agent.DemoAgent
Agent-Class: agent.DemoAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true

  同时还需要在pom文件中加入如下的打包信息:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <descriptorRefs>
                        <!--  当前项目所有依赖均会被打包进该目标jar当中  -->
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <!--  通过指定MANIFEST文件来确定agent配置信息  -->
                        <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
                    </archive>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>attached</goal>
                        </goals>
                        <phase>package</phase>
                    </execution>
                </executions>
            </plugin>

1. 静态启动方式

  在打包完成之后,静态启动方式只需要在启动时加入对应的指令-javaagent:{path}/xxx-jar-with-dependencies.jar。这里就不将全部的执行结果展示出来,只看部分结果:

premain: 这是一个实验用的DemoAgent
premain: main, threadId: 1
premain, 当前线程是否为保护线程: false

  可以看到 premain 方法实际上和 main 方法从属于同一个线程组,即 main线程。

2. 动态Attach方式

  动态Attach方法需要通过编码将Java Agent所在模块(jar)加载到指定的JVM进程当中,具体使用如下:

/**
 * @author brucebat
 * @version 1.0
 * @since Created at 2021/10/10 8:21 下午
 */
public class TestAttach {

    public static void main(String[] args) throws IOException, AttachNotSupportedException {
        List<VirtualMachineDescriptor> virtualMachineDescriptors = VirtualMachine.list();
        for (VirtualMachineDescriptor virtualMachineDescriptor : virtualMachineDescriptors) {
            System.out.println("vm displayName : " + virtualMachineDescriptor.displayName());
            if (virtualMachineDescriptor.displayName().equals("App")) {
                System.out.println(virtualMachineDescriptor.id());
                // 这里最终要的一点就是不需要通过使用命令行的方式去指定需要监测的JVM进程, 可以通过较为明确的虚拟机名称来进行设置
                VirtualMachine virtualMachine = VirtualMachine.attach(virtualMachineDescriptor.id());
                try {
                    virtualMachine.loadAgent("{path}/xxx-SNAPSHOT-jar-with-dependencies.jar");
                    System.out.println("ok");
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    virtualMachine.detach();
                }
            }
        }
    }
}

  这里也只展示部分结果:

agnetmain: 这是一个实验用的DemoAgent, 线程名称为: Attach Listener
system, threadId: 11
当前线程是否为保护线程: true

  可以看到使用Attach方式进行启动的Java Agent会以保护线程身份进行运行。

三、应用场景

  通过上面的讲解不难发现,除了能够动态修改class文件以外,Java Agent还有一个较为明显的优势就是在于它是完全独立于应用程序的。通过Java Agent,开发者可以以一种对应用程序几乎无感知、无侵入的方式添加一些监测能力,真正意义上的“润物细无声”,目前较火的APM工具SkyWalking就是使用这种方式来实现的。

  对于Java Agent的使用场景,官方文档中也有所提及:

Examples of such benign tools include monitoring agents, profilers, coverage analyzers, and event loggers.

此类良性工具的示例包括监控代理、分析器、覆盖分析器和事件记录器。

  在上面的代码中并没有展示如何进行文件的替换,但在代码的注释中进行了标注,在类文件转换器的 transform 中可以直接针对原始的class文件的字节数组,即方法入参当中的 classfileBuffer 进行变更(可以使用ASM工具,但是略显复杂,建议慎用,大神请忽略),也可以使用Javassist工具进行源代码层面的修改(舒适度较高,建议使用这种方式)。

四、总结

  本文较为浅显的讲解了一下Java Agent基本概念和简单使用,如果想要进一步了解Java Agent的执行流程可以查询一下JVM相关的源码。在后续的学习中,笔者会尝试进一步讲解具体的执行流程。