javaagent 应用 | 七日打卡

2,676 阅读9分钟

前景提要

Java 热更新 讲解了如何手动替换class文件, 然后通过监听文件是否被修改, 重复创建新的ClassLoader实现了热更新的功能.

这种方式的热更新是jvm原生支持的方式, 但是缺点也很明显:

  1. 不够灵活, 需要手动修改文件等操作

  2. 重复创建类加载器, 并且卸载困难, 会增加系统负担

  3. 使用起来具有代码侵入性, 需要对代码进行一定改造

javaagent是什么?

顾名思义, javaagent就是一个可以作为java代理的工具, 简单来说就是一个可供用于编写的java切面, 它的主要功能就是为用户提供了在 JVM 将字节码文件读入内存之后,JVM 使用对应的字节流在 Java 堆中生成一个 Class 对象之前,用户可以对其字节码进行修改的能力,从而 JVM 也将会使用用户修改过之后的字节码进行新的Class 对象的创建(打破了一个类只能加载一次的规则)。

javaagent的使用对于你自身的代码是无侵入性的.

从功能上来看, 它完美的解决了我们自定义类加载器实现热更新的缺点

javaagent的使用

javaagent根据加载时机的不同分为两种

  1. 通过命令行的形式, 启动java 项目时就声明使用javaagent

  2. 对于一个正在运行的项目, 使用javaagent, 此时的javaagent就类似一个可插拔的工具, 需要时才启动.

jvm启动前使用javaagent---premain模式

使用方法

javaagent本身作为java命令的一个参数, 可以在本身的项目启动前, 额外指定一个jar包, 该jar包包含了你期望通过javaagent实现的逻辑

具体的命令行demo如下:

一个java程序中-javaagent参数的个数是没有限制的,所以可以添加任意多个javaagent。所有的java agent会按照你定义的顺序执行

java -javaagent:agent1.jar -javaagent:agent2.jar -jar MyProgram.jar
  1. agent1.jar, agent2.jar是你按照要求打包生成的agent jar包 包含你期望在类加载前进行的处理

  2. MyProgram.jar 自己业务的jar包

premain模式 agent jar包的实现流程

1. 定义MANIFEST.MF文件

首先需要一份配置文件, 通常包含以下配置

Manifest-Version: 1.0
Premain-Class: javaagent.PreMainTraceAgent
参数名含义
Manifest-VersionMANIFEST.MF的版本
Premain-Class包含premain方法的类全名

2. premain方法

创建一个类, 其中包含名为premain的静态方法即可, 无需继承, 主要修改的逻辑包含在transform方法中

package javaagent;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class PreMainTraceAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        // agentArgs 外部参数
        // inst Instrumentation的实例
        System.out.println("agentArgs : " + agentArgs);
        // 添加类转换器, 类似注册一个拦截器
        // 类在第一次加载的时候发出 ClassFileLoad 事件, 会被拦截器拦截
        inst.addTransformer(new DefineTransformer(), true);
    }

    // 每次类加载时, 都会触发ClassFileTransformer中的transform方法
    // 因此就可以在这个方法中在类加载前, 对它进行拦截并处理
    static class DefineTransformer implements ClassFileTransformer{
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
            // 转换方法里可以实现对字节码的修改, 具体的修改可以采用ASM等方式
            // demo只是单纯的打印字符串, 表示拦截成功
            System.out.println("premain load Class:" + className);
            return classfileBuffer;
        }
    }
}

3. 打包成jar

采用maven或者gradle打包即可 注意检查MANIFEST.MF文件打包后是否正确

项目结构如下

----src
--------main
--------|------java
--------|----------javaagent
--------|------------PreMainTraceAgent
--------|resources
-----------META-INF
--------------MANIFEST.MF

premain模式的运行原理

1.创建并初始化 JPLISAgent

2.MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容

3.监听 VMInit 事件,在 JVM 初始化完成之后做下面的事情:

(1)创建 InstrumentationImpl 对象 ;

(2)监听 ClassFileLoadHook 事件 ;

(3)调用 InstrumentationImpl 的loadClassAndCallPremain方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法

优缺点

优点

  1. 类似切面, 能够成功拦截部分系统类和用户类, 在类第一次加载前对字节码进行修改, 没有侵入性, 对业务透明

  2. 自定义ClassFileTransformer里的transform方法, 在每次classLoader加载类的时候都会拦截触发, 也就是说你只要能够让classLoader重新加载类, 这部分逻辑都会生效, 可以做一些文章

缺点

  1. jvm中的类只会被类加载器加载一次, 因此正常情况下transform方法对于一个类,同一个类加载器, 只会执行一次, 如果不重新定义类加载器加载类的话, 无法实现热更新功能.

  2. 对于已经正在运行的java项目, 没办法使用javaagent的功能

jvm启动后使用javaagent---agentmain模式

在premain模式的基础上, java升级提供了agentmain模式.

简而言之,agentmain 可以在类加载之后再次加载一个类,也就是重定义,你就可以通过在重定义的时候进行修改类了,甚至不需要创建新的类加载器,JVM 已经在内部对类进行了重定义(重定义的过程相当复杂)。

agentmain可以直接对一个正在运行的java程序起作用, 因此通过attach工具启动一个包含agentmain的jar包载入正在运行的java程序即可

agentmain模式 agent jar包的实现流程

1. 定义MANIFEST.MF文件

首先需要一份配置文件, 通常包含以下配置

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Agent-Class: cn.think.in.java.clazz.loader.asm.agent.PreMainTraceAgent
参数名含义
Manifest-VersionMANIFEST.MF的版本
Can-Redefine-Classestrue表示能重定义此代理所需的类,默认值为 false(可选)
Can-Retransform-Classestrue 表示能重转换此代理所需的类,默认值为 false (可选)
Agent-Class包含agenmain方法的类全名

注意在jvm前启动前使用javaagent的时候, 必须要定义Can-Redefine-Classes和Can-Retransform-Classes为true, 表示允许对一个类的二进制流在读取后进行重定义(改变字节码), 然后再进行相应类的加载流程.

2. agentmain方法

创建一个类, 其中包含名为agentmain的静态方法即可, 无需继承, 主要修改的逻辑包含在transform方法中

public class AgentMainTraceAgent {

  public static void agentmain(String agentArgs, Instrumentation inst)
      throws UnmodifiableClassException {
    System.out.println("Agent Main called");
    System.out.println("agentArgs : " + agentArgs);
    inst.addTransformer(new ClassFileTransformer() {

      @Override
      public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
          ProtectionDomain protectionDomain, byte[] classfileBuffer)
          throws IllegalClassFormatException {
          System.out.println("agentmain load Class  :" + className);
          return classfileBuffer;
      }
    }, true);
    inst.retransformClasses(Account.class);
  }

通过执行retransformClasses方法, 使得正在运行的java程序中的某个类, 重新进行类加载的过程, 也就会被载入的agentmain方法拦截, 执行相应的逻辑

inst.retransformClasses(Account.class); 
这段代码的意思是,重新转换目标类,也就是 Account 类。
也就是说,你需要重新定义哪个类,需要指定,否则 JVM 不可能知道。

3. 打包成jar包

采用maven或者gradle打包即可 注意检查MANIFEST.MF文件打包后是否正确

4. 通过attach工具加载

通过VirtualMachine类的attach(pid)方法,便可以attach到一个运行中的java进程上,之后便可以通过loadAgent(agentJarPath)来将agent的jar包注入到对应的进程,然后对应的进程会调用agentmain方法。

   // 列出所有VM实例
   List<VirtualMachineDescriptor> list = VirtualMachine.list();
   // attach目标VM
   VirtualMachine.attach(descriptor.id());
   // 目标VM加载Agent
   VirtualMachine#loadAgent("代理Jar路径","命令参数");

agentmain模式的运行原理

1.创建并初始化JPLISAgent

2.解析MANIFEST.MF 里的参数,并根据这些参数来设置 JPLISAgent 里的一些内容

3.监听 VMInit 事件,在 JVM 初始化完成之后做下面的事情:

(1)创建 InstrumentationImpl 对象 ;

(2)监听 ClassFileLoadHook 事件 ;

(3)调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class类的agentmain方法。

优缺点

优点

  1. 能够对运行中对java程序直接加载java agent, 无需在启动时指定

  2. 在不重新定义类加载器的情况下, 对于已经加载的类重新加载

  3. 没有侵入性, 对业务透明

  4. 比较完美的实现热更新的功能

缺点

  1. 因为涉及到对类对重新加载, 因此对于类字节码在修改时是有一定要求, 要求如下
1.父类是同一个;
2. 实习那的接口数也要相同;
3. 类访问符必须一致;
4. 字段数和字段名必须一致;
5. 新增的方法必须是 private static/final 的;
6. 可以删除修改方法;

premain模式和agentmain模式的对比

premain和agentmain两种方式最终的目的都是为了回调Instrumentation实例并激活sun.instrument.InstrumentationImpl#transform()(InstrumentationImpl是Instrumentation的实现类)从而回调注册到Instrumentation中的ClassFileTransformer实现字节码修改,本质功能上没有很大区别。两者的非本质功能的区别如下:

  1. premain方式是JDK1.5引入的,agentmain方式是JDK1.6引入的,JDK1.6之后可以自行选择使用premain或者agentmain。

  2. premain需要通过命令行使用外部代理jar包,即-javaagent:代理jar包路径;agentmain则可以通过attach机制直接附着到目标VM中加载代理,也就是使用agentmain方式下,操作attach的程序和被代理的程序可以是完全不同的两个程序。

  3. premain方式回调到ClassFileTransformer中的类是虚拟机加载的所有类,这个是由于代理加载的顺序比较靠前决定的,在开发者逻辑看来就是:所有类首次加载并且进入程序main()方法之前,premain方法会被激活,然后所有被加载的类都会执行ClassFileTransformer列表中的回调。

  4. agentmain方式由于是采用attach机制,被代理的目标程序VM有可能很早之前已经启动,当然其所有类已经被加载完成,这个时候需要借助Instrumentation#retransformClasses(Class<?>...classes)让对应的类可以重新转换,从而激活重新转换的类执行ClassFileTransformer列表中的回调。

  5. 通过premain方式的代理Jar包进行了更新的话,需要重启服务器,而agentmain方式的Jar包如果进行了更新的话,需要重新attach,但是agentmain重新attach还会导致重复的字节码插入问题,不过也有Hotswap和DCE VM方式来避免。