前世今生
- 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;
- 接口实现
csharppublic 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
makefileManifest-Version: 1.0 Premain-Class: com.example.MyPremainAgent Agent-Class: com.example.MyAgent
-
挂载JVM
- premian
javascriptjava -javaagent:/java-agent/path/java-agent.jar[=options]
- agentmain
typescriptpublic 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编程模式。