关于java:用JavaAgent欺骗你的JVM

41次阅读

共计 12251 个字符,预计需要花费 31 分钟才能阅读完成。

相熟 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:
11140
16372 RemoteMavenServer36
16392 AgentmainTest
20204 Jps
2460 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 办法中,应用 Instrumentation 的 addTransformer 办法拦挡类的加载:
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,重点看一下新加的第二个参数,当 canRetransform 为 true 时,示意容许从新定义 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 办法批改复制进去办法的内容,在新办法中进行了逻辑加强并调用了旧办法,最初将新办法增加到类中

https://zhuanlan.zhihu.com/p/…
https://zhuanlan.zhihu.com/p/…

https://zhuanlan.zhihu.com/p/…

https://zhuanlan.zhihu.com/p/…

https://zhuanlan.zhihu.com/p/…

https://zhuanlan.zhihu.com/p/…

https://zhuanlan.zhihu.com/p/…

https://zhuanlan.zhihu.com/p/…

正文完
 0