Java Agent技术在业务中的价值和落地实践

前世今生

  • Instrumentation
    • 在JAVA 5中,我们可以通过java代码,即java.lang.instrument做动态Instrumentation,它把Java的instrument功能从本地代码中解放出来,使之可以用java代码的方式解决问题。使用Instrumentation,开发者可以构建一个独立于应用程序的代理程序(agent),用来监测和协助运行在JVM上的程序,甚至能够替换和修改某些类的定义。有了这样的功能,开发者就可以实现更为灵活的运行时虚拟机监控和Java类操作了,这样的特性实际上提供了一种虚拟机级别支持的AOP实现方式,使得开发者无需对JDK做任何升级和改动,就可以使用某些AOP的功能了。
    • 在JAVA SE6中,通过Java ToolAPI中的attach方式,我们可以很方便的在运行过程中动态的设置加载代理类,以达到instrumentation的目的。而不用像在Java SE 5中,必须在运行前用命令行参数或者系统参数来设置代理类。
    • java.lang.instrument包的具体实现,依赖于JVMTI(Java Virtual Machine Tool Interface)。JVMTI是一套由Java虚拟机提供的,为JVM相关的工具提供的本地编程接口集合。除开Instrumentation功能外,JVMTI还在虚拟机内存管理,线程控制,方法和变量操作等等方面提供了大量有价值的函数。
  • 工作流程(以premain为例)
    • 创建代理 - 通过实现premain方法来创建 Java代理。这个方法是代理的入口,允许对字节码进行操作和插装。
    • 指定代理 - 使用-javaagent命令行选项启动JVM,指定代理JAR文件的路径。在应用程序类加载之前调用代理的premain方法。
    • 插装 - 在premain方法中,使用 Instrumentation对象向 JVM 添加 ClassFileTransformer。ClassFileTransformer 实现允许在类加载到 JVM 时拦截和修改字节码。
    • 类转换 - 每当加载类时,ClassFileTransformer 的 transform 方法被调用,允许你根据需求修改字节码。
    • 应用修改 - 在 transform 方法中,你可以检查类字节码,进行必要的修改,并将修改后的字节码返回给 JVM。
    • 类加载 - 一旦应用修改,类就会以 ClassFileTransformer 所做的更改被加载到 JVM 中。
  • 接口介绍
csharp 复制代码
public interface Instrumentation {
    /**
     * 注册一个Transformer,从此之后的类加载都会被Transformer拦截。
     * Transformer可以直接对类的字节码byte[]进行修改
     */
    void addTransformer(ClassFileTransformer transformer);

    /**
     * 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
     * retransformClasses可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
     */
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    /**
     * 获取一个对象的大小
     */
    long getObjectSize(Object objectToSize);

    /**
     * 将一个jar加入到bootstrap classloader的 classpath里
     */
    void appendToBootstrapClassLoaderSearch(JarFile jarfile);

    /**
     * 获取当前被JVM加载的所有类对象
     */
    Class[] getAllLoadedClasses();
}

增强开发

  • 开发流程

    • 选择加载方式

      • 启动时加载 - premain
      arduino 复制代码
      /**
       * 以vm参数的形式载入,在程序main方法执行之前执行
       * 其jar包的manifest需要配置属性Premain-Class
       */
      public static void premain(String agentArgs, Instrumentation inst);
      • 运行时加载 - agentmain
      arduino 复制代码
      /**
       * 以Attach的方式载入,在Java程序启动后执行
       * 其jar包的manifest需要配置属性Agent-Class
       */
      public static void agentmain(String agentArgs, Instrumentation inst);
    • transform实现

      • 接口定义
      vbnet 复制代码
      /**
       * 传入参数表示一个即将被加载的类,包括了classloader, classname和字节码byte[]
       * 返回值为需要被修改后的字节码byte[]
       */
      byte[] transform(ClassLoader			loader,
                      String				className,
                      Class<?>			classBeingRedefined,
                      ProtectionDomain	        protectionDomain,
                      byte[]				classfileBuffer) throws IllegalClassFormatException;
      • 接口实现
      csharp 复制代码
      public byte[] transform(ClassLoader classLoader, String internalTypeName, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        if (internalTypeName == null) {
          return ClassReloadingStrategy.Strategy.NO_REDEFINITION;
        } else {
          ClassDefinition redefinedClass = (ClassDefinition)this.redefinedClasses.remove(classBeingRedefined);
          return redefinedClass == null ? ClassReloadingStrategy.Strategy.NO_REDEFINITION : redefinedClass.getDefinitionClassFile();
        }
      }
    • 指定main-class

    makefile 复制代码
    Manifest-Version: 1.0
    Premain-Class: com.example.MyPremainAgent
    Agent-Class: com.example.MyAgent
    • 挂载JVM

      • premian
      javascript 复制代码
      java -javaagent:/java-agent/path/java-agent.jar[=options]
      • agentmain
      typescript 复制代码
      public class AgentAttacher {
          public static void main(String[] args) throws Exception {
              String agentJarPath = "path_to_your_agent_jar_file.jar"; 
              String pid = findProcessId("YourMainClassName"); 
              if (pid != null) {
                  VirtualMachine vm = VirtualMachine.attach(pid);
                  vm.loadAgent(agentJarPath, "your_agent_arguments_if_any");
                  vm.detach();
              } else {
                  System.out.println("Target process not found");
              }
          }
          private static String findProcessId(String processName) {
              for (VirtualMachineDescriptor vmd : VirtualMachine.list()) {
                  if (vmd.displayName().contains(processName)) {
                      return vmd.id();
                  }
              }
              return null;
          }
      }

热门应用

  • JRebel - JRebel是目前最常用的热部署工具,节省了大量重启时间,提高了开发效率。其工作流程如下:

    • 监测变化: JRebel会监测开发人员在IDE中对类文件所做的修改,包括更改现有方法的实现、添加新方法、修改字段等。
    • 创建新版本: 当发生变化时,JRebel会创建一个新版本修改过的类,涉及将新的类定义加载到内存中,而不影响正在运行的应用程序。
    • 类版本管理: JRebel会维护一个类版本的历史记录,以便跟踪每个类的变化。这使得在应用程序继续运行时,可以明确地知道每个类的先前版本和当前版本。
    • 与JVM通信: Java Agent与JVM通信,将正在运行的类定义替换为新的版本,而无需重新启动应用程序。
    • 应用变更: 一旦JVM接受了新的类定义,正在运行的应用程序将立即开始使用新的类版本,而无需停止或重新启动。
  • Pinpoint - Pinpoint通过字节码增强技术来实现无侵入式的调用链采集。其核心实现还是基于JVM的javaagent机制来实现。其工作流程如下:

    • Agent注入: Pinpoint需要在应用程序中注入Agent,用于收集性能数据。Agent可以通过Java Instrumentation或者字节码增强技术,动态地对应用程序进行修改和增强。
    • 数据采集: 一旦Agent被注入到应用程序中,它开始收集各种性能数据,包括方法调用、响应时间、数据库访问等。这些数据被称为"spans"。
    • 数据传输: 采集到的数据被传输到Pinpoint的Collector,Collector将数据聚合并存储,通常使用HBase等存储系统。
    • 实时监控: 收集到的数据可以在实时监控界面上进行展示,开发人员和运维人员可以通过这些界面来查看应用程序的性能指标。
    • 性能分析: Pinpoint提供了性能分析工具,用于分析数据以识别性能瓶颈和优化机会。
  • Arthas - Arthas是一个开源的Java诊断工具,主要用于实时监控和诊断 Java 应用程序。其工作原理如下:

    • Agent注入:Arthas 通过Java Agent技术将自身注入到目标 Java 进程中。
    • 字节码增强: 一旦注入到目标进程中,Arthas可以通过字节码增强技术,如ASM等,实时修改目标类的字节码,以收集信息或注入诊断代码。
    • 命令行交互: 用户可以通过命令行界面(CLI)与Arthas进行交互,发送命令来获取应用程序状态、执行诊断操作或监控信息。
    • 数据收集:Arthas可以收集各种信息,如方法调用、类加载、线程状态、内存使用情况等。
    • 实时监控: 收集到的数据可以在命令行界面实时显示,帮助用户监控应用程序的运行状态。
  • SkyWalking - SkyWalking 是一个应用性能监控(APM)工具,其工作原理如下:

    • Agent代理:SkyWalking 通过在应用程序中嵌入代理来监控应用程序的性能。这个代理可以通过字节码增强或者使用各种语言的 Agent 实现,来收集关于应用程序的各种信息。
    • 数据收集:代理收集有关应用程序的性能指标、调用链路、错误信息等数据,并将其发送到 SkyWalking 服务端。
    • 存储和分析:SkyWalking 服务端接收来自代理的数据,将其存储在数据库中,并进行分析和处理,以便生成性能报告、调用链路图等。
    • 可视化:最后,SkyWalking 提供用户界面,使用户能够以图形化的方式查看应用程序的性能数据、调用链路等信息。

主流框架

  • ASM - 直接编辑字节码的框架,提供接口可以让我们方便地操作字节码文件,进行注入修改类的方法,动态创造一个新的类等等操作。使用 asm 可以直接对 class 文件进行读写。

    • 低级别、高效的字节码操作库。
    • 适合对性能要求严格、需要对字节码进行精细控制的应用。
    • 适用于库开发人员和需要进行低级别优化的开发者。
  • Javassist - 能够在运行时定义、编译新类,并在JVM加载时修改类文件。相比ASM的缺点就是臃肿,优点就是生成新字节码非常方便,直接拼java源码就行了,学习成本比ASM大大降低,是比较通用的开发java agent的框架。

    • 提供比 ASM 更高级别的字节码操作抽象。
    • 适用于运行时字节码生成、类转换以及对各种 Java 类进行字节码操作。
    • 常用于开发者更青睐于更简单的字节码操作 API 的情况。
  • ByteBuddy - Byte Buddy是一个基于ASM的一种高级字节码生成方案,用于在Java应用程序运行时创建和修改Java类,而无需编译器的帮助。除了Java类库附带的代码生成实用程序外,Byte Buddy还允许创建任意类,并且不限于实现用于创建运行时代理的接口。此外,Byte Buddy提供了一种方便的API,可以使用Java代理或在构建过程中手动更改类。​​

    • 专注于简单性和易用性,在运行时提供了一个高级别的 API 用于生成和转换 Java 类。
    • 适用于创建动态代理、实现拦截器以及其他需要简单性和提高开发人员生产率的场景。
    • 在 AOP(面向切面编程)以及动态类生成需求的框架和库中常被使用。

使用字节码框架的优点:​

  • 灵活性: 字节码框架允许开发人员在编程层面对字节码进行操作,从而实现对类的灵活修改、增强和生成,使得在运行时进行高度定制成为可能。
  • 性能: 通过直接操作字节码,开发人员可以实现更高效的优化,避免了反射等机制带来的性能损耗,对性能敏感的场景中具有优势。
  • 功能丰富: 字节码框架提供了丰富的 API 和工具,可以进行各种复杂的字节码操作,包括生成新的类、修改现有类、动态代理等,为开发人员提供了强大的功能支持。
  • 对现有代码透明: 通过字节码框架,可以在不修改原始源代码的情况下对现有类进行修改和增强,从而保持现有代码的稳定性和可维护性。
  • 跨平台性: 字节码框架通常在不同的 Java 虚拟机上都能够良好地运行,提供了跨平台的支持。

总的来说,字节码框架提供了更高级别的抽象和灵活性,使得开发人员能够更直接、更高效地操作 Java 字节码,从而实现对类的动态修改和增强。推荐使用Byte-Buddy。

业务场景

​业务痛点

  • 测试环境只有有限的几套,不同需求相互干扰,无法满足敏捷开发的诉求,如何灵活实现测试环境隔离?

=》流量染色,有特定标识的请求优先路由到绑定标识的服务实例,实现环境隔离

  • 新需求上线前的线上灰度,如何尽早的发现问题?

=》流量复制、回放 + 数据比对

  • 全链路压测,如何避免脏数据对现有数据的影响?

=》影子流量的标记、标记流量的重定向

  • 应用启动慢,如何分析高耗时的业务模块?

=》应用启动数据采集,Bean构造数据采集

  • 服务治理,各个服务各自为政,是不是可以统一剥离业务,服务治理的能力、配置和管理以Sidecar形式展开?

=》集成服务治理能力,Java进程内的Sidecar。

我们把问题总结归纳为数据标记、数据采集、数据治理。

解决方案

上述的业务痛点,几乎存在于各个业务团队,而这些问题使用Java Agent技术都能得到良好的解决,实际上我们或多或少的用到过其中的某个或者几个。但实际的使用体验并不好,问题如下,

  • 不同Agent技术侧重点不同,应用场景不同,这要求我们需要引入多个Agent,管理、维护、使用成本很高
  • 多个Agent的加载存在不兼容场景,容易引发线上问题
  • 定制化开发不知道如何开展,以哪个为主?

对于上述的业务痛点,在参考了业务几款主流的Java Agent的设计和实现后,我们决定以skywalking为技术底座,通过其插件拓展机制,实现了上述业务诉求。设计要点如下:

  • Plugin 设计 - 通过BootService定义了插件的生命周期,并通过SPI机制实现插件的加载
csharp 复制代码
public interface BootService {
    void prepare() throws Throwable;
    void boot() throws Throwable;
    void onComplete() throws Throwable;
    void shutdown() throws Throwable;
    default int priority() {
        return 0;
    }
}
  • 能力增强 - 通过ClassEnhancePluginDefine定义字节码增强操作,保留了bytebuddy原始字节码操作能力(任意能力可增强),字节码增强操作同样通过SPI机制定义并读取字节码增强类。
  • ClassEnhancePluginDefine 定义
less 复制代码
public abstract class ClassEnhancePluginDefineV2 extends AbstractClassEnhancePluginDefine {
    @Override
    protected DynamicType.Builder<?> enhanceClass(TypeDescription typeDescription,
                                                  DynamicType.Builder<?> newClassBuilder,
                                                  ClassLoader classLoader) throws PluginException {}
    @Override
    protected DynamicType.Builder<?> enhanceInstance(TypeDescription typeDescription,
                                                     DynamicType.Builder<?> newClassBuilder, ClassLoader 													  classLoader,
                                                     EnhanceContext context) throws PluginException {}
    @Override
    public InstanceMethodsInterceptPoint[] getInstanceMethodsInterceptPoints() {}

    @Override
    public StaticMethodsInterceptPoint[] getStaticMethodsInterceptPoints() {}
}
  • SPI定义
arduino 复制代码
resources/META-INF.service/BootService
resources/{module-name}.transformer

基于上述两点,自定义一个插件,实现各生命周期阶段的逻辑,同时实现字节码增强逻辑,这能够解决任意的业务诉求。到此,我们的方案已经满足了设计目标。

  • ClassLoader隔离 - 在使用ClassLoader隔离技术之前,我们遇到最多的问题就是Agent Jar包和Application Jar包不兼容问题。该设计和实现参考了JVM-Sandbox。
  • 类隔离策略 - 通过自定义的AgentClassLoader破坏了双亲委派的约定,实现了和目标应用的类隔离。所以不用担心加载Agent会引起应用的类污染、冲突。各模块之间类通过ModuleJarClassLoader实现了各自的独立,达到模块之间、模块和沙箱之间、模块和应用之间互不干扰。
  • 类增强策略 - 通过在BootstrapClassLoader中埋藏的Spy类完成目标类和Agent内核的通讯

思考总结

  • java agent = agentmain/premain + instrument + 字节码框架
  • 借助java动态字节码增强技术,我们可以在不修改源码的情况下,动态的修改系统的功能,对应用无侵入,便于推广。甚至可以对很多无法获取或者无法修改源码的类如java系统类进行修改,比如统一修改java socket类我们就可以统计系统的流入流出网络流量等。从而实现了另一种功能强大的动态非侵入式AOP编程模式。
相关推荐
白露与泡影24 分钟前
Spring Boot中的 6 种API请求参数读取方式
java·spring boot·后端
CodeClimb24 分钟前
【华为OD-E卷 - 服务失效判断 100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
CodeClimb26 分钟前
【华为OD-E卷 - 九宫格按键输入 100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
豪宇刘33 分钟前
MyBatis 与 MyBatis-Plus 的区别
java·tomcat
一个儒雅随和的男子41 分钟前
Spring为什么要用三级缓存解决循环依赖?
java·spring·缓存
梦想是成为Java高手41 分钟前
ThreadLocal的介绍与使用规范,初学者必看
java
StevenGerrad42 分钟前
【读书笔记/源码】How Tomcat Works 笔记 - c1~c10
java·笔记·tomcat
数据小小爬虫1 小时前
淘宝商品详情API返回值说明:Python爬虫代码示例
java·爬虫·python
起名方面没有灵感1 小时前
力扣23.合并K个升序链表
java·算法
m0_748233361 小时前
Spring中WebSocket的使用
java·websocket·spring