字节码插桩之Java Agent

Posted 风在哪

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节码插桩之Java Agent相关的知识,希望对你有一定的参考价值。

字节码插桩之Java Agent

本篇文章将详细讲解有关Java Agent的知识,揭开它神秘的面纱,帮助开发人员了解它的黑魔法,帮助我们完成更多业务需求

What is Java Agent

Java Agent又称为Java探针,它提供了向现有已编译的Java类添加字节码的功能,相当于字节码插桩的入口;通过使用Java Instrumentation API,它能够侵入运行在JVM上的应用程序,进而修改应用程序中各种类的字节码。

在Java程序运行环境中,instrumentation是一种用于更改现有应用程序并向其添加代码的技术,可以在编译期间或者运行时执行此操作。它的优点就是,我们无需编辑源代码文件就能修改代码、更改它的行为;这非常有效,但也非常危险。

我们可以用它来实现很多功能,例如AOP

Java Agent是Java Instrumentation API的一部分,Java Instrumentation API提供了一种可以动态或者静态地修改字节码的机制,这意味着我们可以在不修改源代码的情况下向Java类中添加代码,这对Java应用程序有着很重要的影响

简单来说,Java Agent是一种特殊的jar文件,但是它包含遵循特殊约定的Java类,并在jar文件中的MANIFEST.MF(该文件通常存放在src/main/resources/META-INF文件夹下)文件中指定遵循特殊约定的Java类;在该特殊的类中有如下两类方法:

  • premain:在JVM启动时加载的Java Agent,例如使用java -jar命令启动jar文件时,添加-javaagent命令添加Java Agent;通俗来说就是在JVM初始化之后&main方法之前运行该agent

    其方法签名如下:

    public static void premain(String agentArgs, Instrumentation inst) 
    

    如果不包含上述方法,可选方法如下:

    public static void premain(String agentArgs) 
    
  • agentmain:使用Java Attach API将Java Agent动态加载到JVM中,在JVM初始化之后运行,可以在我们的main方法中通过Attach API调用该agent

    其方法签名如下:

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

应用程序是如何识别Java Agent的呢,答案就是MANIFEST.MF文件,该文件作为JAR文件的一部分包含jar文件的元数据信息,其中关于Java Agent的属性如下:

  • Premain-Class:当JVM启动时,该属性指定代理类,该类为包含premain方法的类,其值为全限定类名;如果在JVM启动时指定Java Agent则必须定义该属性
  • Agent-Class:如果实现支持在JVM启动后某个时间启动agent的机制,则此属性指定代理类;该类为包含agentmain方法的类,其值为全限定类名
  • Can-Readefine-Classes:定义Java Agent是否能够重定义Java类,其值为true or false,默认false
  • Can-Retransform-Classes:定义Java Agent能否重转换Java类,其值为true or false,默认false
  • Can-Set-Native-Method-Prefix:定义Java Agent能否设置所需的本机方法前缀,默认false
  • Boot-Class-Path:设置启动类加载器搜索的路径列表;查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径;按列出的顺序搜索路径,列表中的路径由一个或多个空格分开;路径使用分层 URI 的路径组件语法;如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径;相对路径根据代理 JAR 文件的绝对路径解析,忽略格式不正确的路径和不存在的路径,如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径

在使用maven构建项目的过程中,我们可以通过pom.xml中的build属性设置maven-jar-plugin插件来自动生成MANIFEST.MF文件,示例如下:

	<build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.2.0</version>
                <configuration>
                    <archive>
                        <!--true代表自动添加MANIFEST.MF文件-->
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <!--设置MANIFEST.MF文件的相关属性,类文件使用全限定类名-->
                        <manifestEntries>
                            <Premain-Class>cn.wygandwdn.agent.TestAgent</Premain-Class>
                            <Agent-Class>cn.wygandwdn.agent.TestAgent</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

Java Instrumentation API解析

官方文档:

java.instrument (Java SE 9 & JDK 9 )

java.lang.instrument (Java Platform SE 8 )

Java Instrumentation API主要提供了两个接口:Instrumentation、ClassFileTransformer:

  • Instrumentation:该类提供了向Java中插入代码的服务
  • ClassFileTransformer:顾名思义,该类就是类文件转换器接口,Java Agent提供该接口的实现,用于转换类文件

Instrumentation提供了一些添加&移除类转换器(ClassFileTransformer)、获取被JVM加载的类、重定义类、判断类是否支持重转换&重定义的接口,详细介绍如下:

  • addTransformer(ClassFileTransformer transformer, boolean canRetransform):注册类转换器。除了注册类转换器依赖的类定义以外,所有的类定义都会经过类转换器;当类加载时、重定义时、重转换时(如果canRetransform为true),会调用类转换器;如果一个类转换器抛出异常,JVM会依次调用其他类转换器;同一个类转换器实例可以被添加多次,但是强烈建议不要这么做,可以通过创建新的实例来避免这种做法
  • addTransformer(ClassFileTransformer transformer):注册类转换器,与addTransformer(transformer, false)作用相同
  • removeTransformer(ClassFileTransformer transformer):移除已注册的transformer,类定义将不再经过已经移除的transformer;移除策略为移除最近添加的transformer实例;由于多线程的原因,一个被移除的transformer可能会被调用,我们实现的transformer应该避免这种情况
  • isRetransformClassesSupported():判断JVM配置是否支持对类的重转换,该属性在jar包的MANIFEST.MF文件中定义,属性名为:Can-Retransform-Classes
  • retransformClasses(Class<?>… classes):重转换参数中提供的类,此函数有助于修改已加载的类;原始的类文件字节可以被ClassFileTransformer转换;如果在重转换期间被转换类的方法正在运行,该方法还会运行原始方法的字节码,后续新调用才会运行新方法;该方法不会导致任何初始化,换句话说,重定义一个类不会导致其初始化方法运行,静态变量的值将保持调用之前的状态;重转换类的实例不受影响;重转换可能修改方法体、常量池和属性,但是不能添加、移除和重命名字段和方法,不能修改方法签名,不能修改继承关系(这些限制可能会在未来的版本取消)
  • isRedefineClassesSupported():判断JVM配置是否支持对类的重定义,该属性在jar包的MANIFEST.MF文件中定义,属性名为:Can-Redefine-Classes
  • redefineClasses(ClassDefinition… definitions):重定义参数提供的类定义;此方法用于在不引用现有类文件字节的情况下替换类的定义,就像从源代码重新编译以修复并继续调试时所做的那样,如果引用现有类字节文件则会调用retransformClasses方法;其余性质与retransform类似
  • isModifiableClass(Class<?> theClass):判断类是否被修改过,例如被重转换或者重定义;如果修改过则返回true,否则返回false(原始类型和数组类型的类永远不会被修改)
  • getAllLoadedClassed():返回所有被JVM加载的类
  • getInitiatedClasses(ClassLoader loader):返回指定类加载器加载的全部类数组;如果loader为空,则返回启动类加载器加载的类数组
  • getObjectSize(Object objectToSize):返回指定对象消耗的数组大小

Instrumentation的实现类为sun.instrument.InstrumentationImpl,有关接口的具体实现可以参考该类,这里不再赘述;掌握Instrumentation提供的大部分接口的使用方法,对我们完成特定的功能非常有帮助

ClassFileTransformer只提供了一个接口transform并提供了默认实现,用于转换类文件,返回byte数组:

	/*
	该类用于转换指定的类文件并返回转换后的新的类文件
	参数详解:
	loader-被转换类的类加载器,如果是启动类加载器加载的话则为null
	className-被转换类的全限定类名
	classBeingRedefined-如果这是由重定义或重传触发的,则为被重定义或重传的类;如果这是类加载,则为null
	protectionDomain-正在定义或重定义的类的保护域
	classfileBuffer-类文件格式的输入字节缓冲区-不得修改
	*/
	default byte[]
    transform(  ClassLoader         loader,
                String              className,
                Class<?>            classBeingRedefined,
                ProtectionDomain    protectionDomain,
                byte[]              classfileBuffer)
        throws IllegalClassFormatException 
        return null;
    

在transform的实现中,我们通过字节码增强的方法对类进行修改,例如使用javassist or asm等方法修改类定义,来实现我们想要的功能;然后将ClassFileTransformer接口的实现类注册到Instrumentation中,如果JVM允许重转换,Instrumentation将会调用我们注册的transform来转换JVM加载的类

Instrumentation API原理

java.lang.instrument包的具体实现,依赖于JVMTI(Java Virtual Machine Tool Interface),JVMTI是一套由Java虚拟机提供的,为JVM相关工具提供的本地变成接口集合。JVMTI从Java SE5开始引入,整合和取代了之前使用的Java Virtual Machine Profiler Interface(JVMPI)和Java Virtual Machine Debug Interface(JVMDI),在Java SE6中,JVMPI和JVMDI已经消失了。JVMTI 提供了一套”代理”程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM,并利用 JVMTI 提供的丰富的编程接口,完成很多跟 JVM 相关的功能。

事实上,java.lang.instrument 包的实现,也就是基于这种机制的:在 Instrumentation 的实现当中,存在一个 JVMTI 的代理程序,通过调用 JVMTI 当中 Java 类相关的函数来完成 Java 类的动态操作。除Instrumentation功能外,JVMTI 还在虚拟机内存管理,线程控制,方法和变量操作等等方面提供了大量有价值的函数。

JVM官方文档地址:JVMTI

启动时加载instrument agent过程

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

运行时加载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方法。

Example

实现Java Agent的大致流程如下:

  1. 编写Java Agent类,结合ClassFileTransformer添加要实现的功能
  2. 自定义resources/META-INF/MANIFEST.MF文件,或者通过pom.xml属性配置自动生成该文件
  3. 打jar包,并确定jar包的绝对路径,为后续使用做准备
  4. 通过JVM参数:-javaagent:jar包绝对路径=args 启动我们要运行的main方法或者jar包,从而运行指定Java Agent

上述为实现Java Agent的简单流程,可根据实际需要进行简单调整

premain

自定义transformer,用于转换类,起作用为计算每个方法的执行时间,实现类如下:

public class CalculateTime implements ClassFileTransformer 

    private String config;
    private ClassPool pool;

    public CalculateTime(String config, ClassPool pool) 
        this.config = config;
        this.pool = pool;
    

    private final static String source = "\\n"
            + "    long begin = System.currentTimeMillis();\\n"
            + "    Object result;\\n"
            + "    try \\n"
            + "        result = ($w) %s$agent($$);\\n"
            + "     finally \\n"
            + "        long end = System.currentTimeMillis();\\n"
            + "        System.out.println(\\"%s方法执行时间为: \\" + (end - begin) + \\"ms\\");"
            + "    \\n"
            + "    return ($r) result;"
            + "\\n";

    private final static String voidSource = "\\n" +
            "            long begin = System.currentTimeMillis();\\n" +
            "            try \\n" +
            "                %s$agent($$);\\n" +
            "             finally \\n" +
            "                long end = System.currentTimeMillis();\\n" +
            "                System.out.println(\\"%s方法执行时间为: \\" + (end - begin) + \\"ms\\");\\n" +
            "            \\n" +
            "        ";

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException 
        if (className == null || !className.replaceAll("/", ".").startsWith(this.config)) 
            return null;
        
        try 
            className = className.replaceAll("/", ".");
            CtClass ctClass = pool.get(className);
            // 获取所有非private的方法
            CtMethod[] methods = ctClass.getDeclaredMethods();
            for (CtMethod method : methods) 
                newMethod(method);
            
            return ctClass.toBytecode();
         catch (Exception e) 
            e.printStackTrace();
        
        return null;
    

    private static CtMethod newMethod(CtMethod oldMethod) throws CannotCompileException, NotFoundException 
        CtMethod copy = CtNewMethod.copy(oldMethod, oldMethod.getDeclaringClass(), null);
        copy.setName(oldMethod.getName() + "$agent");
        oldMethod.getDeclaringClass().addMethod(copy);
        if (oldMethod.getReturnType().equals(CtClass.voidType)) 
            oldMethod.setBody(String.format(voidSource, oldMethod.getName(), oldMethod.getName()));
         else 
            oldMethod.setBody(String.format(source, oldMethod.getName(), oldMethod.getName()));
        
        return copy;
    


这里不是直接通过如下方法修改原有的类:

method.insertBefore("long start = System.currentTimeMillis();");
method.insertAfter("System.out.println(System.currentTimeMillis() - start);");

因为javassist插入的代码为一个代码块,两个代码块之间的变量无法互通,所以使用上述方法会报错;故这里是在原有的方法之上copy一个方法名为(原方法名+$agent)的方法添加到CtClass中,然后修改原始方法的方法体调用这个新增的方法,并增加计时操作;此外,还要分为带返回值和不带返回值的两种情况,详情请看newMethod方法

自定义带有premain方法的代理类:

public class MyAgent 

    /**
     * 主程序main方法执行前执行该方法
     * @param agentArgs
     * @param inst
     */
    public static void premain(String agentArgs, Instrumentation inst) 
        System.out.println("premain start");
        final String config = agentArgs;
        final ClassPool pool = ClassPool.getDefault();
        inst.addTransformer(new CalculateTime(config, pool));
    

    /**
     * 主程序main方法执行时可通过attach api调用该方法
     * @param args
     * @param inst
     */
    public static void agentmain(String args, Instrumentation inst) 
        System.out.println("load agent after main run.args=" + args);
		// 打印该应用程序中所有被类加载器加载的类
        Class[] allLoadedClasses = inst.getAllLoadedClasses();
        for (Class allLoadedClass : allLoadedClasses) 
            System.out.println(allLoadedClass.getName());
        
        System.out.println("agent run completely.");
    

这里并未自定义resources/META-INF/MANIFEST.MF文件,而是在pom.xml中进行了如下配置:

	<dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
        </dependency>
    </dependencies>
	<build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.2.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Premain-Class>cn.wygandwdn.agent.MyAgent</Premain-Class>
                            <Agent-Class>cn.wygandwdn.agent.MyAgent</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

上述设置完成之后即可开始打包,打包完成之后,通过下面的方法测试该agent:

public class BlogService 
    public String getBlog() 
        try 
            Thread.sleep(1000);
         catch (InterruptedException e) 
            e.printStackTrace();
        
        return "blog's content";
    


public class TestAgentMain 
    public static void main(String[] args) 
        BlogService service = new BlogService();
        service.getBlog();
    

在idea中配置启动参数:

1、配置main方法运行参数:

2、添加VM启动参数

3、设置VM启动参数,指定-javaagent启动参数

然后运行main方法即可,运行结果如下:

agentmain

attach api

agentmain为JVM启动后加载的Java Agent,主要通过attach api实现;attach api主要通过VirtualMachine和VirtualMachineDescriptor两个类来实现其加载Java Agent的功能,其官方api文档地址为:com.sun.tools.attach (Java SE 9 & JDK 9 )

  • VirtualMachine:该类代表当前JVM连接的目标JVM进程;应用程序通过VirtualMachine将Java Agent加载到目标JVM中;例如,用Java语言编写的探查器工具可能会连接到正在运行的应用程序,并加载其探查器代理来评测正在运行的应用程序。此外,VirtualMachine提供了访问目标JVM系统属性的权限。类的详细方法可查阅相关源码

    该类允许我们通过给attach方法传入一个jvm的pid(进程id),远程连接到jvm上;代理类注入操作只是它众多功能中的一个,通过loadAgent方法向jvm注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理。

  • VirtualMachineDescriptor:该类是一个用于描述JVM的容器类,配合VirtualMachine类完成各种功能

attach动态注入的原理则非常简单:

  1. 通过VirtualMachine.attach(pid)方法,可以远程连接一个正在运行的JVM进程
  2. 通过loadAgent(agent)将Java Agent的jar包注入到对应的进程中,然后对应的进程会调用agentmain方法

通过attach api和agentmain方法,我们可以很方便地在运行过程中动态设置加载代理类,在main函数开始运行之后再运行agent,以达到instrumentation的目的;相较于之前只能使用-javaagent参数在main方法之前运行的premain方法有了更大的扩展性,让我们修改正在运行的程序成为了可能

简单案例

在premain的agent例子中也有agentmain方法,该方法主要实现了打印该应用程序加载的所有类,我们需要通过attach api在main方法中加载Java Agent jar包,加载完成之后会运行agentmain方法,这里为了简单起见,在main方法中找到当前JVM并加载agent:

public class TestAgentMain 
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException 
        // test agentmain
        System.out.println("====start test agentmain====");
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        for (VirtualMachineDescriptor virtualMachineDescriptor : list) 
            // 要想连接当前jvm,需要添加jvm运行参数:-Djdk.attach.allowAttachSelf=true
            if (virtualMachineDescriptor.displayName().endsWith("cn.wygandwdn.TestAgentMain")) 
                VirtualMachine virtualMachine = VirtualMachine.attach(virtualMachineDescriptor.id());
                virtualMachine.loadAgent("D:/java_project/trace/learn-javaagent/target/learn-javaagent-1.0-SNAPSHOT.jar");
                virtualMachine.detach();
            
        
    

如果此时依旧使用之前的参数会报错:

这是因为main方法不允许连接当前JVM,此时我们可以通过参数设置避免该问题,具体设置如下:

-javaagent:learn-javaagent-1.0-SNAPSHOT.jar
-Djdk.attach.allowAttachSelf=true

起作用的VM参数为-Djdk.attach.allowAttachSelf=true

main方法的运行结果如下:

reference

特别鸣谢:

JVM字节码插桩神技,不改一行代码就可以监控java线上系统。阿里鹰眼、华为SkyWalking用它实现

javaagent使用指南

Java Instrument 功能使用及原理

Java程序员必知:深入理解Instrument

Javassist详解

欢迎访问我的个人博客: 风在哪个人博客

以上是关于字节码插桩之Java Agent的主要内容,如果未能解决你的问题,请参考以下文章

字节码插桩之Java Agent

Android Gradle 中的字节码插桩之ASM

Android Gradle 中的字节码插桩之ASM

Android Gradle 中的字节码插桩之ASM

Java 字节码插桩技术

Android字节码插桩——详细讲解 附带Demo