一文带你了解agent机制

2,054 阅读9分钟

原创文章&经验总结&从校招到 A 厂一路阳光一路沧桑

详情请戳www.codercc.com

1. 插桩的使用场景

在实际业务开发中,系统层面会有一些公共模块需要进行实现,类似于校验、权限等等,在成熟的解决方案中会通过AOP的方式进行实现。

通常链路日志追踪上,每个公司都会有ELK的解决方案,但是公司的业务线众多的情况下,通常会要求业务系统在日志打印上会增加不同标记来进行区分,方便后续不同业务部门进行成本核算以及权限管控等等,也就是说在日志输出上会有一定的格式要求。

另外,在实际排查问题中往往需要完成的上下文参数才能有助于问题的高效排查,因为平时在系统中主动的编写日志,实际上是一种防御式编程了,那么一定是在写代码时就考虑了这种或者那种的业务异常情况,基本上在线上出现问题的概率会很小。大多数情况,出现线上问题一定是日常开发中没有考虑的地方了,也只能通常arthas去分析。如果涉及到上下游服务时进行沟通的时候,往往上下游开发同学会询问调用服务的参数以及链路的traceid,才能高效的排查。糟糕的是,如果系统中没有提前埋入的话,只能临时去加代码,然后发布到预发等环境上,如果幸运的话能够复现问题,也就能解决。针对这种情况,如果系统能够自动打印出方法的上下文出入参数的话,在每一条链路上并且自动种入traceId的话,这样就能在问题排查场景上更加高效,针对这块日志标准化的能力可以抽象成公共基础能力。

因此,在这样的诉求下,如果涉及到日志标准化改造就需要一套通用的解决方案来进行,来完成日志格式的改造当然有很多的方式来进行推进,比如堆人集中改造:通过团队组织层面,作为技术驱动的事项,有每个同学在原先的log.info(其他日志级别的日志一样)中按照公司的日志格式要求添加部门特殊的业务标记KV对。或者实现一套spring AOP的方案,定义一些注解提供给各个业务系统使用,但是针对存量代码来说,需要投入人力去改造,在类或者方法上添加相应的注解,这种方式也会带来人效很低的问题。

针对上述这些问题,可以通过agent的方式来实现方法级别的字节码插桩并且进行日志标准化。AOP是一类解决方案的“指导思想”,具体的落地实现方式会有很多,比如aspectJ,cglib等等工具,通过记录方案的执行耗时以及异常和方法出入参来完成业务链路的非侵入监控。整体思路是,agent机制提供了“字节码更改”的时机,字节码插桩则是AOP的一种具体落地方式。

在 JDK 1.5 中,Java 引入了 java.lang.Instrument 包,该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型。其中,使用该软件包的一个关键组件就是 Java agent。从名字上看,似乎是个 Java 代理之类的,提供了一个可以更改class字节码的时机。有很多开发工具都是基于Java Agent实现的,例如常见的热部署JRebel,各种线上诊断工具(btrace, greys),还有阿里最近开源的arthas。

2. agent使用

2.1 agent静态加载

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

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

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

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

默认会优先使用带有Instrumentation的premain加载,如果加载了第一个方法,那么第二个方法就不会再去加载。如果第一个方法没有,才会去加载第二个方法。

agent静态启动方式

使用 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等等来改写实现类。

2.2 静态加载示例

  1. 首先创建一个agent类,其中包含了premian方法,并且通过实现ClassFileTransformer接口来完成一个自定义重写字节码的类。

    public class PremainAgent {
        public static void premain(String agentArgs, Instrumentation inst) {
            System.out.println("agentArgs : " + agentArgs);
            inst.addTransformer(new CustomClassTransformer(), true);
        }
    
        static class CustomClassTransformer implements ClassFileTransformer {
    
            @Override
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
                System.out.println("premain load class !!!");
                return classfileBuffer;
            }
        }
    
    }
    
  2. 配置MAINFEST.MF文件

    Manifest-Version: 1.0
    Can-Redefine-Classes: true
    Can-Retransform-Classes: true
    Premain-Class: com.agent.example.PremainAgent
    

    该文件的生成也可以通过maven插件配置后自动生成,具体配置如下:

    <plugin>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.0.2</version>
        <configuration>
            <archive>
                <manifest>
                    <addClasspath>true</addClasspath>
                </manifest>
                <manifestEntries>
                    <Premain-Class>com.agent.example.PremainAgent</Premain-Class>
                    <Agent-Class>com.agent.example.PremainAgent</Agent-Class>
                    <Can-Redefine-Classes>true</Can-Redefine-Classes>
                    <Can-Retransform-Classes>true</Can-Retransform-Classes>
                </manifestEntries>
            </archive>
        </configuration>
    </plugin>
    
  3. 配置JVM参数指定agent路径,启动应用

    -javaagent:path-to/agent-core-0.0.1-SNAPSHOT.jar
    

    启动应用后,在类加载之前会先被agent先进行拦截,可以看示例代码的输出:

    premain load class !!!
    premain load class !!!
    premain load class !!!
    premain load class !!!
    premain load class !!!
    premain load class !!!
    

2.3 agent动态加载

premain的方式是在应用启动执行main函数之前,提供了可以对类进行修改的时机。在main函数执行之后或者说业务应用正常运行后,再去更改类字节码的时机只能通过agentmain方法,具体如下:

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

具体的步骤和静态加载的基本一致:

  1. 新建agent类,其中包含agentmain方法,并在次类中完成对应的agent逻辑。并且,如果需要完成对字节码的更改,同样可以实现ClassFileTransformer接口,将实现类放置到Instrumentation;

  2. 完成MAINFEST.MF文件,配置Agent-Class等选项,具体如下:

    Agent-Class: com.agent.example.AgentMainAgent
    Can-Redefine-Classes: true
    Can-Retransform-Classes: true
    
    

    对MAINFEST.MF文件也可以通过maven插件完成配置,在打包的时候自动生成,具体配置如下:

    <plugin>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.0.2</version>
        <configuration>
            <archive>
                <manifest>
                    <addClasspath>true</addClasspath>
                </manifest>
                <manifestEntries>
                    <Agent-Class>com.agent.example.AgentMainAgent</Agent-Class>
                    <Can-Redefine-Classes>true</Can-Redefine-Classes>
                    <Can-Retransform-Classes>true</Can-Retransform-Classes>
                </manifestEntries>
            </archive>
        </configuration>
    </plugin>
    

2.4 agent挂载

动态agent的方式实际上是指业务应用在运行中能够注入一个agent,借助agent完成相应的代理逻辑。那么,怎样才能在JVM运行的时候向其完成注入,自然而然也就涉及到了两个JVM进程之间的通信,可以通过VirtualMachine来完成。

VirtualMachine 字面意义表示一个Java 虚拟机,也就是程序需要监控的目标虚拟机,提供了获取系统信息(比如获取内存dump、线程dump,类信息统计(比如已加载的类以及实例个数等), loadAgent,Attach 和 Detach (Attach 动作的相反行为,从 JVM 上面解除一个代理)等方法,可以实现的功能可以说非常之强大 。该类允许我们通过给attach方法传入一个jvm的pid(进程id),远程连接到jvm上 。

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

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

2.5 动态加载示例

  1. 首先创建一个包含了agentmain方法的agent类,并新建实现ClassFileTransformer接口的类加载到instrument中。

    public class AgentMainAgent {
        public static void agentmain(String agentArgs, Instrumentation inst) {
            System.out.println("start agentmain");
            inst.addTransformer(new CusDefinedClass(), true);
        }
    
        static class CusDefinedClass implements ClassFileTransformer {
            @Override
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
                System.out.println("agentMain load class !!!");
                return classfileBuffer;
            }
        }
    }
    
  2. 将整个agent进行打包,完成MAINFEST.MF文件配置;

  3. 在测试类中中通过VirtualMainche类完成对agent动态挂载到正在运行的JVM进程中

    public class AgentTest {
        public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
            List<VirtualMachineDescriptor> vms = VirtualMachine.list();
            for (VirtualMachineDescriptor vm : vms) {
                if ("com.agent.example.AgentTest".equals(vm.displayName())) {
                    VirtualMachine machine = VirtualMachine.attach(vm.id());
                    machine.loadAgent("/path-to/agent-core-0.0.1-SNAPSHOT.jar");
                }
                System.out.println(vm.displayName());
            }
        }
    }
    

    VirtualMachine.list()可以列出当前正在运行JVM进程,示例中通过具体的进程名判断出当前正在执行的JVM,然后通过VirtualMachine.attach与目标VM建立连接后,通过loadAgent的方式将agent挂载到目标VM中。示例代码如下:

    start agentmain
    com.agent.example.AgentTest
      
    agentMain load class !!!
    agentMain load class !!!
    

agent机制提供了在应用执行前或者应用执行后,能够获取class字节码的时机,并且能够通过更改class字节码的方式来完成相应的业务逻辑,比如方法级别的监控、日志标准化等等AOP常见的业务场景,这种方式对业务应用的侵入性是最低的,并且性能是相当可观的。在后续文章中会总结下字节码的使用、基于字节码插桩完成业务监控以及实际开发中遇到问题。

参考资料

www.cnblogs.com/rickiyang/p…

www.cnblogs.com/huanshilang…