简单来说,Java Agent就是JVM为了应用程序提供的具有检测功能的软件组件。在Java Agent的上下文中,通过JDK1.5出现的java.lang.instrument.Instrumentation来提供重新定义在运行时加载的类的内容的能力。那么这有什么用?其实对我们实现一些需要通过字节码的形式隐式注入到业务代码中的中间件非常有用(注意,这和Java的远程debug使用的JDWP(Java Debug Wire Protocol)原理不同),比较典型的有韩国Naver开源的应用性能管理工具Pinpoint(https://github.com/naver/pinpoint),当然,Java Agent还能实现动态对运行的Java应用进行字节码注入,做到“窥探”运行时的信息,典型的代表有Java追踪工具BTrace(https://github.com/btraceio/btrace)、阿里开源的JVM-SANDBOX(https://github.com/alibaba/jvm-sandbox)、Java在线问题诊断工具Greys(https://github.com/oldmanpushcart/greys-anatomy)等。
Java Agent的最常用方式是premain方式,它属于静态注入,即在Java应用程序启动时,在类加载器对类的字节码进行加载之前对类字节码进行“再改造”来做功能增强(例如实现AOP),另一种方式是HotSpot独有的attach方式(JDK1.6才出现),它能实现动态注入,即对已经运行的Java应用的类进行字节码增强。
说得这么神乎其神,我们直接举例来看!!!
我们先看看premain方式,前面说了,它属于静态注入,通过引用一个本地的Jar来在Java应用启动时去做类的增强:
java -javaagent:/root/application-premain.jar MyApplication
如上面这句java命令,我们假定/root目录下已经有一个符合Java Agent规范的Jar了(这里指application-premain.jar),而MyApplication指的是我们Java应用的启动类(main方法的类),于是我们就成功的对这个Java应用进行了静态注入,那我们接下来看看这个application-premain.jar需要遵循什么规范才能让JVM识别。
我们先看看MyApplication,假定这个Java应用相当简单,里面就一个打印语句:
package com.mzz.study.javaagent; import java.util.concurrent.TimeUnit; /** * 某个jvm进程 */ public class MyApplication { public static void main(String[] args) { while (true) { testPrint(); } } private static void testPrint() { System.out.println("这是我第 " + (System.currentTimeMillis() / 1000) + " 次想你"); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { } } }
假如我们希望通过代码植入在testPrint()方法的开始和结束各打印一个语句,那么我们该如何做?这个就是前面application-premain.jar要做的事情了,我们新建一个Maven项目,项目结构如下:
这里我们先重点关注premian包下面的MyTransformer和PremainMain类,既然是修改字节码,我们当然得告诉JVM该如何修改,这个是由MyTransformer类来完成的,具体代码如下:
package com.mzz.study.javaagent.premain; import javassist.ClassPool; import javassist.CtClass; import javassist.CtMethod; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.security.ProtectionDomain; import java.util.Objects; public class MyTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { //java自带的方法不进行处理 if(className.startsWith("java") || className.startsWith("sun")){ return classfileBuffer; } /** * 好像使用premain这个className是没问题的,但使用attach时className的.变成了/,所以如果是attach,那么这里需要替换 */ className = className.replace('/', '.'); // 只处理MyApplication类 if(!className.endsWith("MyApplication")) { return classfileBuffer; } try { ClassPool classPool = ClassPool.getDefault(); CtClass ctClass = classPool.get(className); CtMethod[] declaredMethods = ctClass.getDeclaredMethods(); for (CtMethod declaredMethod : declaredMethods) { // 只处理testPrint方法 if(Objects.equals("testPrint", declaredMethod.getName())) { /** * 在方法执行之前加入打印语句 */ declaredMethod.insertBefore("System.out.println(\"欧,亲爱的,\");"); /** * 在方法执行之后加入打印语句 */ declaredMethod.insertAfter("System.out.println(\"祝你一切安好!\");"); } } return ctClass.toBytecode(); } catch (Exception e) { e.printStackTrace(); } return classfileBuffer; } }
可以看出,我们可以看到MyTransformer实现了ClassFileTransformer接口,ClassFileTransformer是专门为Java Agent提供类转换功能的接口,其中有transform方法,在transform方法中,我们可以大显身手了,从上面代码片段可以看出,我们只是对MyApplication#testPrint方法的之前和末尾各加入了一条打印语句,你可能会奇怪,不是字节码吗?为啥可以直接像表达式引擎一样直接输入Java表达式?是因为这里使用了javassist这一轻量级的字节码工具,它帮我们屏蔽了字节码的细节,使我们可以只关注Java代码。
有了MyTransformer,在哪用?答案就在PremainMain类中,PremainMain要做的事情很简单,就是把我们自定义的类转换器MyTransformer加到前面提到的Instrumentation实例中:
package com.mzz.study.javaagent.premain; import java.lang.instrument.Instrumentation; public class PremainMain { /** * 注意,这个premain方法签名是Java Agent约定的,不要随意修改 * @param agentArgs * @param instrumentation */ public static void premain(String agentArgs, Instrumentation instrumentation) { instrumentation.addTransformer(new MyTransformer()); } }
需要注意的是,PremainMain#premain的方法签名是Java Agent内部约定的,不能随意修改。既然说是内部约定的,那么Java Agent是怎么知道premain方法在哪个类中呢?这是个好问题,答案就是在项目中resources/META-INF/MANIFEST.MF文件中,MANIFEST.MF文件内容如下:
Manifest-Version: 1.0 Created-By: manzhizhen Premain-Class: com.mzz.study.javaagent.premain.PremainMain
注意最后一行需要留一个空行,到目前为止,所有文件已经就绪,我们需要把它打成一个jar包(需要包含javassist),于是我们用到了maven-assembly-plugin插件,项目的pom.xml文件内容如下:
4.0.0 com.mzz mzz-study-javaagent 1.0-SNAPSHOT jar mzz-study-javaagent org.javassist javassist 3.21.0-GA com.sun tools 1.8 system ${java.home}/../lib/tools.jar org.apache.maven.plugins maven-compiler-plugin 3.8.0 1.7 UTF-8 org.codehaus.plexus plexus-compiler-eclipse 1.9.1 maven-assembly-plugin 3.1.1 src/main/resources/META-INF/MANIFEST.MF jar-with-dependencies make-assembly package single
我们直接执行Maven的打包命令:
mvn clean package
这里假定打包得到的jar是:application-premain.jar
一切就绪了,本以为直接可以在idea运行MyApplication时带上-javaagent参数,但实际上会报错,所以我们把application-premain.jar和MyApplication.java都拷贝到/root目录下(注意,为了方便javac,需要去掉MyApplication.java中package com.mzz.study.javaagent;)。
cd到/root目录下,执行:
javac MyApplication.java
于是我们看到/root下多了一个MyApplication.class字节码文件。后面直接执行我们上面提到的命令:
java -javaagent:/root/application-premain.jar MyApplication
于是我们在控制台看到了如下效果:
我们可以看到,原本只有一个打印语句的MyApplication类,在前后加了两条打印语句,目标达成!!!!!
前面我们也说了,Naver开源的应用性能管理工具Pinpoint用的就是静态注入这一招。接下来,我们看看如果应用程序已经运行,我们如何动态注入字节码,还是使用刚才这个项目,我们新增AttachAgent类:
package com.mzz.study.javaagent.attach; import com.mzz.study.javaagent.premain.MyTransformer; import java.lang.instrument.Instrumentation; import java.lang.instrument.UnmodifiableClassException; public class AttachAgent { /** * 注意:agentmain的方法签名也是约定好的,不能随意修改 * * 其实如果要支持premain和attach两种方式的话,可以把premain和agentmain两个方法写在一个类里,这里为了方便演示,写成了两个 * * @param agentArgs * @param instrumentation */ public static void agentmain(String agentArgs, Instrumentation instrumentation) { String targetClassPath = "com.mzz.study.javaagent.MyApplication"; for (Class> clazz : instrumentation.getAllLoadedClasses()) { // 过滤掉不能修改的类 if(!instrumentation.isModifiableClass(clazz)) { continue; } // 这里只修改我们关心的类 if (clazz.getName().equals(targetClassPath)) { // 最根本的目的还是把MyTransformer添加到instrumentation中 instrumentation.addTransformer(new MyTransformer(), true); try { instrumentation.retransformClasses(clazz); } catch (UnmodifiableClassException e) { e.printStackTrace(); } return; } } } }
这里约定好的方法是agentmain,不在是premain,但agentmain方法的本质也是把MyTransformer添加到instrumentation中,AttachAgent类准备好后,同样的问题来了,Java Agent如何知道AttachAgent中有它想要的agentmain方法?同理秘密还是在MANIFEST.MF文件中,在MANIFEST.MF中添加如下三行:
Manifest-Version: 1.0 Created-By: manzhizhen Premain-Class: com.mzz.study.javaagent.premain.PremainMain Agent-Class: com.mzz.study.javaagent.attach.AttachAgent Can-Redefine-Classes: true Can-Retransform-Classes: true
最关键的是Agent-Class这个key,指明了使用AttachAgent类,注意,同样最后需要多留一个空行。
准备好后,我们同样需要打一个包(注意,pom.xml文件需要依赖tools,参见上面给的pom.xml全文),执行如下命令打包:
mvn clean package
这里假定打包得到的jar还是(别忘了拷贝到/root目录下):application-premain.jar
注意,这个attach的case可以直接用idea演示,我们先在idea中直接运行MyApplication,于是我们看到如下源源不断的输出:
我们使用jps命令来看下MyApplication的进程ID,假如是:1234,于是我们创建一个AttachMain类,来准备通过它来对MyApplication动态注入字节码:
package com.mzz.study.javaagent.attach; import com.sun.tools.attach.VirtualMachine; import java.io.File; import java.util.concurrent.TimeUnit; /** * 先运行需要植入代码的MyApplication类 */ public class AttachMain { public static void main(String[] args) { // MyApplication的jvm进程ID String jvmPid = "1234"; File agentFile = new File("/root/application-premain.jar"); if (!agentFile.isFile()) { System.out.println("jar 不存在"); return; } try { VirtualMachine jvm = VirtualMachine.attach(jvmPid); jvm.loadAgent(agentFile.getAbsolutePath()); jvm.detach(); System.out.println("attach 成功"); } catch (Exception e) { e.printStackTrace(); } try { TimeUnit.SECONDS.sleep(10000); } catch (InterruptedException e) { } } }
这里需要关注的就是VirtualMachine类,它进行attach时需要知道目标Java进程的ID,因为不需要输入IP,所以它的设定是只支持本地attach,准备好AttachMain后,直接运行AttachMain,我们可以看到MyApplication的控制台输出变了:
说明我们确实动态对MyApplication注入了字节码。attach的这种动态字节码注入方式衍生出了很多工具,像本文开头提到的JVM-SANDBOX、BTrace和Greys等。
好了,本文就到这里了,希望对各位能有所帮助。