Java Agent

简单介绍

Java agent可以简单理解为:动态修改字节码的技术。它可以独立于正常程序,作为一个新应用运行。其应用非常广泛,如可以在运行状态下的Java程序中,往函数开头和函数结尾插入调试信息,达到监测、调试、甚至保护Java程序的功能。具体实现需要借助Java自带的instrument包。

Java Agent机制

在JDK1.5版本开始,Java增加了Instrumentation(Java Agent API)和JVMTI(JVM Tool Interface)功能,Java Agent可以拦截运行的应用程序并修改字节码。其本质实际是在classloader读取字节码后转化为class之前,对字节码进行修改的技术,其可以实现字节码插桩、动态跟踪分析等。

Java Agent运行模式

java Agent分为静态agent和动态agent两种

  • 静态agent需要在目标应用程序启动时,将agent路径添加进JVM启动参数 -javaagent,其原理是instrumentation预先设定了一个premain方法,会先于目标应用程序的main方法调用,premain调用时就可以使用instrument去transform class了。也就是说需要通过修改启动项的方式让agent生效的,如果我不想修改启动项,就需要动态agent

    • 启动Java程序的时候添加-javaagent(Instrumentation API实现方式)-agentpath/-agentlib(JVMTI的实现方式)参数
  • 动态agent采用attach方案,attach是sun公司专门提供为了向目标应用程序附着agent的API。同时,1.6后Instrumentation提供了agentmain方法,可以让Instrumentation在目标应用程序已经都被加载之后再生效。两者结合就完成了对目标应用程序的动态修改,实现“插桩”的效果。

静态agent中只能在启动前去指定需要加载的Agent文件,而动态agent可以在Java程序运行后根据进程ID进行动态注入Agent到JVM里面去。

Java Agent概念

Java Agent是一个Java里面命令的参数,该参数内容可以指定一个jar包,该jar包内容有一定的规范。

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

这里提到的premain方法就是我们在运行模式中提到的,该方法会在在运行main方法前被调用,也就是说在运行main方法前会去加载-javaagent指定的jar包里面的Premain-Class类中的premain方法。那么其实Java agent本质上就是一个Java的类,但是普通的Java类是以main方法作为程序入口点,而Java Agent则将premain(Agent模式)和agentmain(Attach模式)作为了Agent程序的入口。

命令参数

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

上面说到的 java.lang.instrument 提供允许 Java 编程语言代理监测运行在 JVM 上的程序的服务。监测的机制是对方法的字节码的修改,在启动 JVM 时,通过指示代理类 及其代理选项 启动一个代理程序。

该代理类必须实现公共的静态 premain方法,该方法原理上类似于 main 应用程序入口点,并且premain方法的前面也会有一定的要求,签名必须满足一下两种格式:

public static void premain(String agentArgs,Instrumentation inst)
public static void premain(String agentArgs)

JVM会去优先加载带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。这个逻辑在sun.instrument.InstrumentationImpl类中实现,可以来审计一下该代码

1666115580110

我们可以看到,首先其尝试带有Instrumentation参数的方式加载,如果这种方式加载失败,就会采取没有Instrumentation参数的方式进行加载。

例:

public static void premain(String agentArgs, Instrumentation inst);

参数简析

agentArgspremain执行时传入的参数,它是与main()参数是不同的, 且传参方式也是不同的, 后文会讲到传参方式。inst是JVM内置参数,可以获得执行时所有类的字节码信息以及类定义的转换等操作。

运行命令

premain运行时的基本格式:

java -javaagent:jar位置[=传入premain的参数]

例如:

java -javaagent:/xx/premain-1.0-SNAPSHOT.jar=agentArgs –cp MAIN.jar main args1 args2

参数详细说明

-javaagent:jarpath[=options]
    jarpath 是指向代理程序JAR文件的路径。options 是代理选项。此开关可以在同一命令行上多次使用,从而创建多个代理程序。多个代理程序可以使用同一jarpath。代理JAR文件必须符合 JAR 文件规范。下面的清单属性是针对代理 JAR 文件定义的:
    Premain-Class
    代理类。即包含premain方法的类。此属性是必需的,如果它不存在,JVM将中止。注:这是类名,而不是文件名或路径。
    Boot-Class-Path
    由引导类加载器搜索的路径列表。路径表示目录或库(在许多平台上通常作为jar或zip库被引用)。查找类的特定于平台的机制出现故障之后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层URI的路径组件的语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理JAR文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。此属性是可选的。
    Can-Redefine-Classes
    布尔值(true 或 false,与大小写无关)。能够重定义此代理所需的类。值如果不是 true,则被认为是 false。此属性是可选的,默认值为 false。
代理 JAR 文件附加到类路径之后。

在JDK里的rt.jar包中存在一个java.lang.instrument的包,这个包提供了Java运行时动态修改系统中的Class类型的功能,单最关键的还是javaagent,它可以在运行时重新接收外部请求,对class类型进行一个修改。

这里有两个重要的接口InstrumentationClassFileTransformer

Instrumentation接口

首先我们查看Instrumentation中包含哪些方法

1666148092548

1993669-20201204153542945-2074401989

public interface Instrumentation {
    
    //增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
    void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

    //在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
    void addTransformer(ClassFileTransformer transformer);

    //删除一个类转换器
    boolean removeTransformer(ClassFileTransformer transformer);

    boolean isRetransformClassesSupported();

    //在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    boolean isRedefineClassesSupported();
                      
    void redefineClasses(ClassDefinition... definitions)
        throws  ClassNotFoundException, UnmodifiableClassException;

    boolean isModifiableClass(Class<?> theClass);

    @SuppressWarnings("rawtypes")
    Class[] getAllLoadedClasses();

    @SuppressWarnings("rawtypes")
    Class[] getInitiatedClasses(ClassLoader loader);

    //获取一个对象的大小
    long getObjectSize(Object objectToSize);
    
    void appendToBootstrapClassLoaderSearch(JarFile jarfile);

    void appendToSystemClassLoaderSearch(JarFile jarfile);

    boolean isNativeMethodPrefixSupported();

    void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);
}

java.lang.instrument.Instrumentation的作用是用来监测运行在JVM中的Java API,利用该类可以实现如下功能

  • 动态添加或移除自定义的ClassFileTransformeraddTransformer/removeTransformer),JVM会在类加载时调用Agent中注册的ClassFileTransformer
  • 动态修改classpathappendToBootstrapClassLoaderSearchappendToSystemClassLoaderSearch),将Agent程序添加到BootstrapClassLoaderSystemClassLoaderSearch(对应的是ClassLoader类的getSystemClassLoader方法,默认是sun.misc.Launcher$AppClassLoader)中搜索;
  • 动态获取所有JVM已加载的类(getAllLoadedClasses);
  • 动态获取某个类加载器已实例化的所有类(getInitiatedClasses)。
  • 重定义某个已加载的类的字节码(redefineClasses)。
  • 动态设置JNI前缀(setNativeMethodPrefix),可以实现Hook native方法。
  • 重新加载某个已经被JVM加载过的类字节码retransformClasses)。

ClassFileTransformer接口

java.lang.instrument.ClassFileTransformer是一个转换类文件的代理接口,我们可以在获取到Instrumentation对象后通过addTransformer方法添加自定义类文件转换器。

示例中我们使用了addTransformer注册了一个我们自定义的TransformerJava Agent,当有新的类被JVM加载时JVM会自动回调用我们自定义的Transformer类的transform方法,传入该类的transform信息(类名、类加载器、类字节码等),我们可以根据传入的类信息决定是否需要修改类字节码,修改完字节码后我们将新的类字节码返回给JVMJVM会验证类和相应的修改是否合法,如果符合类加载要求JVM会加载我们修改后的类字节码。

1666172174344

ClassLoader loader                  定义要转换的类加载器;如果是引导加载器,则为null
String   className                   加载的类名,如:java/lang/Runtime
Class<?> classBeingRedefined         如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
ProtectionDomain protectionDomain   要定义或重定义的类的保护域
byte[]  classfileBuffer             类文件格式的输入字节缓冲区(不得修改)

重写transform方法注意事项:

  • ClassLoader如果是被Bootstrap ClassLoader(引导类加载器)所加载那么loader参数的值是空。
  • 修改类字节码时需要特别注意插入的代码在对应的ClassLoader中可以正确的获取到,否则会报ClassNotFoundException,比如修改java.io.FileInputStream(该类由Bootstrap ClassLoader加载)时插入了我们检测代码,那么我们将必须保证FileInputStream能够获取到我们的检测代码类。`
  • JVM类名的书写方式路径方式:java/lang/String而不是我们常用的类名方式:java.lang.String
  • 类字节必须符合JVM校验要求,如果无法验证类字节码会导致JVM崩溃或者VerifyError(类验证错误)
  • 如果修改的是retransform类(修改已被JVM加载的类),修改后的类字节码不得新增方法修改方法参数类成员变量
  • addTransformer时如果没有传入retransform参数(默认是false)就算MANIFEST.MF中配置了Can-Redefine-Classes: true而且手动调用了retransformClasses方法也一样无法retransform
  • 卸载transform时需要使用创建时的Instrumentation实例。

Java Agent 技术实现

了解过Java Agent概念性的内容后,我们现在尝试对Java Agent进行一个实现。

步骤如下

1定义一个 MANIFEST.MF 文件,必须包含 Premain-Class 选项,通常也会加入Can-Redefine-Classes 和 Can-Retransform-Classes 选项。

2.创建指定的Premain-Class类,并且里面包含premain 方法,方法逻辑由用户自己确定。

3.把premainMANIFEST.MF文件打包成一个jar包

4.使用 -javaagent: jar参数包路径启动要代理的方法。

完成以上步骤后,启动程序的时候会去执行premain 方法,当然这个肯定是优先于main方法执行的。但是不免会有一些系统类优先于javaagent进行执行。但是用户类这些肯定是会被javaagent给拦截下来的。这么这时候拦截下来后就可以进行一个重写类等操作,例如使用ASM、javassist,cglib等等来改写实现类。

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

JVM运行前运行

工程目录结构如下:

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

PreMainTraceAgent类代码

import java.lang.instrument.Instrumentation;

public class Agent {
    public static void premain(String agentArgs, Instrumentation inst){
        System.out.println("agentArgs"+agentArgs);
        inst.addTransformer(new DefineTransformer(),true);//调用addTransformer添加一个Transformer
    }
}

DefineTransformer类代码

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

public 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 new byte[0];
    }
}

这里我们重写了transform方法,在加载时候需要执行的内容在

MANIFSET.MF文件内容,注意需要在最后多一个空行

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.alexsel.test.PreMainTraceAgent

这个MANIFREST.MF文件如果你不去手动指定的话,直接打包,默认会在打包的文件中生成一个MANIFREST.MF文件

默认的文件中包含当前的一些版本信息,当前工程的启动类,它还有别的参数允许你做更多的事情,可以用上的有如下内容

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

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

Boot-Class-Path :设置引导类加载器搜索的路径列表。

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

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

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

该文件中主要定义了城市需运行相关的配置信息,程序运行前会先检测该文件中的配置信息。

一个java程序中-javaagent参数的个数是没有限制的,所以可以添加任意多个javaagent。所有的java agent会按照你定义的顺序执行,例如:

java -javaagent:agent1.jar -javaagent:agent2.jar -jar MyProgram.jar

程序执行的顺序将会是:

MyAgent1.premain -> MyAgent2.premain -> MyProgram.main

还有一种不用手写MANIFREST.MF文件的方式,使用maven插件,在pom.xml中添加如下内容

    <build>
        <plugins>
            <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.alexsel.test.PreMainTraceAgent</Premain-Class>
                            <Agent-Class>com.alexsel.test.PreMainTraceAgent</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>6</source>
                    <target>6</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

我使用这种方法的时候没有自动生成MANIFREST.MF文件,最终还是使用第一种方法自己创建了MANIFREST.MF文件

打包成JAR*

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

首先我们在Project Structure->Artifats中添加jar文件配置

1666182457265

1666182386001

接着进入Build->build Artifacts点击Build生成

1666182553875

接着我们新建一个项目并编写main函数

package com.alexsel.test;

import java.io.IOException;

public class testMain {
    public static void main(String[] args) throws IOException {
        System.out.println("main");
    }
}

接着配置启动信息

1666182679775

我们的jar文件需要相应的放在out文件下,然后运行程序可以看到我们编写的agent方法在main函数前

1666182323984

前面说过transform方法,也就是在加载的时候需要执行其他的操作都会在该方法中进行实现。这是因为ClassFileTransformer中会去拦截系统类和自己实现的类对象,如果需要对某个类进行改写,就可以在拦截的时候抓住这个类使用字节码编译工具去实现。

JVM运行后运行

之前我们介绍的是在main方法运行前执行Instrument,在JDK1.6之后新增的agentmain方法可以实现在main方法执行之后进行插入执行。

该方法和premain方法类似,需要定义一个agentmain方法的类。

public static void agentmain (String agentArgs, Instrumentation inst)

public static void agentmain (String agentArgs)

这个也是和前面的一样,有Instrumentation类型参数的运行优先级也是会比没有该参数的高。

在Java JDK6以后实现启动后加载Instrument的是Attach api。存在于com.sun.tools.attach里面有两个重要的类。

来查看一下该包中的内容,这里有两个比较重要的类,分别是VirtualMachineVirtualMachineDescriptor

1666277075161

VirtualMachine

VirtualMachine类: 代表一个 Java 虚拟机,也就是程序需要监控的目标虚拟机,提供了获取系统信息(比如内存dump、线程dump,类信息统计)、加载代理程序、Attach 和 Detach 等方法

Attach 机制对外提供了一种进程间的通信能力,能让一个进程传递命令给 JVM;其次,Attach 机制内置一些重要功能,可供外部进程调用。

loadAgent:向jvm注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以 在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理。

Detach:从 JVM 上面解除一个代理(agent)

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

获取JAVA进程ID

代码获取

import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

import java.util.List;

public class test {
    public static void main(String[] args) {
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        for (VirtualMachineDescriptor virtualMachineDescriptor : list) {
            System.out.println(virtualMachineDescriptor+"\n"+virtualMachineDescriptor.id());
        }
    }
}

1666304908779

命令行获取

jps
jps -l

1666307467748

测试代码

首先我们需要启动一个tomcat作为目标程序A,因为我们不可能修改已经启动目标程序A的代码,所以我们需要在启动一个程序B,通过attach机制,将JVM B连接到JVM A,然后让JVM A执行JVM B发送的指令。目标程序A就是简单的tomcat程序,这里就不过多的展示了。

编写agent类

这里的代理类使用的是agentmain方法,由于这次我们是在主程序运行之后执行我们的代码,所以我们可以获取主程序中运行时的信息,这里我们就讲主程序中加载的类名进行输出。

package com.alexsel.test;

import java.lang.instrument.Instrumentation;

public class Agent {
    public static void agentmain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("loadagent after main run.args="+agentArgs);
        Class<?>[] classes = instrumentation.getAllLoadedClasses();
        for (Class<?> cls : classes){
            System.out.println(cls.getName());
        }
        System.out.println("agent run completely");
    }
}

编写MANIFEST.MF文件

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Agent-Class: com.alexsel.test.Agent

将程序打包之后放到指定位置,然后我们编写B程序

import sun.tools.attach.WindowsVirtualMachine;

public class testMain {
    public static void main(String[] args) throws Exception {
        WindowsVirtualMachine wvm = (WindowsVirtualMachine) WindowsVirtualMachine.attach("26224");//目标程序A的ID
        wvm.loadAgent("D:/java_project/testAgent.jar");//打包文件的路径
    }
}

B程序运行结果

1666307404866

目标程序A中打印了加载类名的信息

1666306958230

完结

参考文章

https://www.cnblogs.com/nice0e3/p/13544143.html

https://pureqh.top/?p=6723

https://www.cnblogs.com/rickiyang/p/11368932.html

https://tttang.com/archive/1769/

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

最后修改:2022 年 11 月 11 日
如果觉得我的文章对你有用,请随意赞赏