聊聊大厂都在用的 JavaAgent 下

简介: 聊聊大厂都在用的 JavaAgent 下

应用

到这里,我们就已经简单地了解了两种模式的实现方法,但是作为高质量程序员,我们肯定不能满足于只用代理单纯地打印语句,下面我们再来看看能怎么利用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的出现,让程序的运行不再循规蹈矩,也为我们的代码提供了无限的可能性。



相关文章
|
5月前
|
Java Linux 应用服务中间件
Linux启动tomcat报错:Neither the JAVA_HOME nor the JRE_HOME environment variable is defined
Linux启动tomcat报错:Neither the JAVA_HOME nor the JRE_HOME environment variable is defined
177 0
|
Java 编译器 Maven
javaagent实战
javaagent实战
|
监控 Java 应用服务中间件
APM - Hello Javaagent
APM - Hello Javaagent
114 0
|
消息中间件 JavaScript 小程序
聊聊大厂都在用的 JavaAgent 上
聊聊大厂都在用的 JavaAgent 上
|
Java 应用服务中间件 Linux
tomcat catalina.sh JAVA_OPTS参数说明与配置
tomcat catalina.sh JAVA_OPTS参数说明与配置
443 0
|
Java 应用服务中间件 API
JavaAgent-SandBox
1.前言 之前初步学习了javaAgent,并做了一份总结《JavaAgent学习笔记》。然后在看到《JVM-Sandbox 基于JVM的非侵入式运行期AOP解决方案》之后,接触到了集团的sandBox。并尝试使用这种有真正应用场景的运行时AOP框架。 2.SandBox简介 sandBox是集团开发的一种非侵入式的运行时AOP解决方案,它能动态地将你要实现的代码模块打包编织到目标代码中,实
7047 0
|
前端开发 Android开发