写点什么

偷天换日,用 JavaAgent 欺骗你的 JVM

作者:码农参上
  • 2021 年 12 月 06 日
  • 本文字数:9133 字

    阅读完需:约 30 分钟

偷天换日,用JavaAgent欺骗你的JVM

熟悉 Spring 的小伙伴们应该都对 aop 比较了解,面向切面编程允许我们在目标方法的前后织入想要执行的逻辑,而今天要给大家介绍的 Java Agent 技术,在思想上与 aop 比较类似,翻译过来可以被称为 Java 代理Java 探针技术。


Java Agent 出现在 JDK1.5 版本以后,它允许程序员利用 agent 技术构建一个独立于应用程序的代理程序,用途也非常广泛,可以协助监测、运行、甚至替换其他 JVM 上的程序,先从下面这张图直观的看一下它都被应用在哪些场景:



看到这里你是不是也很好奇,究竟是什么神仙技术,能够应用在这么多场景下,那今天我们就来挖掘一下,看看神奇的 Java Agent 是如何工作在底层,默默支撑了这么多优秀的应用。


回到文章开头的类比,我们还是用和 aop 比较的方式,来先对 Java Agent 有一个大致的了解:


  • 作用级别:aop 运行于应用程序内的方法级别,而 agent 能够作用于虚拟机级别

  • 组成部分:aop 的实现需要目标方法和逻辑增强部分的方法,而 Java Agent 要生效需要两个工程,一个是 agent 代理,另一个是需要被代理的主程序

  • 执行场合:aop 可以运行在切面的前后或环绕等场合,而 Java Agent 的执行只有两种方式,jdk1.5 提供的preMain模式在主程序运行前执行,jdk1.6 提供的agentMain在主程序运行后执行


下面我们就分别看一下在两种模式下,如何动手实现一个 agent 代理程序。

Premain 模式

Premain 模式允许在主程序执行前执行一个 agent 代理,实现起来非常简单,下面我们分别实现两个组成部分。

agent

先写一个简单的功能,在主程序执行前打印一句话,并打印传递给代理的参数:


public class MyPreMainAgent {    public static void premain(String agentArgs, Instrumentation inst) {        System.out.println("premain start");        System.out.println("args:"+agentArgs);    }}
复制代码


在写完了 agent 的逻辑后,需要把它打包成jar文件,这里我们直接使用 maven 插件打包的方式,在打包前进行一些配置。


<build>    <plugins>        <plugin>            <groupId>org.apache.maven.plugins</groupId>            <artifactId>maven-jar-plugin</artifactId>            <version>3.1.0</version>            <configuration>                <archive>                    <manifest>                        <addClasspath>true</addClasspath>                    </manifest>                    <manifestEntries>                        <Premain-Class>com.cn.agent.MyPreMainAgent</Premain-Class>                                                    <Can-Redefine-Classes>true</Can-Redefine-Classes>                        <Can-Retransform-Classes>true</Can-Retransform-Classes>                        <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>                    </manifestEntries>                </archive>            </configuration>        </plugin>    </plugins></build>
复制代码


配置的打包参数中,通过manifestEntries的方式添加属性到MANIFEST.MF文件中,解释一下里面的几个参数:


  • Premain-Class:包含premain方法的类,需要配置为类的全路径

  • Can-Redefine-Classes:为true时表示能够重新定义 class

  • Can-Retransform-Classes:为true时表示能够重新转换 class,实现字节码替换

  • Can-Set-Native-Method-Prefix: 为true时表示能够设置 native 方法的前缀


其中Premain-Class为必须配置,其余几项是非必须选项,默认情况下都为false,通常也建议加入,这几个功能我们会在后面具体介绍。在配置完成后,使用mvn命令打包:


mvn clean package
复制代码


打包完成后生成myAgent-1.0.jar文件,我们可以解压jar文件,看一下生成的MANIFEST.MF文件:



可以看到,添加的属性已经被加入到了文件中。到这里,agent 代理部分就完成了,因为代理不能够直接运行,需要附着于其他程序,所以下面新建一个工程来实现主程序。

主程序

在主程序的工程中,只需要一个能够执行的main方法的入口就可以了。


public class AgentTest {    public static void main(String[] args) {        System.out.println("main project start");    }}
复制代码


在主程序完成后,要考虑的就是应该如何将主程序与 agent 工程连接起来。这里可以通过-javaagent参数来指定运行的代理,命令格式如下:


java -javaagent:myAgent.jar -jar AgentTest.jar
复制代码


并且,可以指定的代理的数量是没有限制的,会根据指定的顺序先后依次执行各个代理,如果要同时运行两个代理,就可以按照下面的命令执行:


java -javaagent:myAgent1.jar -javaagent:myAgent2.jar  -jar AgentTest.jar
复制代码


以我们在 idea 中执行程序为例,在VM options中加入添加启动参数:


-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Hydra-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Trunks
复制代码


执行main方法,查看输出结果:



根据执行结果的打印语句可以看出,在执行主程序前,依次执行了两次我们的 agent 代理。可以通过下面的图来表示执行代理与主程序的执行顺序。


缺陷

在提供便利的同时,premain 模式也有一些缺陷,例如如果 agent 在运行过程中出现异常,那么也会导致主程序的启动失败。我们对上面例子中 agent 的代码进行一下改造,手动抛出一个异常。


public static void premain(String agentArgs, Instrumentation inst) {    System.out.println("premain start");    System.out.println("args:"+agentArgs);    throw new RuntimeException("error");}
复制代码


再次运行主程序:



可以看到,在 agent 抛出异常后主程序也没有启动。针对 premain 模式的一些缺陷,在 jdk1.6 之后引入了 agentmain 模式。

Agentmain 模式

agentmain 模式可以说是 premain 的升级版本,它允许代理的目标主程序的 jvm 先行启动,再通过attach机制连接两个 jvm,下面我们分 3 个部分实现。

agent

agent 部分和上面一样,实现简单的打印功能:


public class MyAgentMain {    public static void agentmain(String agentArgs, Instrumentation instrumentation) {        System.out.println("agent main start");        System.out.println("args:"+agentArgs);    }}
复制代码


修改 maven 插件配置,指定Agent-Class


<plugin>    <groupId>org.apache.maven.plugins</groupId>    <artifactId>maven-jar-plugin</artifactId>    <version>3.1.0</version>    <configuration>        <archive>            <manifest>                <addClasspath>true</addClasspath>            </manifest>            <manifestEntries>                <Agent-Class>com.cn.agent.MyAgentMain</Agent-Class>                <Can-Redefine-Classes>true</Can-Redefine-Classes>                <Can-Retransform-Classes>true</Can-Retransform-Classes>            </manifestEntries>        </archive>    </configuration></plugin>
复制代码

主程序

这里我们直接启动主程序等待代理被载入,在主程序中使用了System.in进行阻塞,防止主进程提前结束。


public class AgentmainTest {    public static void main(String[] args) throws IOException {        System.in.read();    }}
复制代码

attach 机制

和 premain 模式不同,我们不能再通过添加启动参数的方式来连接 agent 和主程序了,这里需要借助com.sun.tools.attach包下的VirtualMachine工具类,需要注意该类不是 jvm 标准规范,是由 Sun 公司自己实现的,使用前需要引入依赖:


<dependency>    <groupId>com.sun</groupId>    <artifactId>tools</artifactId>    <version>1.8</version>    <scope>system</scope>    <systemPath>${JAVA_HOME}\lib\tools.jar</systemPath></dependency>
复制代码


VirtualMachine代表了一个要被附着的 java 虚拟机,也就是程序中需要监控的目标虚拟机,外部进程可以使用VirtualMachine的实例将 agent 加载到目标虚拟机中。先看一下它的静态方法attach


public static VirtualMachine attach(String var0);
复制代码


通过attach方法可以获取一个 jvm 的对象实例,这里传入的参数是目标虚拟机运行时的进程号pid。也就是说,我们在使用attach前,需要先获取刚才启动的主程序的pid,使用jps命令查看线程pid


1114016372 RemoteMavenServer3616392 AgentmainTest20204 Jps2460 Launcher
复制代码


获取到主程序AgentmainTest运行时pid是 16392,将它应用于虚拟机的连接。


public class AttachTest {    public static void main(String[] args) {        try {            VirtualMachine  vm= VirtualMachine.attach("16392");            vm.loadAgent("F:\\Workspace\\MyAgent\\target\\myAgent-1.0.jar","param");        } catch (Exception e) {            e.printStackTrace();        }    }}
复制代码


在获取到VirtualMachine实例后,就可以通过loadAgent方法可以实现注入 agent 代理类的操作,方法的第一个参数是代理的本地路径,第二个参数是传给代理的参数。执行AttachTest,再回到主程序AgentmainTest的控制台,可以看到执行了了 agent 中的代码:



这样,一个简单的 agentMain 模式代理就实现完成了,可以通过下面这张图再梳理一下三个模块之间的关系。


应用

到这里,我们就已经简单地了解了两种模式的实现方法,但是作为高质量程序员,我们肯定不能满足于只用代理单纯地打印语句,下面我们再来看看能怎么利用 Java Agent 搞点实用的东西。


在上面的两种模式中,agent 部分的逻辑分别是在premain方法和agentmain方法中实现的,并且,这两个方法在签名上对参数有严格的要求,premain方法允许以下面两种方式定义:


public static void premain(String agentArgs)public static void premain(String agentArgs, Instrumentation inst)
复制代码


agentmain方法允许以下面两种方式定义:


public static void agentmain(String agentArgs)public static void agentmain(String agentArgs, Instrumentation inst)
复制代码


如果在 agent 中同时存在两种签名的方法,带有Instrumentation参数的方法优先级更高,会被 jvm 优先加载,它的实例inst会由 jvm 自动注入,下面我们就看看能通过Instrumentation实现什么功能。

Instrumentation

先大体介绍一下Instrumentation接口,其中的方法允许在运行时操作 java 程序,提供了诸如改变字节码,新增 jar 包,替换 class 等功能,而通过这些功能使 Java 具有了更强的动态控制和解释能力。在我们编写 agent 代理的过程中,Instrumentation中下面 3 个方法比较重要和常用,我们来着重看一下。

addTransformer

addTransformer方法允许我们在类加载之前,重新定义 Class,先看一下方法的定义:


void addTransformer(ClassFileTransformer transformer);
复制代码


ClassFileTransformer是一个接口,只有一个transform方法,它在主程序的main方法执行前,装载的每个类都要经过transform执行一次,可以将它称为转换器。我们可以实现这个方法来重新定义 Class,下面就通过一个例子看看具体如何使用。


首先,在主程序工程创建一个Fruit类:


public class Fruit {    public void getFruit(){        System.out.println("banana");    }}
复制代码


编译完成后复制一份 class 文件,并将其重命名为Fruit2.class,再修改Fruit中的方法为:


public void getFruit(){    System.out.println("apple");}
复制代码


创建主程序,在主程序中创建了一个Fruit对象并调用了其getFruit方法:


public class TransformMain {    public static void main(String[] args) {        new Fruit().getFruit();    }}
复制代码


这时执行结果会打印apple,接下来开始实现 premain 代理部分。


在代理的premain方法中,使用InstrumentationaddTransformer方法拦截类的加载:


public class TransformAgent {    public static void premain(String agentArgs, Instrumentation inst) {        inst.addTransformer(new FruitTransformer());    }}
复制代码


FruitTransformer类实现了ClassFileTransformer接口,转换 class 部分的逻辑都在transform方法中:


public class FruitTransformer implements ClassFileTransformer {    @Override    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,                            ProtectionDomain protectionDomain, byte[] classfileBuffer){        if (!className.equals("com/cn/hydra/test/Fruit"))            return classfileBuffer;
String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class"; return getClassBytes(fileName); }
public static byte[] getClassBytes(String fileName){ File file = new File(fileName); try(InputStream is = new FileInputStream(file); ByteArrayOutputStream bs = new ByteArrayOutputStream()){ long length = file.length(); byte[] bytes = new byte[(int) length];
int n; while ((n = is.read(bytes)) != -1) { bs.write(bytes, 0, n); } return bytes; }catch (Exception e) { e.printStackTrace(); return null; } }}
复制代码


transform方法中,主要做了两件事:


  • 因为addTransformer方法不能指明需要转换的类,所以需要通过className判断当前加载的 class 是否我们要拦截的目标 class,对于非目标 class 直接返回原字节数组,注意className的格式,需要将类全限定名中的.替换为/

  • 读取我们之前复制出来的 class 文件,读入二进制字符流,替换原有classfileBuffer字节数组并返回,完成 class 定义的替换


将 agent 部分打包完成后,在主程序添加启动参数:


-javaagent:F:\Workspace\MyAgent\target\transformAgent-1.0.jar
复制代码


再次执行主程序,结果打印:


banana
复制代码


这样,就实现了在main方法执行前 class 的替换。

redefineClasses

我们可以直观地从方法的名字上来理解它的作用,重定义 class,通俗点来讲的话就是实现指定类的替换。方法定义如下:


void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;
复制代码


它的参数是可变长的ClassDefinition数组,再看一下ClassDefinition的构造方法:


public ClassDefinition(Class<?> theClass,byte[] theClassFile) {...}
复制代码


ClassDefinition中指定了的 Class 对象和修改后的字节码数组,简单来说,就是使用提供的类文件字节,替换了原有的类。并且,在redefineClasses方法重定义的过程中,传入的是ClassDefinition的数组,它会按照这个数组顺序进行加载,以便满足在类之间相互依赖的情况下进行更改。


下面通过一个例子来看一下它的生效过程,premain 代理部分:


public class RedefineAgent {    public static void premain(String agentArgs, Instrumentation inst)             throws UnmodifiableClassException, ClassNotFoundException {        String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class";        ClassDefinition def=new ClassDefinition(Fruit.class,                FruitTransformer.getClassBytes(fileName));        inst.redefineClasses(new ClassDefinition[]{def});    }}
复制代码


主程序可以直接复用上面的,执行后打印:


banana
复制代码


可以看到,用我们指定的 class 文件的字节替换了原有类,即实现了指定类的替换。

retransformClasses

retransformClasses应用于 agentmain 模式,可以在类加载之后重新定义 Class,即触发类的重新加载。首先看一下该方法的定义:


void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
复制代码


它的参数classes是需要转换的类数组,可变长参数也说明了它和redefineClasses方法一样,也可以批量转换类的定义。


下面,我们通过例子来看看如何使用retransformClasses方法,agent 代理部分代码如下:


public class RetransformAgent {    public static void agentmain(String agentArgs, Instrumentation inst)            throws UnmodifiableClassException {        inst.addTransformer(new FruitTransformer(),true);        inst.retransformClasses(Fruit.class);        System.out.println("retransform success");    }}
复制代码


看一下这里调用的addTransformer方法的定义,与上面略有不同:


void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
复制代码


ClassFileTransformer转换器依旧复用了上面的FruitTransformer,重点看一下新加的第二个参数,当canRetransformtrue时,表示允许重新定义 class。这时,相当于调用了转换器ClassFileTransformer中的transform方法,会将转换后 class 的字节作为新类定义进行加载。


主程序部分代码,我们在死循环中不断的执行打印语句,来监控类是否发生了改变:


public class RetransformMain {    public static void main(String[] args) throws InterruptedException {        while(true){            new Fruit().getFruit();            TimeUnit.SECONDS.sleep(5);        }    }}
复制代码


最后,使用 attach api 注入 agent 代理到主程序中:


public class AttachRetransform {    public static void main(String[] args) throws Exception {        VirtualMachine vm = VirtualMachine.attach("6380");        vm.loadAgent("F:\\Workspace\\MyAgent\\target\\retransformAgent-1.0.jar");    }}
复制代码


回到主程序控制台,查看运行结果:



可以看到在注入代理后,打印语句发生变化,说明类的定义已经被改变并进行了重新加载。

其他

除了这几个主要的方法外,Instrumentation中还有一些其他方法,这里仅简单列举一下常用方法的功能:


  • removeTransformer:删除一个ClassFileTransformer类转换器

  • getAllLoadedClasses:获取当前已经被加载的 Class

  • getInitiatedClasses:获取由指定的ClassLoader加载的 Class

  • getObjectSize:获取一个对象占用空间的大小

  • appendToBootstrapClassLoaderSearch:添加 jar 包到启动类加载器

  • appendToSystemClassLoaderSearch:添加 jar 包到系统类加载器

  • isNativeMethodPrefixSupported:判断是否能给 native 方法添加前缀,即是否能够拦截 native 方法

  • setNativeMethodPrefix:设置 native 方法的前缀

Javassist

在上面的几个例子中,我们都是直接读取的 class 文件中的字节来进行 class 的重定义或转换,但是在实际的工作环境中,可能更多的是去动态的修改 class 文件的字节码,这时候就可以借助 javassist 来更简单的修改字节码文件。


简单来说,javassist 是一个分析、编辑和创建 java 字节码的类库,在使用时我们可以直接调用它提供的 api,以编码的形式动态改变或生成 class 的结构。相对于 ASM 等其他要求了解底层虚拟机指令的字节码框架,javassist 真的是非常简单和快捷。


下面,我们就通过一个简单的例子,看看如何将 Java agent 和 Javassist 结合在一起使用。首前先引入 javassist 的依赖:


<dependency>    <groupId>org.javassist</groupId>    <artifactId>javassist</artifactId>    <version>3.20.0-GA</version></dependency>
复制代码


我们要实现的功能是通过代理,来计算方法执行的时间。premain 代理部分和之前基本一致,先添加一个转换器:


public class Agent {    public static void premain(String agentArgs, Instrumentation inst) {        inst.addTransformer(new LogTransformer());    }
static class LogTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (!className.equals("com/cn/hydra/test/Fruit")) return null;
try { return calculate(); } catch (Exception e) { e.printStackTrace(); return null; } } }}
复制代码


calculate方法中,使用 javassist 动态的改变了方法的定义:


static byte[] calculate() throws Exception {    ClassPool pool = ClassPool.getDefault();    CtClass ctClass = pool.get("com.cn.hydra.test.Fruit");    CtMethod ctMethod = ctClass.getDeclaredMethod("getFruit");    CtMethod copyMethod = CtNewMethod.copy(ctMethod, ctClass, new ClassMap());    ctMethod.setName("getFruit$agent");
StringBuffer body = new StringBuffer("{\n") .append("long begin = System.nanoTime();\n") .append("getFruit$agent($$);\n") .append("System.out.println(\"use \"+(System.nanoTime() - begin) +\" ns\");\n") .append("}"); copyMethod.setBody(body.toString()); ctClass.addMethod(copyMethod); return ctClass.toBytecode();}
复制代码


在上面的代码中,主要实现了这些功能:


  • 利用全限定名获取类CtClass

  • 根据方法名获取方法CtMethod,并通过CtNewMethod.copy方法复制一个新的方法

  • 修改旧方法的方法名为getFruit$agent

  • 通过setBody方法修改复制出来方法的内容,在新方法中进行了逻辑增强并调用了旧方法,最后将新方法添加到类中


主程序仍然复用之前的代码,执行查看结果,完成了代理中的执行时间统计功能:



这时候我们可以再通过反射看一下:


for (Method method : Fruit.class.getDeclaredMethods()) {    System.out.println(method.getName());    method.invoke(new Fruit());    System.out.println("-------");}
复制代码


查看结果,可以看到类中确实已经新增了一个方法:



除此之外,javassist 还有很多其他的功能,例如新建 Class、设置父类、读取和写入字节码等等,大家可以在具体的场景中学习它的用法。

总结

虽然我们在平常的工作中,直接用到 Java Agent 的场景可能并不是很多,但是在热部署、监控、性能分析等工具中,它们可能隐藏在业务系统的角落里,一直在默默发挥着巨大的作用。


本文从 Java Agent 的两种模式入手,手动实现并简要分析了它们的工作流程,虽然在这里只利用它们完成了一些简单的功能,但是不得不说,正是 Java Agent 的出现,让程序的运行不再循规蹈矩,也为我们的代码提供了无限的可能性。


如果文章对您有所帮助,欢迎关注公众号 码农参上。有趣、深入、与你聊聊技术。

发布于: 2 小时前阅读数: 10
用户头像

码农参上

关注

公众号:码农参上 2021.03.30 加入

公众号【码农参上】,有趣、深入、与你聊聊技术。

评论

发布
暂无评论
偷天换日,用JavaAgent欺骗你的JVM