ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java Agent原理与实践:基于Instrumentation和Byte Buddy实现无侵入监控

Java Agent原理与实践:基于Instrumentation和Byte Buddy实现无侵入监控 1. 项目概述Java Agent到底是什么东西先说结论Java Agent是JVM在启动或运行过程中能够动态修改已加载字节码的一套机制。它的本质是一个jar包里面有一个特殊的入口类通过Java Instrumentation API让外部程序在类被加载之前或者之后对字节码进行改写。这个技术我最早接触是在做APM应用性能监控的时候。当时要在不改动业务代码的前提下给线上所有接口加上耗时统计、链路追踪第一反应是Spring AOP但AOP只能作用于Spring管理的Bean对于框架内部的类、JDK自带的类库、甚至第三方中间件的调用链AOP就无能为力了。而Java Agent可以在类加载的时候直接改写字节码不管这个类是Spring管理的还是JDK自己加载的都能拦截。它解决的痛点其实很明确无侵入式增强。你的业务代码可以一个字都不用改打包成jar之后在启动命令里加上-javaagent:xxx.jar就能给运行中的JVM注入额外能力。这也是它能被用在新 relic、SkyWalking、Arthas、IDEA的HotSwap插件等众多工具中的根本原因。适合谁来学习如果你是Java后端开发工程师正在做性能监控、故障排查、全链路追踪或者想搞明白热部署、JUnit插件、代码覆盖率工具的底层原理这篇内容值得读完。全文不需要你有很深的内功但最好对类加载机制和字节码执行有模糊的概念没有也没关系我会用尽量直白的语言把这些串起来。2. premain与agentmainJava Agent的两种形态2.1 premain启动时增强premain是Java Agent最传统、用得最多的一种形态。JVM在启动阶段主类的main()方法还没执行之前会先加载-javaagent指定的jar包找到其中的premain方法并调用。它的特点是时机非常早早到所有自定义类都还没被加载只加载了JVM必需的基础类。这个特性让premain非常适合做启动阶段的全局字节码增强几乎能覆盖所有后续加载的业务类。对于这类AgentMANIFEST.MF文件中需要声明Premain-Class: com.example.MyAgent如果你的Agent需要在JDK自身的模块类比如java.*、jdk.*上做手脚还要额外加一个Can-Retransform-Classes: true但绝大多数场景用不到别给自己找麻烦。2.2 agentmain运行中挂载agentmain则是另一种玩法JVM已经跑起来了业务流量正在走这时候你想把一个Agent挂载进去不需要重启进程。它是通过Attach机制实现的最典型的场景就是Arthas的启动方式——用java -jar arthas-boot.jar把诊断工具挂到已运行的进程上全程不用动目标进程。agentmain的入口方法是agentmain(String args, Instrumentation inst)它有两个关键点第一由于目标JVM已经跑了一段时间很多类已经被加载过了所以光靠ClassFileTransformer是不够的必须调用inst.retransformClasses()对已加载的类做重新转换。第二它能增强的类范围取决于目标JVM启动时的参数。如果目标JVM允许重定义类默认是允许的就能生效如果加了-XX:-CanRetransformClasses之类的限制检测的时候会直接抛UnsupportedOperationException。两者的适用场景我整理了一张表对比项premainagentmain挂载时机JVM启动前JVM运行中是否需要改启动命令需要-javaagent不需要用Attach API对已加载类的处理类还没加载直接增强需要retransform典型工具SkyWalking、Byte Buddy examplesArthas、热修复工具风险等级较低可控较高挂载失败或增强出错会殃及在线进程2.3 两种形态的代码结构差异两种形态的Agent jar包内部结构几乎一样只是入口方法不同my-agent.jar ├── META-INF │ └── MANIFEST.MF └── com └── example └── MyAgent.classMANIFEST.MF文件是整个Agent的“名片”JVM通过它找到入口类。用Maven打包时我推荐直接在maven-jar-plugin里配置manifestEntries而不是手动维护一个.MF文件否则很容易出现换行符或空格导致的诡异问题。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration archive manifestEntries Premain-Classcom.example.MyAgent/Premain-Class Agent-Classcom.example.MyAgent/Agent-Class Can-Redefine-Classestrue/Can-Redefine-Classes Can-Retransform-Classestrue/Can-Retransform-Classes /manifestEntries /archive /configuration /plugin这里有两个最容易被坑的地方一是Premain-Class必须写全限定名不能写com.example.MyAgent.java二是如果同时声明了Premain-Class和Agent-Class两者指向的类可以相同但方法签名不能一样JVM会按premain优先来查。3. Instrumentation APIAgent的底层支撑3.1 核心对象与能力边界入口方法会收到一个java.lang.instrument.Instrumentation实例这个实例是JVM提供给Agent的“手术刀”它提供了几组能力addTransformer(ClassFileTransformer transformer)注册一个转换器后续每个类被加载时这个转换器都有机会修改它的字节码。retransformClasses(Class?... classes)对已加载的类重新触发转换流程这是agentmain能增强已运行类的前提。redefineClasses(ClassDefinition... definitions)直接用新的字节码替换旧类但这个替换是完整的不能做局部修改。getAllLoadedClasses()获取当前JVM中所有已加载的类能帮你快速定位目标类。getObjectSize(Object)估算对象大小但只是浅层大小不包含引用对象的实际大小。要注意的是addTransformer注册的转换器默认只在类首次加载时生效如果你想让已经加载的类也经过转换器处理必须配合retransformClasses才行。这是一个非常容易出现“我明明加了Transformer但线上类一点变化都没有”的问题点。3.2 ClassFileTransformer的作用时机ClassFileTransformer接口只有一个方法byte[] transform(ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer)classfileBuffer是JVM读取到的原始字节码你的任务是对它做修改后返回新字节码如果返回null表示什么都不做JVM继续用原始字节码。这个方法的执行时机有几个讲究它在类加载器的defineClass之前被调用所以此时类还没有真正被定义。className是JVM内部的类名格式斜杠分隔比如java/lang/String不是点分隔。我见过不少同事在这里漏掉转换导致Agent静默失效。同一个类可以被多个Transformer处理前一个的输出会作为后一个的输入所以Transformer的注册顺序也可能影响结果。3.3 为什么说Instrumentation是双刃剑Instrumentation API本身是插桩能力的中枢但使用时要格外留意。它不是拿来做业务开发的而是适合用在监控、诊断、测试这类基础设施上。过度使用或滥用轻则让类加载变慢重则引发ClassCircularityError、VerifyError这类让整JVM崩溃的严重错误。我自己干活时的原则是只增强必要的方法只增加必要的代码量平时测试覆盖到所有增强分支再上生产。Agent代码里的任意一个空指针、一个数组越界都可能把正常业务类搞坏而且错误会指向业务类排查起来非常折磨人。4. 字节码增强的实现方式从ASM到Byte Buddy4.1 为什么不能直接改Java源码很多人第一次接触Agent时会有一个疑问能不能在Agent里直接写Java代码改目标类的方法内容答案是不行。Java源文件需要编译但Agent拿到的是已经编译好的.class字节码而且在JVM内部这个类可能已经被类加载器加载唯一可操作的就是字节码层面的修改。所以在Agent里你实际上是在写字节码生成或者修改代码而不是普通Java逻辑。这对有些人来说是个门槛但好消息是我们有非常多好用的库来屏蔽底层细节。4.2 ASM性能与灵活性的天花板ASM是目前Java字节码操作事实上的标准库性能极高、体积小、可控性极强SkyWalking的核心引擎就是直接基于ASM手写插桩逻辑的。ASM有两种编程模式Tree API把Class表示成一个树状结构适合读和局部改。Visitor API基于事件驱动的流式访问写起来更繁琐但性能和内存占用最优。如果只是给方法加个耗时打印用ASM的Visitor API大概需要三四十行代码而且得对visitCode、visitMethodInsn、visitMaxs这些回调点非常熟。业界普遍认为ASM的学习曲线很陡但一旦掌握收益也最大。4.3 Byte Buddy让字节码操作回归“人类语言”Byte Buddy是另一个热门选择它把字节码的操作抽象成“动态创建类”和“修改方法”的高层API写起来更像是面向对象编程而不像在拼接字节码指令。比如说给一个方法加上进入和退出的日志在Byte Buddy里可以这样new AgentBuilder.Default() .type(ElementMatchers.nameStartsWith(com.example.biz)) .transform((builder, typeDescription, classLoader, module) - builder.method(ElementMatchers.named(doBiz)) .intercept(MethodDelegation.to(TimingInterceptor.class)) ) .installOn(inst);再看TimingInterceptor的实现public class TimingInterceptor { RuntimeType public Object intercept(This Object target, Origin Method method, AllArguments Object[] args, SuperCall Callable? callable) throws Exception { long start System.currentTimeMillis(); try { return callable.call(); } finally { System.out.printf(%s.%s 耗时 %d ms%n, target.getClass().getName(), method.getName(), System.currentTimeMillis() - start); } } }这个写法对新手极其友好。This表示目标对象Origin拿到原始方法元信息SuperCall负责调用原方法AllArguments是原始参数列表。你不用关心字节码层面如何压栈、如何保存局部变量框架全包了。4.4 常见的字节码库选型对比库名抽象级别学习曲线性能适用场景ASM底层陡峭极好对性能极致敏感的框架级项目Byte Buddy中层平缓好日常Agent和动态增强主流推荐Javassist高层平缓中等快速原型简单插桩CGLIB高层平缓中等主要为Spring AOP服务选型建议是做产品级Agent优先Byte Buddy如果你在写框架基础设施追求极致的类加载性能再考虑ASMJavassist虽然上手快但类加载开销较大在大型APM中已经很少出现在核心路径了。5. 从0到1实操用Java Agent实现一个方法耗时统计器5.1 明确目标和场景这次实操的目标是开发一个Java Agent在不修改业务代码的情况下对所有com.example.biz包下的方法加入耗时统计并在方法执行结束后打印方法名和耗时。这个场景非常实用。如果你想统计线上某个核心服务的接口耗时但又不想在代码里到处写日志埋点用Agent是最干净的做法。我们来完整走一遍。5.2 创建工程并编写Agent入口新建一个Maven工程结构如下agent-demo ├── pom.xml └── src/main/java/com/example └── TimingAgent.java先写入口类负责注册Transformerpackage com.example; import java.lang.instrument.Instrumentation; public class TimingAgent { public static void premain(String agentArgs, Instrumentation inst) { System.out.println([Agent] 启动参数 agentArgs); inst.addTransformer(new TimingClassFileTransformer(), false); } public static void agentmain(String agentArgs, Instrumentation inst) { System.out.println([Agent] 运行时挂载参数 agentArgs); inst.addTransformer(new TimingClassFileTransformer(), false); } }注意这里premain和agentmain我用了不同的参数签名但Agent jar同时能适配两种形态。addTransformer的第二个参数canRetransform我暂时设为false它的含义是这个Transformer是否会在执行retransformClasses时再次被调用。如果设为false那么对于已经加载的类就不生效所以后边如果要在agentmain场景下对已加载类生效需要改成true并在挂载时主动触发retransformClasses。5.3 实现ClassFileTransformer接下来是核心的Transformer逻辑。这里我用Byte Buddy来实现目的是减少ASM底层的噪音便于理解整体流程。package com.example; import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebudry.agent.builder.AgentBuilder.InitializationStrategy; import net.bytebuddy.agent.builder.AgentBuilder.RedefinitionStrategy; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.Instrumentation; import static net.bytebuddy.matcher.ElementMatchers.*; public class TimingClassFileTransformer implements ClassFileTransformer { private final AgentBuilder agentBuilder; public TimingClassFileTransformer() { this.agentBuilder new AgentBuilder.Default() .type(nameStartsWith(com.example.biz)) .transform((builder, typeDescription, classLoader, module) - builder.method(any()) .intercept(MethodDelegation.to(TimingInterceptor.class)) ); } Override public byte[] transform(ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { // 实际上我们将控制权转交给Byte Buddy的AgentBuilder // 但为演示原生的ClassFileTransformer流程这里结合Byte Buddy的install操作即可 return agentBuilder.installOn(inst).transform(loader, className, classBeingRedefined, protectionDomain, classfileBuffer); } }这里有一个细节上面这个组合方式其实不推荐因为AgentBuilder本身有更顺滑的写法是为了演示直接实现ClassFileTransformer的流程。实际项目中通常直接使用AgentBuilder的installOn(inst)一步到位它会自动注册合适的Transformer。更好的写法是这样public static void premain(String agentArgs, Instrumentation inst) { new AgentBuilder.Default() .type(nameStartsWith(com.example.biz)) .transform((builder, typeDescription, classLoader, module, protectedDomain) - builder.method(any()) .intercept(MethodDelegation.to(TimingInterceptor.class)) ) .installOn(inst); }这样注册完成后只要JVM后续加载com.example.biz包下的类Byte Buddy就会自动介入。5.4 编写拦截器逻辑这里我们把业务原有的方法执行交给了callable.call()这样既不改动原逻辑又能在前后拿到时间。package com.example; import java.lang.reflect.Method; import java.util.concurrent.Callable; public class TimingInterceptor { RuntimeType public Object intercept(This Object target, Origin Method method, AllArguments Object[] args, SuperCall Callable? callable) throws Exception { long start System.nanoTime(); try { return callable.call(); } finally { long costMs (System.nanoTime() - start) / 1_000_000; if (costMs 0) { System.out.printf([耗时统计] %s#%s 耗时 %d ms%n, target.getClass().getName(), method.getName(), costMs); } } } }这里我用了System.nanoTime()而不是System.currentTimeMillis()原因只有一个nanoTime不受系统时间调整的影响适合测量时间间隔而currentTimeMillis可能因为NTP同步出现时间倒退。对耗时统计来说前者更可靠。5.5 Maven打包并验证在pom.xml中除了依赖byte-buddy和byte-buddy-agent还需要在打包插件里写入manifest属性plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration archive manifestEntries Premain-Classcom.example.TimingAgent/Premain-Class Agent-Classcom.example.TimingAgent/Agent-Class Can-Redefine-Classestrue/Can-Redefine-Classes Can-Retransform-Classestrue/Can-Retransform-Classes /manifestEntries /archive /configuration /plugin然后执行打包mvn clean package准备一个简单的业务工程package com.example.biz; public class OrderService { public void createOrder() { try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(订单创建完成); } public static void main(String[] args) { OrderService service new OrderService(); service.createOrder(); service.createOrder(); } }运行java -javaagent:agent-demo-1.0-SNAPSHOT.jartiming -jar order-service.jar预期输出里会出现[耗时统计] com.example.biz.OrderService#createOrder 耗时 200 ms这样的日志。这足以证明业务类已经被成功增强。5.6 补充运行时挂载方式的验证如果我们用agentmain的方式挂载那么需要先有一个正在运行的Java程序然后通过Attach API或者jattach工具把Agent jar文件挂上。实现方式import com.sun.tools.attach.VirtualMachine; public class AttachMain { public static void main(String[] args) throws Exception { VirtualMachine vm VirtualMachine.attach(args[0]); // 传入目标进程PID vm.loadAgent(/path/to/agent-demo-1.0-SNAPSHOT.jar, timing); vm.detach(); } }注意com.sun.tools.attach.VirtualMachine是JDK内部API编译时需要tools.jar或者使用jdk.attach模块。在JDK9模块化之后这个类被打包在jdk.attach模块里编译时可以用--add-modules jdk.attach来处理。在目标进程已经运行、OrderService已经加载的情况下挂载后需要主动触发retransformClasses否则已加载的类不会走新注册的Transformer。这一步经常被遗漏很多人挂在运行中Agent后发现业务类没有变化就是漏了这个操作。如果使用的是AgentBuilder.installOn(inst)它默认会对之前已加载且匹配的类触发重转换但前提是目标JVM支持重转换。如果是自己手工添加ClassFileTransformer那就必须在agentmain里枚举目标类并调用retransformClasses。6. 实战中的关键技巧与常见问题点6.1 类重复增强问题如果同一个类被多个Agent或者同一个Agent的Transformer多次修改会出现方法被包了多层统计代码的情况。在我的经验里统计日志重复打印是最容易发现的症状。解决办法是在Transformer里设置一个标记字段或者类级标记在增强前先判断是否已经加过标记或者更简单地在Agent的premain里检查启动参数避免重复挂载同一个Agent。企业级APM的Agent一般都有幂等机制就是为了防止重复插桩。6.2 ClassCircularityError与VerifyError增强了java.lang或核心类库时极容易出现ClassCircularityError因为Agent本身依赖了某些基础类而你又尝试在基础类加载过程中对其做转换导致循环依赖。应对策略不要轻易增强java.*、javax.*。如果迫不得已Transformer的代码不能引用目标类中不存在的API或者因为增强而导致的非预期逻辑。必须保证生成的字节码满足JVM校验规则尤其在操作数栈、局部变量表使用对的情况下。用Byte Buddy这类成熟库能大幅降低这类风险。6.3 Lambda表达式的痛点Java 8的Lambda在字节码层面被编译成了invokedynamic指令实际执行时会由LambdaMetafactory动态生成实现类。这意味着普通的类名匹配可能匹配不到Lambda表达式内部的核心方法。如果要对Lambda内的方法做增强思路有两种一是通过Hidden类的模式匹配找到$$Lambda$开头的类但这实现难度较高二是避免在关键业务方法内使用过于复杂的Lambda显得可控性更好。至少我在做APM增强时对Lambda内部方法的插桩是能不放就不放风险和收益不成正比。6.4 Agent加载失败排查的一般流程遇到Agent没有生效通常按这个顺序排查确认jar包中MANIFEST.MF是否包含正确的Premain-Class。确认启动命令中-javaagent路径是否正确可以加-verbose:class观察类加载日志。看目标类是否真的匹配了增强规则包名、类名、方法名匹配条件。在Transformer里临时加一行System.err.println验证JVM是否真的调用了它。确认目标JVM版本是否支持所用Byte Buddy或者ASM版本JDK8和JDK17对字节码版本的要求不一样。查看进程是否已经存在早于Agent启动的类定义这种情况必须用retransform。6.5 如何对线上服务做“无伤”增强线上服务最怕的是Agent增强出问题把整个进程搞挂。我给的压箱底建议是先在压测环境完整跑一遍Agent确认对业务逻辑没有影响。在Agent代码里加一个总开关比如通过环境变量或系统属性控制是否插桩。尽量把Agent的插桩逻辑隔离在一个单独的线程池或异步队列里避免增加主链路延迟。对高危操作比如retransformClasses加一个超时保护如果增强卡住则回退。监控Agent自身的异常不要让它静默吞掉错误。至少要在Agent日志里记录所有异常方便回溯。7. 收尾的个人体会做Java Agent这几年最大的感受是这个技术像一把锋利的刀用得好能悄无声息地改变整个JVM世界的行为用不好会让一个稳定的在线应用瞬间陷入Class加载地狱。我见过线上事故因为一个Agent里的小小字节码Bug导致所有Controller的方法都没法正常执行重启才恢复。从那时起我对Agent的敬畏心特别重。如果要从零开始学我的建议是先别急着写代码把类加载机制搞透再用Byte Buddy从简单的“方法耗时打印”练手逐步过渡到链路上下文传递、线程池增强、数据库慢SQL拦截这些真实场景。这个方向天花板很高像全链路追踪、服务网格的数据面治理、以及Java领域各种插件化工具底层都离不开Agent。希望这篇内容能帮你在理解Java Agent的路上少踩几个坑。
返回列表