一、前言

所谓 Java Agent,其功能都是基于 java.lang.instrument 中的类去完成。Instrument 提供了允许 Java 编程语言代理检测 JVM 上运行的程序的功能,而检测的机制就是修改字节码。Instrument 位于 rt。jar 中,java。lang。instrument 包下,使用 Instrument 可以用来检测或协助运行在 JVM 中的程序;甚至对已加载 class 进行替换修改,这也就是我们常说的热部署、热加载。一句话总结 Instrument:检测类的加载行为对其进行干扰(修改替换)


图片

Instrument 的实现基于 JVMTI (Java Virtual Machine Tool Interface) 的,所谓 JVMTI 就是一套由 Java 虚拟机提供的,为 JVM 相关的工具提供的本地编程接口集合。JVMTI 基于事件驱动,简单点讲就是在 JVM 运行层面添加一些钩子可以供开发者去自定义实现相关功能。


有哪些开源软件使用了该技术?


https://github.com/alibaba/arthas

https://github.com/apache/skywalking

等等。

二、相关 API 初探

2.1 Instrumentation


java.lang.instrument 包下关键的类为:java.lang.instrument.Instrumentation。该接口提供一系列替换转化 class 定义的方法。接下来看一下该接口的主要方法进行以下说明:


addTransformer


用于注册 transformer。除了任何已注册的转换器所依赖的类的定义外,所有未来的类定义都将可以被 transformer 看到。当类被加载或被重新定义(redefine,可以是下方的 redefineClasses 触发)时,transformer 将被调用。如果 canRetransform 为 true,则表示当它们被 retransform 时(通过下方的 retransformClasses),该 transformer 也会被调用。addTransformer 共有如下两种重载方法:

  •  

void addTransformer(ClassFileTransformer transformer,boolean canRetransform)void addTransformer(ClassFileTransformer transformer)

redefineClasses

  •  

void redefineClasses(ClassDefinition... definitions)              throws ClassNotFoundException,                     UnmodifiableClassException

此方法用于替换不引用现有类文件字节的类定义,就像从源代码重新编译以进行修复并继续调试时所做的那样。该方法对一系列 ClassDefinition 进行操作,以便允许同时对多个类进行相互依赖的更改 (类 a 的重新定义可能需要类 B 的重新定义)。假如在 redifine 时,目标类正在执行中,那么执行中的行为还是按照原来字节码的定义执行,当对该类行为发起新的调用时,将会使用 redefine 之后的新行为。


注意:此 redefine 不会触发类的初始化行为。


当然 redefine 时,并不是随心所欲,我们可以重新定义方法体、常量池、属性、但是不可以添加、移除、重命名方法和方法和入参,不能更改方法签名或更改继承。当然,在未来的版本中,这些限制可能不复存在。


在转换之前,不会检查、验证和安装类文件字节,如果结果字节出现错误,此方法将抛出异常。而抛出异常将不会有类被重新定义。


retransformClasses


针对 JVM 已经加载的类进行转换,当类初始加载或重新定义类(redefineClass)时,可以被注册的 ClassFileTransformer 进行转化;但是针对那些已经加载完毕之后的类不会触发这个 transform 行为进而导致这些类无法被我们 agent 进行监听,所以可以通过 retransformClasses 触发一个事件,而这个事件可以被 ClassFileTransformer 捕获进而对这些类进行 transform。


此方法将针对每一个通过 addTransformer 注册的且 canRetransform 是 true 的,进行调用其 transform 方法,转换后的类文件字节被安装成为类的新定义,从而拥有新的行为。


redefineClasses 和 retransformClasses 区别


通过上面的定义可以看得出,貌似 redefineClasses 是在为 JVM 启动前未加载完成的 class 服务,而 retransformClasses 是针对 JVM 启动之后,那些已经完成加载初始化的 class 服务。


2.2 ClassFileTransformer


在我们的 agent 中,需要提供该接口的实现,以便在 JVM 定义类之前转换 class 字节码文件,该接口中就提供了一个方法,此方法的实现可以转换提供的类文件并返回一个新的替换类文件。

  •  

byte[] transform(ClassLoader loader,                 String className,                 Class<?> classBeingRedefined,                 ProtectionDomain protectionDomain,                 byte[] classfileBuffer)          throws IllegalClassFormatException

三、Java Agent 的两种实现


java agent 其实就是一个 jar 文件,通过在该 jar 文件中的 manifest 中通过相关属性指定要加载的 agent 实现类。对于 agent 的实现有两种方式:一种实现是通过命令行方式在 JVM 启动之前进行代理设置;另一种则是在 JVM 启动之后通过 attach 机制去设置。


3.1 JVM 启动前的 agent 实现


Instrument 是 JDK5 开始引入,在 JDK5 中 Instrument 要求在目标 JVM 程序运行之前通过命令行参数 javaagent 来设置代理类,在 JVM 初始化之前,Instrument 启动在 JVM 中设置回调函数,检测特点类加载情况完成实际增强工作。


-javaagent: jarpath[ =options]

这里 jarpath 就是我们的 agent jar 的路径,agent jar 必须符合 jar 文件规范。代理 JAR 文件的 manifest(META-INF/MANIFEST。MF)必须包含属性 Premain-Class。此属性的值是代理类的类名。代理类必须实现一个公共静态 premain 方法,该方法原则上与主应用程序入口点类似。在 JVM 初始化之后,将按照指定代理的顺序调用每个主方法(premain),然后将调用实际应用程序的主方法 (main)。每个 premain 方法必须按照启动顺序返回。


premain 方法可以有如下两种重载方法,如果两者同时存在,则优先调用多参数的方法:


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

我们的代理类将被 SystemClassLoader 进行加载,premain 方法将在和我们的主应用程序 main 方法同等的安全和类加载器规则下执行,主应用程序 main 方法可以干的,premain 都可以去干。如果我们的 agent 无法被解析,这包括 agent class 无法被加载、或 agent class 没有 premain 方法、agent class 的方法出现异常等都会导致 JVM 启动终止!


3.2 JVM 启动后的 agent 实现


JDK6 开始为 Instrument 增加很多强大的功能,其中要指出的就是在 JDK5 中如果想要完成增强处理,必须是在目标 JVM 程序启动前通过命令行指定 Instrument, 然后在实际应用中,目标程序可能是已经运行中,针对这种场景下如果要保证 JVM 不重启得以完成我们工作,这不是我们想要的,于是 JDK6 中 Instrument 提供了在 JVM 启动之后指定设置 java agent 达到 Instrument 的目的。


该实现需要确保以下 3 点:


  • agent jar 中 manifest 必须包含属性 Agent-Class,其值为 agent 类名。

  • agent 类中必须包含公有静态方法 agentmain

  • system classload 必须支持可以将 agent jar 添加到 system class path。


agent jar 将被添加到 system class path,这个路径就是 SystemClassLoader 加载主应用程序的地方,agent class 被加载后,JVM 将会尝试执行它的 agentmain 方法,同样的,如果以下两个方法都存在,则优先执行多参数方法:


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

看到这里,结合 JVM 前启动前 agent 的实现和 JVM 启动后 agent 的实现,可能想问是否可以在一个 agent class 中同时包含 premain、agentmain 呢,答案是可以的,只不过在 JVM 启动前不会执行 agentmain, 同样的,JVM 启动后不会执行 premain。


如果我们的 agent 无法启动(agent class 无法被加载、agentmain 出异常、agent class 没有合法的 agentmain 方法等),JVM 将不会终止!


四、 Manifest


4.1 属性构成


通过上述我们知道,有一个关键文件 META-INF/MANIFEST.MF, 我们需要在这个文件中指定 agent class,结下来看下相关属性:


图片

4.2 文件生成方式

有两种方式生成此文件:


  • 我们手动创建此文件

  • 通过 Maven 插件

<build>    <plugins>        <plugin>            <groupId>org.apache.maven.plugins</groupId>            <artifactId>maven-jar-plugin</artifactId>            <version>3.1.0</version>            <configuration>                <archive>                    <!--自动添加META-INF/MANIFEST.MF -->                    <manifest>                        <addClasspath>true</addClasspath>                    </manifest>                    <manifestEntries>                        <Premain-Class>xxx</Premain-Class>                        <Agent-Class>xxx</Agent-Class>                        <Can-Redefine-Classes>true</Can-Redefine-Classes>                        <Can-Retransform-Classes>true</Can-Retransform-Classes>                    </manifestEntries>                </archive>            </configuration>        </plugin>    </plugins></build>

四、实战


接下来通过实战来近距离感受下 java agent 的魅力。本次实战的目标是替换目标类的行为。

4.1 准备工作

这里初始化一个 Spring Boot 工程,随便搞一个简单的 controller 如下:

@RestControllerpublic class MainController {    @RequestMapping("/index")    public String index(){        return "hello world";    }}

那么当我访问这个地址时,浏览器将会展现 hello world 字样,如下:


图片

接下来我们将通过 java agent 来改变这个 controller 的行为。


4.2 JVM 启动前替换实现

4.2.1 定义 ClassFileTransformer 实现


在我们自定义的 ClassFileTransformer 中,通过 javassist 动态修改字节码,来更改 controller 输出的内容。

public class MyClassFileTransformer implements ClassFileTransformer{    @SneakyThrows    @Override    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,        ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {        if (className.contains("MainController")){            final ClassPool classPool = ClassPool.getDefault();            final CtClass clazz = classPool.get("com.cf.springboot.controller.MainController");            CtMethod convertToAbbr = clazz.getDeclaredMethod("index");            String methodBody = "return \"hello world【version2]\";";            convertToAbbr.setBody(methodBody);            // 返回字节码,并且detachCtClass对象            byte[] byteCode = clazz.toBytecode();            //detach的意思是将内存中曾经被javassist加载过的Date对象移除,如果下次有需要在内存中找不到会重新走javassist加载            clazz.detach();            return byteCode;        }        // 如果返回null则字节码不会被修改        return null;    }}

4.2.2 定义 agent class 实现

public class BeforeJvmAgent {
   public static void premain(String agentArgs, Instrumentation inst) {        System.out.println("premain invoke!");        inst.addTransformer(new MyClassFileTransformer());    }
   public static void main(String[] args) {        System.out.println("main invoke!");    }
}

4.2.3 打包,设置命令行参数启动 Spring Boot

图片

启动后,观察控制台输出。


图片

可以看到 premain 最新被执行了,这时候访问下试试。

图片

可以看到,我们的修改已经生效~

4.3 JVM 启动后替换实现

在这里,ClassFileTransformer 的实现我们还是复用 4.2 节中的,所以这里只需要看新实现。此刻开始,我们的应用属于一直启动之中了,我们要做的就是真正意义上的热替换。

4.3.1 agent class 实现


public class AfterJvmAgent {
   public static void agentmain(String agentArgs, Instrumentation inst)        throws ClassNotFoundException, UnmodifiableClassException {        inst.addTransformer(new MyClassFileTransformer(), true);        // 关键点        inst.retransformClasses(Class.forName("com.cf.springboot.controller.MainController",false,ClassLoader.getSystemClassLoader()));    }
    public static void main(String[] args) {}
}

这里关键的一点就是在我们的 agentmain 中手动 retransform 一下我们需要增强的类。

4.3.2 启动应用程序,并 attach


这里我们需要获取目标 JVM 程序,并且进行 attach 加载我们的 agent。


public static void main(String[] args) throws Exception{
   List<VirtualMachineDescriptor> list = VirtualMachine.list();    for (VirtualMachineDescriptor vmd : list) {        //如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid        //然后加载 agent.jar 发送给该虚拟机        System.out.println(vmd.displayName());        if (vmd.displayName().equals("com.cf.springboot.Application")) {            VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());            virtualMachine.loadAgent("/Users/zhuyu/code/spring-boot/after_jvm_agent/target/after_jvm_agent-0.0.1-SNAPSHOT.jar");            virtualMachine.detach();        }    } }
这个时候看再访问一下我们的请求:

图片

完美!

转自:我有一只喵喵,

链接:juejin.cn/post/7025410644463583239