Java Agent

发布时间 2023-06-16 15:06:29作者: bigCousinXu

前言

当我们使用java命令执行一个java程序时,可以添加一个参数 -javaagent,如下图:

概念

Java Agent 直译为 Java 代理,也常常被称为 Java 探针技术。
Java Agent 是在 JDK1.5 引入的一种可以动态修改 Java 字节码的技术。Java 中的类编译后形成字节码被 JVM 执行。Java Agent在 JVM 在执行这些字节码之前获取这些字节码的信息,并且通过字节码转换器对这些字节码进行修改,以此来完成一些额外的功能。Java Agent 是一个不能独立运行 jar 包,它通过依附于目标程序的 JVM 进程进行工作。

功能及应用

功能:
在JVM加载字节码之前拦截并对字节码进行修改;
在JVM 运行期间修改已经加载的字节码;
应用:
IDE 的调试功能,例如 Eclipse、IntelliJ IDEA
热部署功能,例如 JRebel、XRebel、spring-loaded
各种线上诊断工具,例如 Btrace、Greys,还有阿里的 Arthas
各种性能分析工具,例如 Visual VM、JConsole 等
全链路性能检测工具,例如 Skywalking、Pinpoint等

例子

启动时加载
  1. 新建maven项目,resource目录添加MANIFEST.MF文件,注意最后要多空一行
Premain-Class: org.example.preagent.PreAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
  1. 定义Agent类,实现premain方法
public class PreAgent {
    public static void premain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("pre main 探针启动");
        System.out.println("pre main 探针传入参数: " + agentArgs);
        instrumentation.addTransformer(new PreTransformer());
    }
}
  1. 定义Transform类,这里通过JavaAssist修改字节码
public class PreTransformer implements ClassFileTransformer {
    private static final String INJECTED_CLASS_NAME = "org.example.AppInit";
    // className参数表示当前加载类的类名
    // classfileBuffer参数是待加载类文件的字节数组
    // 调用addTransformer注册ClassFileTransformer以后,后续所有JVM加载类都会被它的transform方法拦截
    // 这个方法接收原类文件的字节数组,在这个方法中做类文件改写,最后返回转换过的字节数组,由JVM加载这个修改过的类文件
    // 如果transform方法返回null,表示不对此类做处理,如果返回值不为null,JVM会用返回的字节数组替换原来类的字节数组
    @Override
    public byte[] transform(ClassLoader loader,
                            String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) throws IllegalClassFormatException {

        String realClassName = className.replace("/", ".");
        if (realClassName.equals(INJECTED_CLASS_NAME)) {
            System.out.println("拦截到的类名: " + className);
            CtClass ctClass;
            try {
                ClassPool classPool = ClassPool.getDefault();
                ctClass = classPool.get(realClassName);
                CtMethod[] methods = ctClass.getDeclaredMethods();
                for (CtMethod method : methods) {
                    System.out.println(method.getName() + " 方法被拦截");
                    method.addLocalVariable("time", CtClass.longType);
                    method.insertBefore("System.out.println(\"---开始执行---\");");
                    method.insertBefore("time = System.currentTimeMillis();");
                    method.insertAfter("System.out.println(\"---结束执行---\");");
                    method.insertAfter("System.out.println(\"运行耗时: \" + (System.currentTimeMillis() - time));");
                }
                return ctClass.toBytecode();
            } catch (Throwable e) {
                e.printStackTrace();
            }
        }
        return classfileBuffer;
    }
}
  1. 修改pom文件打包配置,将项目打包成jar包
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>3.2.2</version>
            <configuration>
                <archive>
                    <manifest>
                        <addClasspath>true</addClasspath>
                    </manifest>
                    <manifestFile>
                        src/main/resources/META-INF/MANIFEST.MF
                    </manifestFile>
                    <manifestEntries>
                        <Premain-Class>org.example.preagent.PreAgent</Premain-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                    </manifestEntries>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>
  1. 编写测试类
//Main方法
public class PreAgentTestMain {
    public static void main(String[] args) {
        System.out.println("=================main thread start=================");
        AppInit.init();
        System.out.println("=================main thread end=================");
    }
}

//要拦截的类
public class AppInit {
    public static void init() {
        try {
            System.out.println("---App init started---");
            Thread.sleep(1000L);
            System.out.println("---App init finished---");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  1. 在终端或者shell输入javac xxx/PreAgentTestMain.java得到class文件,再通过-javaagent的方式启动java程序
java xxx/PreAgentTest 
运行时加载
  1. 定义Agent
public class RuntimeAgent {
    public static void agentmain(String arg, Instrumentation instrumentation) {
        System.out.println("runtime agent 探针启动");
        System.out.println("runtime agent 探针传入参数:" + arg);
        instrumentation.addTransformer(new RuntimeTransformer());
    }
}
  1. 定义Transformer与上面类似
  2. 打包成jar包
  3. 测试类启动,注意这里用到了sun的jar包,可以在pom中加入以下依赖
<dependencies>
    <dependency>
        <groupId>org.javassist</groupId>
        <artifactId>javassist</artifactId>
        <version>3.28.0-GA</version>
    </dependency>
    <dependency>
        <groupId>com.sun</groupId>
        <artifactId>tools</artifactId>
        <version>1.8</version>
        <scope>system</scope>
        <systemPath>${java.home}/../lib/tools.jar</systemPath>
    </dependency>
</dependencies>
public class RuntimeAgentTestMain {
    public static void main(String[] args) {
        System.out.println("=================main thread start=================");
        System.out.println("===APP start===");
        for (VirtualMachineDescriptor descriptor : VirtualMachine.list()) {
            if (descriptor.displayName().contains("RuntimeAgent")) {
                String jvmId = descriptor.id();
                System.out.println("当前的JVM:" + jvmId);
                try {
                    VirtualMachine vm = VirtualMachine.attach(jvmId);
                    vm.loadAgent("/Users/eric/workspace/java-agent/agent-runtime/target/agent-runtime-1.0-SNAPSHOT.jar=\"hello my runtime agent\"");
                    vm.detach();
                } catch (AttachNotSupportedException | IOException | AgentLoadException |
                         AgentInitializationException e) {
                    e.printStackTrace();
                }
            }
        }
        AppInit.init();
        System.out.println("=================main thread start=================");
    }
}
  1. 执行结果
=================main thread start=================
===APP start===
当前的JVM:73833
runtime agent 探针启动
runtime agent 探针传入参数:"hello my runtime agent"
拦截到的类名: org/example/AppInit
init 方法被拦截
---开始执行---
---App init started---
---App init finished---
---结束执行---
运行耗时: 1004
=================main thread start=================

原理

Instrucmentation

JDK定义的一个类,介绍他之前先介绍下JVMTI

JVMTI

JVMTI是java平台调试体系JPDA (Java Platform Debugger Architecture)的一部分。JPDA是Java虚拟机为了调试和监控JVM专门提供的一套接口。这里简单介绍下JDPA:
模块
层次
作用
JDI
提供Java API来远程控制被调试虚拟机
JDWP
定义JVMTI和JDI交互的数据格式
JVMTI
获取及控制当前虚拟机状态
JVMTI 全称是JVM Tool Interface,是 JVM 提供的给用户扩展使用的native编程接口集合,可以使开发者直接与C/C++以及JNI打交道。
如何使用这套接口:一般采用建立一个Agent的方式来使用JVMTI,这个Agent的表现形式是一个以C/C++编写的动态链接库,把Agent编译成一个动态链接库,Java启动或运行时,动态加载一个外部基于JVMTI 编写的dynamic module到Java进程内,然后触发JVM原生线程Attach Listener来执行这个dynamic module的回调函数。在回调函数体内,可以获取各种各样的VM级信息,注册感兴趣的VM事件,甚至控制VM行为。
JVMTI Agent是以动态链接库的形式被虚拟机加载的,区别于普通的动态链接库,一般会实现如下的一个或者多个函数:
  1. Agent_OnLoad函数,如果agent是在启动时加载的,通过JVM参数设置
  2. Agent_OnAttach函数,如果agent不是在启动时加载的,而是我们先attach到目标进程上,然后给对应的目标进程发送load命令来加载,则在加载过程中会调用Agent_OnAttach函数
  3. Agent_OnUnload函数,在agent卸载时调用
所以Instrumentation就是一个JVMTI Agent。Instrumentation 实现了Agent_OnLoad和Agent_OnAttach两个方法,也就是说在使用时,agent既可以在启动时加载,也可以在运行时动态加载。其中启动时加载通过类似
-javaagent:jar包路径的方式来间接加载instrument agent,其中运行时动态加载,使用了JVM attach的方式。JVM Attach 是指 JVM 提供的一种进程间通信的功能,能让一个进程传命令给另一个进程,并进行一些内部的操作,比如进行线程 dump,那么就需要执行 jstack 命令,然后把 pid 等参数传递给需要 dump 的线程来执行。

启动时加载Agent流程

  参数解析

  JVM创建时会进行参数解析,这里只关注读取到的JVM命令行参数 -agentlib -agentpath -javaagent,这几个参数用来指定Agent,JVM会根据这几个参数加载Agent构建了Agent Library链表。初始化 Agent 代码如下:https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/69087d08d473/src/share/vm/runtime/arguments.cpp

  加载Agent

  create_vm_init_agents这个函数通过遍历Agent链表来逐个加载Agent。首先通过lookup_agent_on_load来加载Agent并且找到Agent_OnLoad函数,这个函数是Agent的入口函数。如果没找到这个函数,则认为是加载了一个不合法的Agent,则什么也不做,否则调用这个函数,这样Agent的代码就开始执行起来了。对于使用Java Instrumentation API来编写Agent的方式来说,在解析阶段观察到在add_init_agent函数里面传递进去的是一个叫做"instrument"的字符串,其实这是一个动态链接库。在Linux里面,这个库叫做libinstrument.so,在BSD系统中叫做libinstrument.dylib,该动态链接库在{JAVA_HOME}/jre/lib/目录下。

  Instrument动态链接库

  libinstrument用来支持使用Java Instrumentation API来编写Agent,在libinstrument 中有一个非常重要的类称为:JPLISAgent(Java Programming Language Instrumentation Services Agent),它的作用是初始化所有通过Java Instrumentation API编写的Agent,并且也承担着通过JVMTI实现Java Instrumentation中暴露API的责任。我们已经知道,在JVM启动的时候,JVM会通过-javaagent参数加载Agent。最开始加载的是libinstrument动态链接库,然后在动态链接库里面找到JVMTI的入口方法:Agent_OnLoad。下面就来分析一下在libinstrument动态链接库中,Agent_OnLoad函数是怎么实现的。
  JNIEXPORT jint JNICALL
  DEF_Agent_OnLoad(JavaVM *  vm  , char *tail, void *   reserved  ) {
      initerror = createNewJPLISAgent(  vm  , &agent);
      if ( initerror == JPLIS_INIT_ERROR_NONE ) {
          if (parseArgumentTail(tail, &jarfile, &options) != 0) {
              fprintf(  stderr  , "-javaagent:   memory allocation   failure.\n");
              return JNI_ERR;
          }
          attributes = readAttributes(jarfile);
          premainClass = getAttribute(attributes, "Premain-Class");
          /* Save the jarfile name */
          agent->mJarfile = jarfile;
          /*
           * Convert JAR attributes into agent capabilities
           */
          convertCapabilityAttributes(attributes, agent);
          /*
           * Track (record) the agent class name and options data
           */
          initerror = recordCommandLineData(agent, premainClass, options);
      }
      return result;
  }
  上述代码片段是经过精简的libinstrument中Agent_OnLoad实现的,大概的流程就是:先创建一个JPLISAgent,然后将ManiFest中设定的一些参数解析出来, 比如(Premain-Class)等。创建了JPLISAgent之后,调用initializeJPLISAgent对这个Agent进行初始化操作。跟进initializeJPLISAgent看一下是如何初始化的:
  JPLISInitializationError initializeJPLISAgent(JPLISAgent *agent, JavaVM *vm, jvmtiEnv *jvmtienv) {
      /* check what capabilities are available */
      checkCapabilities(agent);
      /* check phase - if live phase then we don't need the VMInit event */
      jvmtierror = (*jvmtienv)->GetPhase(jvmtienv, &phase);
      /* now turn on the VMInit event */
      if ( jvmtierror == JVMTI_ERROR_NONE ) {
          jvmtiEventCallbacks callbacks;
          memset(&callbacks, 0, sizeof(callbacks));
            callbacks.VMInit = &eventHandlerVMInit;
          jvmtierror = (*jvmtienv)->SetEventCallbacks(jvmtienv,&callbacks,sizeof(callbacks));
      }
      if ( jvmtierror == JVMTI_ERROR_NONE ) {
          jvmtierror = (*jvmtienv)->SetEventNotificationMode(jvmtienv,JVMTI_ENABLE,JVMTI_EVENT_VM_INIT,NULL);
      }
      return (jvmtierror == JVMTI_ERROR_NONE)? JPLIS_INIT_ERROR_NONE : JPLIS_INIT_ERROR_FAILURE;
  }
  其中eventHandlerVMInit:
  void JNICALL  eventHandlerVMInit( jvmtiEnv *jvmtienv,JNIEnv *jnienv,jthread thread) {
     // ...
     success = processJavaStart( environment->mAgent, jnienv);
    // ...
  }
  jboolean  processJavaStart(JPLISAgent *agent,JNIEnv *jnienv) {
      result = createInstrumentationImpl(jnienv, agent);
      /*
       *  Load the Java agent, and call the premain.
       */
      if ( result ) {
          result = startJavaAgent(agent, jnienv, agent->mAgentClassName, agent->mOptionsString, agent->mPremainCaller);
      }
      return result;
  }
  jboolean startJavaAgent( JPLISAgent *agent,JNIEnv *jnienv,const char *classname,const char *optionsString,jmethodID agentMainMethod) {
    // ...  
    invokeJavaAgentMainMethod  (jnienv,agent->mInstrumentationImpl,agentMainMethod, classNameObject,optionsStringObject);
    // ...
  }
  看到这里,Instrument已经实例化,invokeJavaAgentMainMethod这个方法将我们的Premain方法执行起来了。接着,我们就可以根据Instrument实例来做我们想要做的事情了
  
  总结一下就是:
  1. Instrument是Java给我们提供的一种JVMTI Agent;
  2. libinstrument中有个类JPLISAgent初始化时会加载我们通过Instrument的编写的Agent实例,同时注册一个VMInit事件的回调函数;
  3. VM初始化的时候,发送事件,通过invoke执行我们定义的premain方法;

运行时加载Agent流程

运行时动态加载agent,使用了JVM attach的方式。JVM Attach是指 JVM 提供的一种进程间通信的功能,可以让一个进程传命令给另一个进程,并进行一些内部的操作,比如进行线程 dump:执行 jstack 命令,然后把 pid 等参数传递给需要 dump 的线程来执行。
 

Attach机制

Attach机制的主要功能就是实现了一个JVM进程和另一个JVM进程之前相互发送命令进行通信的机制。Attach机制可以对目标进程收集很多信息,如内存dump,线程dump,类信息统计(比如加载的类及大小以及实例个数等),动态加载agent,动态设置vm flag,打印vm flag,获取系统属性等等,这些对应的源码(AttachListener.cpp)
Attach机制实现的关键是两个JVM线程,分别是Attach Listener线程和Signal Dipatcher线程。
  1. Attach Listener线程负责接收外部发送来的命令,并处理JVM命令返回结果
  2. Signal Dispatcher线程负责将信号分发,然后将结果返回。
Attach Listener线程并非是随着JVM启动而启动的,而是需要在启动JVM时启动,或者当第一个JVM命令到来时才启动;Signal Dispatcher线程是随着JVM启动。

Attach流程

当外部进程Attach目标JVM时,会向目标进程发送sigquit信号,目标进程接收到信号之后广播给子线程,Signal Dispatcher线程会处理该信号,并会创建Attach Listener线程
Attach Listener线程启动之后,会创建监听套接字文件/tmp/.java_pid,表示外部进程Attach目标JVM成功。之后外部进程发送命令写入该套接字,Attach Listener线程监听该套接字,解析成功命令进行处理。
Attach Listener线程命令对应的源码(AttachListener.cpp)如下: