字节码插桩

一、前言

  JVM不能直接执行.java 代码,也不能直接执行.class文件,它只能执行.class 文件中存储的指令码。这就是为什么class需要通过classLoader装载以后才能运行。基于此机制思考是否可以在ClassLoader加载目标类进JVM方法区之前拦截并修改目标class中的内容(jvm 指令码)进而让程序中包含我们的埋点逻辑呢?答案是肯定的,但需要用到两个技术 javaagent(探针)javassist(字节码文件动态编辑) 。前者用于在 ClassLoad 加载目标 class 之前拦截进行拦截,后者用于操作修改class文件。

二、技术应用场景

bTrace:BTrace快速入门

Arthas:官网

安全检查方面

性能监测方面

JVM层面的AOP

....等等

三、javaagent介绍

  javaagent 是java1.5之后引入的特性,其主要作用是在class 被加载之前对其拦截,以便于对目标 class 插入埋点逻辑

1、JVM启动前静态Instrument

javaagent 是什么

  Javaagent 是java命令的一个参数。参数 javaagent 可以用于指定一个 jar 包,并且对该 jar 包有2个要求:

  1. 这个 jar 包的 MANIFEST.MF 文件必须指定 Premain-Class 项。
  2. Premain-Class 指定的那个类必须实现 premain() 方法。

  premain 方法从字面上理解就是运行在 main 函数之前的方法。当Java 虚拟机启动时,在执行 main 函数之前,JVM 会先运行-javaagent所指定 jar 包内 Premain-Class 这个类的 premain 方法 。

  在命令行输入 java可以看到相应的参数,其中有 和 java agent相关的:

-agentlib:<libname>[=<选项>] 加载本机代理库 <libname>, 例如 -agentlib:hprof
	另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
	按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
	加载 Java 编程语言代理, 请参阅 java.lang.instrument

在上面-javaagent参数中提到了参阅java.lang.instrument,在这个包下面有两个重要的类:

  • java.lang.instrument.ClassFileTransformer
  • java.lang.instrument.Instrumentation

  这个包同时也提供了一些工具帮助开发人员在 Java 程序运行时,能够动态的去修改系统中的 Class 类型。其中,使用该软件包的一个关键组件就是 Javaagent。从名字上看似乎是个 Java 代理之类的,而实际上它的功能更像是一个Class 类型的转换器,对Class类型进行修改。

  从本质上讲Java Agent 是一个遵循一组严格约定的常规 Java 类。 上面说到 javaagent命令要求指定的类中必须要有premain()方法,并且对premain方法的签名也有要求,签名必须满足以下两种格式:

public static void premain(String agentArgs, Instrumentation inst)  // 优先级更高
public static void premain(String agentArgs)  // 优先级一般

  JVM 会优先加载 带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。这个逻辑在sun.instrument.InstrumentationImpl 类中:

    private void loadClassAndStartAgent(String classname, String methodname, String optionsString) throws Throwable {
        ClassLoader mainAppLoader = ClassLoader.getSystemClassLoader();
        Class<?> javaAgentClass = mainAppLoader.loadClass(classname);
        Method m = null;
        NoSuchMethodException firstExc = null;
        boolean twoArgAgent = false;
        try {
            // 通过反射去拿premain签名方法
            m = javaAgentClass.getDeclaredMethod(methodname,new Class<?>[]{String.class,
                            java.lang.instrument.Instrumentation.class});
            twoArgAgent = true;
        } catch (NoSuchMethodException x) {firstExc = x;}

        if (m == null) {
            try {
            // 通过反射去拿premain签名方法
                m = javaAgentClass.getDeclaredMethod(methodname,new Class<?>[]{String.class});
            } catch (NoSuchMethodException x) {}
        }
        
        if (m == null) {
            try {
            // 通过反射去拿premain签名方法
                m = javaAgentClass.getMethod(methodname,new Class<?>[]{String.class,
                                java.lang.instrument.Instrumentation.class});
                twoArgAgent = true;
            } catch (NoSuchMethodException x) {}
        }

        if (m == null) {
            try {
            // 通过反射去拿premain签名方法
                m = javaAgentClass.getMethod(methodname,new Class<?>[]{String.class});
            } catch (NoSuchMethodException x) {throw firstExc;}
        }
    }

javaagent 怎么用

  • 步骤:
  1. 定义 MANIFEST.MF 文件,必须包含 Premain-Class (入口类)选项,通常也会加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项。
  2. 创建一个Premain-Class 指定的类,类中包含 premain 方法,方法逻辑由用户自己确定。
  3. 将 premain 的类和 MANIFEST.MF 文件打成 jar 包。
  4. 使用参数 -javaagent: jar包路径 启动要代理的方法。
  • 说明:

  在执行以上步骤后,JVM 会先执行 premain 方法,大部分类加载都会通过该方法,注意是大部分。遗漏的主要是系统类,因为很多系统类先于 agent 执行,而用户类的加载肯定是会被拦截的。也就是说,这个方法是在 main 方法启动前拦截大部分类的加载活动,既然可以拦截类的加载,那么就可以去做重写类这样的操作,结合第三方的字节码编译工具,比如ASM,javassist,cglib等等来改写实现类。

  • 实现:

  实现一个 javaagent 需要搭建两个工程,一个工程是用来承载 javaagent 类并将其单独打成jar包;一个工程是 javaagent 需要去代理的类。即javaagent会在这个工程中的main方法启动之前去做一些事情。

(1)javaagent工程

工程目录结构:

-java-agent
----src
--------main
--------|------java
--------|----------com.test
--------|------------PreMainTraceAgent
--------|resources
-----------META-INF
--------------MANIFEST.MF

【PreMainTraceAgent.java】

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class PreMainTraceAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("agentArgs : " + agentArgs);
        // 添加拦截器
        inst.addTransformer(new DefineTransformer(), true);
    }

    static class DefineTransformer implements ClassFileTransformer {
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
            // 会对所有类进行拦截
            System.out.println("premain load Class:" + className);
            return classfileBuffer;
        }
    }
}

【MANIFREST.MF】

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: PreMainTraceAgent
																		# 注意这一行是必须有的空行

  MANIFREST.MF文件一般如果不去手动指定的话,直接打包springBoot类型的工程项目默认会在打包的文件中生成一个MANIFREST.MF文件内容如下:

Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Implementation-Title: 01.spring-boot-test
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: com.ailun.springboottest.Application			# 启动类
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8											# jdk版本
Spring-Boot-Version: 2.3.0.RELEASE							# 框架版本
Created-By: Maven Jar Plugin 3.2.0
Implementation-Vendor: Pivotal Software, Inc.
Main-Class: org.springframework.boot.loader.JarLauncher

MANIFREST.MF还有别的参数,例如有:

Premain-Class :包含 premain 方法的类(类的全路径名)

Agent-Class :包含 agentmain 方法的类(类的全路径名)

Boot-Class-Path :设置引导类加载器搜索类文件的路径列表。在特定平台查找类文件机制失败以后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)

Can-Redefine-Classes :true表示能重定义此代理所需的类,默认值为 false(可选)

Can-Retransform-Classes :true 表示能重转换此代理所需的类,默认值为 false (可选)

Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

  即MANIFREST.MF文件中主要定义了程序运行相关的配置信息,程序运行前会先检测该文件中的配置项。

  java程序中能够指定的-javaagent参数个数没有限制,可以添加任意多个。所有的 javaagent 会按指定的顺序执行,例如:

java -javaagent:agent1.jar -javaagent:agent2.jar -jar MyProgram.jar
程序执行的顺序将会是:MyAgent1.premain -> MyAgent2.premain -> MyProgram.main

  说回上面的 javaagent工程,接下来将该工程打成jar包,需要注意在打包的时候发现打完包之后的 MANIFREST.MF文件被默认配置替换掉了,手动将上面MANIFREST.MF文件替换到jar包中MANIFREST.MF文件。

使用maven插件在打包时自动去维护MANIFREST.MF文件

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.1.0</version>
    <configuration>
        <archive>
            <!--自动添加META-INF/MANIFEST.MF -->
            <manifest>
                <addClasspath>true</addClasspath>
            </manifest>
            <manifestEntries>
                <Premain-Class>com.test.PreMainTraceAgent</Premain-Class>
                <Agent-Class>com.test.PreMainTraceAgent</Agent-Class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

至此javaagent工程所有的工作全部做完了

(2) 实际运行的java工程

【测试类】

public class TestMain {

    public static void main(String[] args) {
        System.out.println("main start");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main end");
    }
}

使用方式一:

image-20221222175724977

使用方式二:

java -javaagent:agentJar包绝对路径 -jar springboot-test.jar

运行:

D:\soft\jdk1.8\bin\java.exe -javaagent:agentJar包绝对路径 "-javaagent:D:\soft\IntelliJ IDEA 2019.1.1\lib\idea_rt.jar=54274:D:\soft\IntelliJ IDEA 2019.1.1\bin" -Dfile.encoding=UTF-8 -classpath D:\soft\jdk1.8\jre\lib\charsets.jar;D:\soft\jdk1.8\jre\lib\deploy.jar;....大量省略....;E:\.m2\repository\org\springframework\spring-context\5.1.3.RELEASE\spring-context-5.1.3.RELEASE.jar com.springboot.example.demo.service.TestMain

agentArgs : null
premain load Class     :java/util/concurrent/ConcurrentHashMap$ForwardingNode
premain load Class     :sun/nio/cs/ThreadLocalCoders
premain load Class     :sun/nio/cs/ThreadLocalCoders$1
premain load Class     :sun/nio/cs/ThreadLocalCoders$Cache
premain load Class     :sun/nio/cs/ThreadLocalCoders$2
premain load Class     :java/util/jar/Attributes
premain load Class     :java/util/jar/Manifest$FastInputStream
...
...
...
premain load Class     :java/lang/Class$MethodArray
premain load Class     :java/lang/Void
main start
premain load Class     :sun/misc/VMSupport
premain load Class     :java/util/Hashtable$KeySet
premain load Class     :sun/nio/cs/ISO_8859_1$Encoder
premain load Class     :sun/nio/cs/Surrogate$Parser
premain load Class     :sun/nio/cs/Surrogate
...
...
...
premain load Class     :sun/util/locale/provider/LocaleResources$ResourceReference
main end
premain load Class     :java/lang/Shutdown
premain load Class     :java/lang/Shutdown$Lock

Process finished with exit code 0

上面输出结果能够发现:

  1. 执行main方法之前会加载所有的类,包括系统类和自定义类;
  2. 在ClassFileTransformer类中的transform方法会去拦截系统类和自己实现的类对象;
  3. 如果你想对某些被拦截到的类对象进行修改,那么在拦截的时候抓住该类使用字节码编辑类库javassist即可实现

【举例:使用javassist动态编辑某个类】

package com.test;
import javassist.*;
import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

public class MyClassTransformer implements ClassFileTransformer {
    
    @Override
    public byte[] transform(final ClassLoader loader, final String className, final Class<?> classBeingRedefined,final ProtectionDomain protectionDomain, final byte[] classfileBuffer) {
        // 操作Date类
        if ("java/util/Date".equals(className)) {
            try {
                // 从ClassPool获得CtClass对象
                final ClassPool classPool = ClassPool.getDefault();
                final CtClass clazz = classPool.get("java.util.Date");
                CtMethod convertToAbbr = clazz.getDeclaredMethod("convertToAbbr");
                // 这里对 Date.convertToAbbr() 改写,在 return之前增加打印操作
                String methodBody = "{sb.append(Character.toUpperCase(name.charAt(0)));" +
                        "sb.append(name.charAt(1)).append(name.charAt(2));" +
                        "System.out.println(\"sb.toString()\");" +
                        "return sb;}";
                convertToAbbr.setBody(methodBody);
                // 返回字节码
                byte[] byteCode = clazz.toBytecode();
                // detach的意思是将内存中曾经被javassist加载过的Date对象移除,如果下次有需要在内存中找不到会重新走javassist加载
                clazz.detach();
                return byteCode;
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
        // 返回null则字节码不会被修改
        return null;
    }
}

2、JVM启动后动态Instrument

  上面介绍的Instrumentation是在 JDK 1.5中开始提供的,开发者只能在main加载之前添加手脚,在 Java SE 6 的 Instrumentation 当中提供了一个新的代理操作方法:agentmain,可以在 main 函数开始运行之后再运行。

premain函数一样, 开发者可以编写一个含有agentmain函数的 Java 类:

// 采用attach机制,被代理的目标程序JVM有可能很早之前已经启动,当然其所有类已经被加载完成,这个时候需要借助Instrumentation#retransformClasses(Class<?>... classes)让对应的类可以重新转换,从而激活重新转换的类执行ClassFileTransformer列表中的回调
public static void agentmain (String agentArgs, Instrumentation inst)  // 优先级更高
public static void agentmain (String agentArgs)		// 优先级一般

  与上面类似,也必须在 MANIFEST.MF 文件里面设置“Agent-Class”来指定包含 agentmain 函数的类。

  实现启动后加载的新实现是Attach api。Attach API 很简单,只有 2 个主要的类,都在 com.sun.tools.attach 包里面:

  1. com.sun.tools.attach.VirtualMachine 字面意义表示一个JVM也就是目标java程序虚拟机,提供了获取目标程序的系统信息(比如获取内存dump、线程dump,类信息统计(比如已加载的类以及实例个数等),loadAgent,Attach 和 Detach (是Attach 动作的相反行为,从 JVM 上面解除一个代理)等方法,可以实现的功能可以说非常之强大。这个类允许通过给attach方法传入一个目标程序的JVM的PID(进程id),远程连接到JVM 上 。代理类注入操作只是它众多功能中的一个,通过loadAgent方法向 JVM 注册一个代理程序 agent ,在该 agent 的代理程序中会得到一个 Instrumentation 实例,该实例可以 在 class 加载前改变 class 的字节码,也可以在 class 加载后使其重新被加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理。
  2. com.sun.tools.attach.VirtualMachineDescriptor 则是一个描述 JVM 的容器类,配合 VirtualMachine 类完成各种功能。

attach实现动态注入的原理

  通过VirtualMachine类的attach(pid)方法,便可以 attach 到一个已经在运行的java进程,之后便可以通过loadAgent(agentJarPath)将 agent 的 jar 包注入到这个正在运行的 java 进程,然后对应的进程会调用 agentmain 方法。

img

  既然是两个进程之间通信就必须先建立连接,VirtualMachine.attach动作类似TCP创建连接的三次握手,目的就是搭建attach通信的连接。而后面执行的操作,例如vm.loadAgent,其实就是向这个socket写入数据流,接收方target JVM会对收到的数据做不同处理。

测试使用 agentmain

  工程结构和上面premain的测试一样,编写AgentMainTest,然后使用maven插件打包生成MANIFEST.MF

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class AgentMainTest {

    public static void agentmain(String agentArgs, Instrumentation instrumentation) {
        instrumentation.addTransformer(new DefineTransformer(), true);
    }

    static class DefineTransformer implements ClassFileTransformer {

        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
            System.out.println("agentmain load Class:" + className);
            return classfileBuffer;
        }
    }
}

【pom.xml】

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <version>3.1.0</version>
  <configuration>
    <archive>
      <!--自动添加META-INF/MANIFEST.MF -->
      <manifest>
        <addClasspath>true</addClasspath>
      </manifest>
      <manifestEntries>
        <Agent-Class>com.test.AgentMainTest</Agent-Class>
        <Can-Redefine-Classes>true</Can-Redefine-Classes>
        <Can-Retransform-Classes>true</Can-Retransform-Classes>
      </manifestEntries>
    </archive>
  </configuration>
</plugin>

  将 agent 打包之后,就是编写测试main方法。上画的图中的步骤是:从一个 attach JVM 去探测目标 JVM,如果目标 JVM 存在则向它发送 agent.jar 。简单测试就找到当前JVM并加载agent.jar。

import com.sun.tools.attach.*;
import java.io.IOException;
import java.util.List;

public class TestAgentMain {

    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        System.out.println("running JVM start ");
        // 1.获取当前系统中所有 运行中的 虚拟机
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        
        for (VirtualMachineDescriptor jvmInfo : list) {
            // 如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid
            // 然后加载 agent.jar 发送给该虚拟机
            System.out.println(jvmInfo.displayName());
            if (jvmInfo.displayName().endsWith("com.test.TestAgentMain")) {
                VirtualMachine targetJvm = VirtualMachine.attach(jvmInfo.id());
                // 目标 JVM 加载 agent
                targetJvm.loadAgent("D:\\test\\java-agent.jar");
                targetJvm.detach();
            }
        }
    }
}

  VirtualMachine.list()方法会找到当前系统中所有正在运行的JVM进程,可以打印VirtualMachineDescriptor.displayName()。因为main函数执行起来的时候进程名为当前类名,所以通过这种方式可以去找到当前的进程id。

  注意:在mac上安装的jdk是能直接找到 VirtualMachine 类,windows中安装的jdk可能会找不到,如果遇到这种情况,可以手动将jdk安装目录下的lib目录中的tools.jar添加进当前工程的Libraries中。

运行TestAgentMain#main()输出结果为:

// TODO

  实际上是启动一个 socket 进程去传输 agent.jar,先打印了“running JVM start”表名main方法是先启动了,然后才进入代理类的transform方法。

3、instrument原理

  instrument的底层实现依赖于JVMTI(JVM Tool Interface),它是JVM暴露出来的一些供用户扩展的接口集合,JVMTI 是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。JVMTIAgent是一个利用JVMTI暴露出来的接口提供了代理启动时加载(agent on load)、代理通过attach形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。而instrument agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是专门为java语言编写的插桩服务提供支持的代理

3.1、启动时加载instrument agent过程

  1. 创建并初始化 JPLISAgent;
  2. 监听 JVMInit 初始化事件,在 JVM 初始化完成之后做下面的事情:
    1. 创建 InstrumentationImpl 对象 ;
    2. 监听 ClassFileLoadHook 事件 ;
    3. 调用 InstrumentationImpl 的loadClassAndCallPremain方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的 Premain-Class 类的 premain 方法 ;
  3. 解析 javaagent 中 MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容。

3.2、运行时加载instrument agent过程

通过 JVM 的 attach 机制来请求目标 JVM 加载对应的 agent ,过程大致如下:

  1. 创建并初始化 JPLISAgent;
  2. 解析 javaagent 里 MANIFEST.MF 里的参数;
  3. 创建 InstrumentationImpl 对象;
  4. 监听 ClassFileLoadHook 事件;
  5. 调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里MANIFEST.MF 里指定的Agent-Class类的agentmain方法。

3.3、Instrumentation的局限性

  大多数情况使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,但是有以下的局限性:

  1. premain 和 agentmain 两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类
  2. 类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:
    1. 新类和老类的父类必须相同;
    2. 新类和老类实现的接口数也要相同,并且是相同的接口;
    3. 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;
    4. 新类和老类新增或删除的方法签名必须是private static final修饰的;
    5. 可以修改方法体。

  除了上面的方式,如果想要重新定义一个类,可以考虑基于类加载器隔离的方式:创建一个新的自定义类加载器去通过新的字节码去定义一个全新的类,不过也存在只能通过反射调用该全新类的局限性。

四、其它

思考:字节码插桩与AOP/动态代理之间有什么相同以及有什么不同

扩展:

  ASM 是一个通用的 Java 字节码操作和分析框架。 它可以用于修改现有类或直接以二进制形式动态生成类。 ASM 提供了一些常见的字节码转换和分析算法,可以从中构建自定义复杂转换和代码分析工具。 ASM 提供与其他 Java 字节码框架类似的功能,但专注于性能。 因为它的设计和实现尽可能小而且快,所以它非常适合在动态系统中使用(但当然也可以以静态方式使用,例如在编译器中)。

原文地址:https://www.cnblogs.com/rickiyang/p/11368932.html

参考:https://www.cnblogs.com/aspirant/p/8796974.html

posted @ 2022-03-20 10:50  黄河大道东  阅读(112)  评论(0编辑  收藏  举报