Java Agent字节码插桩术

关于JVMTI(jvm tool interface)

 JVMTI是⽤来开发和监控JVM所使⽤的程序接⼝,可以探查JVM内部状态,并控制JVM应⽤程序的执⾏,JVMTI的客户端,或称为代理(agent),提供了很多函数,可以监听感兴趣的事件,以便来查询或控制应⽤程序,进而实现控制JVM应用程序目标。但是需要注意的是,并⾮所有的JVM实现都⽀持JVMTI。

Idea的Debug功能就是通过JDWP协议定制化JVMTI的功能实现与JVM的双向通信连接,在获取断点位置的代码信息同时向IDEA客户端进行推送信息,实现监听功能!

如果要自己实现定制化的JVMTI功能的话,需要编写dll文件 更多资料参考 

javaagent是java1.5之后引⼊的特性,基于JVMTI实现,⽀持JVMTI部分功能。主要应⽤场景是对类加载进⾏拦截修改和对已加载的类进⾏重定义。此外还⽀持获取已加载的类,以及实例内存占⽤计算等。

javaagent的启动方式

javaagent的表现形式是监听一个JAR包,用于监听目标应用,它有两种启动方式:

  1. 加载时启动:agent的功能随着⽬标应⽤⼀起启动,通过设置⽬标应⽤的jvm参数:"-javaagent:",即可加载javaagent监听目标jar包。
    • 这种方式是在类加载之前进行拦截的,在符合JVM的规范下可以任意类的结构。比如修改类的名称,加入方法等等
  2. 运行时附着:借助jvmtools⼯具包将javaagent包注⼊到⽬标应⽤中,实现运行过程的监听。
    • 类加载之后进行拦截,可以对方法内部增加逻辑,不能增加方法修改类的结构。

代码演示

加载时启动(premain)

与运行时JAR包的启动区别

  运⾏时JAR包 javaagentjar包
启动类 Main-class Premain-class
启动⽅法 main premain
启动⽅式 java-jarxxx.jar jvm参数设置:-javaagent:xxx.jar

 

编写启动类(Premain)

public class MyAgent {
    /**
     *
     * @param arg 启动参数
     * @param instrumentation 类的加载和定义都是通过它来实现的
     */
    // 加载时启动
    public static void premain(String arg, Instrumentation instrumentation) {
        System.out.println("hello premain");
    }

}

maven插件设置然后进行编译打包

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.2</version>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Project-name>${project.name}</Project-name>
                            <Project-version>${project.version}</Project-version>
                            <!--设置启动类的位置-->
                            <Premain-Class>javaagent.MyAgent</Premain-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                    <skip>true</skip>
                </configuration>
            </plugin>

目标类

public class MyApp {
    public static void main(String[] args) throws IOException {
        System.out.println("访问主程序");
    }
}

将打包好的javaagent的jar包位置作为参数设置到JVM参数中

可以看到在类加载之前就已经被拦截了

 

附着启动(agentmain)

如果想要在应⽤运⾏之后去监听它,⽽⼜不去重启它,就可以采⽤另⼀种⽅式附着启动。其相关属性通过以表来⽐对:

  运⾏时JAR包 javaagentjar包
启动类 Main-class Agent-class
启动⽅法 main agentmain
启动⽅式 java-jarxxx.jar tools⼯具附着

编写启动类

public class MyAgent {
   /**
     *
     * @param arg 启动参数
     * @param instrumentation 类的加载和定义都是通过它来实现的
     */
    // 运行时启动
    public static void agentmain(String arg, Instrumentation instrumentation) {
        System.out.println("hello agentmain");
    }
}

maven插件设置然后进行编译打包

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.2</version>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Project-name>${project.name}</Project-name>
                            <Project-version>${project.version}</Project-version>
                            <!--设置启动类的位置-->
                            <Agent-Class>javaagent.MyAgent</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                    <skip>true</skip>
                </configuration>
            </plugin>

目标类

public class MyApp {
    public static void main(String[] args) throws IOException {
        System.out.println("访问主程序");
        // 设置运行时
        System.in.read();
    }
}

前置设置和加载时启动没有什么区别,运行时附着的启动方式是必须通过jvm/lib/tools.jar中的API注⼊⾄⽬标应⽤:

导入依赖

<dependency>
  <groupId>com.sun</groupId>
  <artifactId>tools</artifactId>
  <version>1.8.0</version>
  <scope>system</scope>
  <systemPath>${JAVA_HOME}/lib/tools.jar</systemPath>
</dependency>
public class AttachStart {
    public static void main(String[] args) throws Exception {
        // 获取jvm进程列表 借用tool工具实现进程交互
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        for (int i = 0; i < list.size(); i++) {
            System.out.println(String.format("[%s] %s", i, list.get(i).displayName()));
        }
        System.out.println("输入数字指定要attach的进程");

        // 选择jvm进程
        BufferedReader read = new BufferedReader(new InputStreamReader(System.in));
        String line = read.readLine();
        int i = Integer.parseInt(line);
        // 附着agent
        VirtualMachine virtualMachine = VirtualMachine.attach(list.get(i));
        // 将对应的agent功能附着到指定进程进去实现运行时启动
        virtualMachine.loadAgent("E:/javaCode/javaagent/target/javaagent-1.0-SNAPSHOT.jar", "111");
        virtualMachine.detach();
        System.out.println("加载成功");
    }
}

选择要附着的jvm进程,需要注意的是一旦附着成功,agent的功能实现是在对方的进程上实现的。

结果很明显,agent的功能是在目标类之后才显现出来

 

核心功能

addTransformer(变压器): 添加类加载拦截器,可重定义加载的类,类如果在这之前加载会失效。

retransformClasses(类重新变更):将类进行二次加载,重新被变压器所拦截,注意加载的类是有限制的,仅可对运⾏指令码进⾏修改:不可修改类结构如继承、接⼝、类符、变更属性、变更⽅法等。可以新增privatestatic/final的方法;必须maven插件添加Can-Retransform-Classes=true该⽅法执⾏才有效,且addTransformer⽅法的canRetransform参数也为true。

redefineClasses(重新定义类):在经过变压器之前进行重定义,逻辑上跟类重新变更没太大的区别

getAllLoadedClasses(获取所有类)

getInitiatedClasses(获取已实例化的类)

getObjectSize(计算对象大小)

示例

public class MyApp {
    public static void main(String[] args) throws IOException {
        System.out.println("访问主程序");
        new HelloWorld().hello();
    }
}
public class HelloWorld {
    public void hello() {
        System.out.println("hello!!");
    }
}
 // 加载时启动
    public static void premain(String arg, Instrumentation instrumentation) throws UnmodifiableClassException, ClassNotFoundException {
        System.out.println("hello premain");
        // 如果类在变压器之前加载那么就不能修改类(运行时加载不能用)
        HelloWorld helloWorld = new HelloWorld();
        instrumentation.addTransformer(new ClassFileTransformer() {
            /**
             * @param loader
             * @param className           动态加载的类名
             * @param classBeingRedefined 这个类重新加载之前的类
             * @param protectionDomain    类的基本信息
             * @param classfileBuffer     这个类的字节码,如果返回null就按照原有的进行加载覆盖
             * @return 返回指令码
             * @throws IllegalClassFormatException
             */
            @Override
            public byte[] transform(ClassLoader loader, String className,
                                    Class<?> classBeingRedefined,
                                    ProtectionDomain protectionDomain,
                                    byte[] classfileBuffer) throws IllegalClassFormatException {
                if (!"javaagent/HelloWorld".equals(className)) {
                    return null;
                }
                // 配合javassist修改指令码
                ClassPool pool = new ClassPool();
                pool.appendSystemPath();
                try {
                    CtClass ctClass = pool.get("javaagent.HelloWorld");
                    CtMethod method = ctClass.getDeclaredMethod("hello");
                    method.insertBefore("System.out.println(\"插入前置逻辑\");");
                    return ctClass.toBytecode();
                } catch (NotFoundException | CannotCompileException | IOException e) {
                    e.printStackTrace();
                }
                return null;
            }
        },true);
        // 重新触发指定类的加载但是对字节码的修改有一定的限制,只是重走变压器的逻辑
        instrumentation.retransformClasses(HelloWorld.class);
        // 这里的访问的方法内部逻辑已经被修改了
        // 原理有点像是spring热部署:(单例)类只加载一次它所指向的方法地址不变,但是方法自身指令码发生了改变(钥匙还是原来的钥匙但是房间里的东西可能就不一样了!)!
        helloWorld.hello();
    }

结果

 

posted @ 2022-02-24 19:06  猫长寿  阅读(834)  评论(0)    收藏  举报