ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

JDK 22 Class-File API:零依赖实现Java Agent官方写法

JDK 22 Class-File API:零依赖实现Java Agent官方写法 写了这么多年Java最让我觉得“官方终于干活了”的一件事就是JDK 22开始正式把Class-File API放进了标准库。以前写Java Agent绕不开ASM、Byte Buddy这些第三方库要么被访问者模式的回调绕晕要么被版本兼容折磨。现在好了从JDK 22开始官方提供了自己的字节码解析、生成和转换APIJava Agent的开发终于有了一条“官方写法”的路线。这篇文章我不整虚的直接从Agent原理讲到新旧写法对比再带你写一个零第三方依赖的Agent Demo最后把踩过的坑一并交代清楚。1. Java Agent到底是什么为什么突然又火了1.1 一句话版本代码接管JVM的“安检口”先说清楚Java Agent不是最近炒得很火的那类AI Agent智能体。Java Agent是JVM提供的Instrumentation机制你可以把它理解成类文件加载进JVM之前的“安检口”。JVM加载类的时候字节码通过ClassLoader的defineClass进入运行时。Java Agent做的事情就是在类被真正加载前通过Instrumentation注册一批Transformer让你有机会查看、修改、甚至完全替换这个类的字节码然后再放入JVM执行。用生活类比最直观JVM是一条高速公路各个Class文件是试图上路的车辆。Agent就是高速路入口的检查站车辆通过前你可以给它加装设备、换车牌、甚至改造成一辆完全不同的车。而这个“检查站”有两种开放方式。premain在main方法执行之前启动配合-javaagent参数一起使用。这是最常见的场景像SkyWalking、JaCoCo都是这么干的。agentmainJVM运行中动态挂载不需要重启应用。Arthas这类在线诊断工具就是依赖这个能力。很多人聊Agent会把“动态代理”混进来其实两者完全不同。JDK动态代理Proxy、CGLIB是应用层通过接口或继承生成代理对象Java Agent是JVM层面的字节码增强它能改那些代理技术碰不到的类比如JDK自带类库、第三方Jar里的类。面试如果被问到这个区别先把“层”说清楚再讲应用场景基本就稳了。1.2 哪些场景必须用Java Agent不是所有工具都非要上Agent但这些问题只有Agent能优雅解决APM监控SkyWalking这样的全链路监控工具核心就是靠Agent自动给方法织入调用链数据采集逻辑业务代码零侵入。代码覆盖率统计JaCoCo在测试阶段通过Agent给每个方法插入覆盖率标识不需要业务代码配合。热部署与热修复Arthas可以在不重启JVM的情况下动态调整日志级别、查看方法入参返回值底层是agentmain retransformClasses。安全审计与越权模拟在方法入口加白名单、黑名单逻辑或者模拟异常行为做故障演练。Mock与测试增强在测试环境用Agent把某些重量级方法替换成桩实现避免改业务代码。这些场景共同点都是“不能改业务代码”但又必须改运行时行为。对Java Agent就是为这种需求而生的。2. 老写法为什么让人头疼2.1 冷冰冰的MANIFEST.MF和那几百行ASM先说传统写法的最低要求。你得手动写一个META-INF/MANIFEST.MF里面至少声明一句Premain-Class然后把这个manifest塞进Jar包再用-javaagent启动。光这一步就能卡住不少新手因为稍微打包不规范Agent就静默不生效。真正劝退的是字节码增强这一步。早期大家用ASMASM有两种核心模型事件模型和树模型。事件模型是访问者模式解析类文件时一路回调visit、visitMethod、visitCode、visitInsn…… 你要插入一段逻辑必须清楚地知道此刻回调到哪个节点这种心智负担非常重。树模型虽然好一点但又要自己维护ClassNode、MethodNode这些结构。简单统计一下传统写一个“方法耗时统计”的Agent至少需要理解class文件结构、熟悉ASM的MethodVisitor、处理好大小帧stack map frame、处理局部变量槽偏移再处理manifest和打包。很多从没写过字节码的同事第一次看ASM代码直接原地放弃。2.2 第三方字节码库的老大难版本敏感ASM为了兼容不同版本的class文件每个JDK新版本releaseASM基本都要跟着出一版适配。如果一个项目里既有ASM 7又有ASM 9或者Spring、Mockito这些框架传了不同版本的ASM依赖你就在依赖冲突的泥潭里越陷越深。Byte Buddy解决了一部分易用性问题写起来确实比ASM优雅太多但它自身依赖比较多尤其在Agent这种启动加载链路上每多一个依赖就意味着更多类需要提前初始化。而且Byte Buddy版本升级同样要跟着JDK节奏走底层最终还是依赖ASM那套解析引擎。说到底第三方库再强也是De Facto的标准不是官方标准。2.3 动态代理和Java Agent面试千万别混把动态代理和Java Agent放在一起讲是因为面试里被问太多次了。还是那句话一个是应用层代码技巧一个是JVM层插桩机制。对比维度JDK动态代理/CGLIBJava Agent作用层次应用层创建代理对象JVM层修改类字节码原理反射Proxy或字节码生成子类Instrumentation ClassFileTransformer能否增强目标类最终形态只能代理接口或可继承类可以增强任何类包括JDK自带类侵入性需要改造对象创建方式业务代码零侵入典型应用Spring AOP、MyBatis Mapper代理SkyWalking、Arthas、JaCoCo面试时先给这个表格的结论再主动说一句“Agent还有一个动态代理做不到的杀手锏可以增强JDK本身和构造方法、静态代码块”一般就能把面试官胃口吊起来。3. 官方新写法Class-File API 带来的变化3.1 官方API从哪来现在到哪个版本阶段Class-File API最早的形态出现在JDK 22对应JEP 457当时还是Preview。JDK 23又做了第二轮PreviewJEP 484JDK 24是第三次JEP 478。这些版本的API虽然还在预览但核心设计已经非常稳定。按照JDK官方一贯的节奏大约在进入正式版后这套API就是java.base之外的另一个字节码处理标准答案。它诞生的背景很简单JDK本身在推进Valhalla这类项目时也需要大量操作class文件长期依赖ASM这种外部库不现实。官方需要一套自有的、稳定的、能随JDK共同演进的class文件处理基础能力。而恰好在Java Agent领域长期被ASM和Byte Buddy把持的局面也该由官方出手收敛一下了。3.2 它凭什么说是“官方写法”不一定要求你立刻把项目里的Byte Buddy全部替换掉但Class-File API至少在三个方向上碾压老方案。第一是零第三方依赖。Agent是植入在目标JVM里的你的Agent类越少依赖classpath冲突风险越低。用Class-File API写Agent只需要一个Jar包不需要任何extra的二进制依赖对启动速度、类加载链路都更友好。第二是API设计与时俱进。官方API用不可变模型和Builder流式计算的方式组织天然对Java开发者友好。它把类、方法、属性、指令全部抽象成不可变对象并提供transformClass、withMethod、withCode这类声明式写法。相比ASM的访问者回调代码更容易读也更容易在IntelliJ里点出候选方法文档更全。第三是天然支持最新字节码版本。这是最有吸引力的。每次JDK发布新class文件版本ASM都要等适配而Class-File API作为JDK的一部分永远和当前JDK的字节码版本同步。你再也不用担心“Agent在JDK 25上运行ASM却不支持class version 69”这种窘境。需要注意JDK 22到JDK 24里Class-File API仍在Preview阶段使用时要加--enable-preview编译参数具体以对应的JDK版本和JEP说明为准。如果你要上生产留意一下项目使用的JDK版本对Preview API的支持期限。4. 实操零依赖写一个Java Agent4.1 准备一个干净的工程先说结论一个最简单的Agent不需要任何Maven依赖不需要Gradle插件只需要一个JDK。为了演示我用JDK 24。目标是在一个叫com.example.OrderService的类里给每个非构造方法插入一行enter方法名的日志。工程目录大概长这样agent-demo/ ├── com/example/agent/TimingAgent.java ├── com/example/agent/TimingTransformer.java ├── com/example/agent/manifest/MANIFEST.MF └── com/example/service/OrderService.javaOrderService是我们拿来做实验的目标类放在一个单独的目录里模拟“目标应用”中的业务类。package com.example.service; public class OrderService { public int getCount() { return 42; } public void createOrder(String orderId) { System.out.println(create order: orderId); } }4.2 premain骨架先让Agent跑起来写一个最小Agent入口先什么都不改只验证Agent能被JVM加载、Instrumentation对象能拿到。package com.example.agent; import java.lang.instrument.Instrumentation; public class TimingAgent { public static void premain(String agentArgs, Instrumentation inst) { System.out.println([TimingAgent] premain execute, args agentArgs); inst.addTransformer(new TimingTransformer(), false); } }addTransformer的第二个参数是canRetransform。这里传false代表只影响后续类加载不作用到已经加载的类上。如果需要热重载已经加载的类这里就要传true并且manifest里要声明Can-Redefine-Classes: true。接下来编译并打成Jar包。先编译目标类和Agent类javac -d target/classes com/example/service/OrderService.java com/example/agent/TimingAgent.java com/example/agent/TimingTransformer.javaTimingTransformer我先写一个什么都不改的版本package com.example.agent; import java.lang.instrument.ClassFileTransformer; import java.security.ProtectionDomain; public class TimingTransformer implements ClassFileTransformer { Override public byte[] transform(Module module, ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { System.out.println([TimingAgent] loading class: className); return null; } }transform返回null表示不修改字节码JVM按原始字节码继续加载。这个骨架几乎是最小可运行Agent拿它先验证Agent链路通不通再接字节码增强逻辑避免把问题混在一起。4.3 用Class-File API做真正的字节码增强现在进入本文的核心环节用官方Class-File API给OrderService的方法开头插入一行系统输出。package com.example.agent; import java.lang.classfile.*; import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; import java.lang.instrument.ClassFileTransformer; import java.security.ProtectionDomain; public class TimingTransformer implements ClassFileTransformer { Override public byte[] transform(Module module, ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { if (className null || !className.startsWith(com/example/service/)) { return null; } System.out.println([TimingAgent] enhance class: className); try { ClassFile cf ClassFile.of(); ClassModel cm cf.parse(classfileBuffer); return cf.transformClass(cm, (ClassBuilder cb, ClassElement ce) - { if (ce instanceof MethodModel mm) { String methodName mm.methodName().toString(); if (methodName.equals(init) || methodName.equals(clinit)) { cb.accept(ce); return; } if (mm.flags().has(Flag.ABSTRACT) || mm.flags().has(Flag.NATIVE)) { cb.accept(ce); return; } cb.withMethod(mm.methodName(), mm.methodDescriptor(), mm.flags(), mb - mb.withCode(codeBuilder - { codeBuilder.getstatic( ClassDesc.of(java.lang.System), out, ClassDesc.of(java.io.PrintStream)); codeBuilder.ldc([agent] enter method: methodName); codeBuilder.invokevirtual( ClassDesc.of(java.io.PrintStream), println, MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_String)); mm.code().ifPresent(codeModel - codeModel.forEach(codeBuilder::accept)); })); } else { cb.accept(ce); } }); } catch (Throwable t) { t.printStackTrace(); return null; } } }这段代码逻辑拆开看ClassFile.of()拿到官方API入口parse解析原始字节码得到ClassModel它是一个不可变模型你可以安全地遍历里面所有方法、字段、注解。transformClass接收一个ClassTransform函数式接口参数是ClassBuilder和ClassElement。遍历到方法元素MethodModel时我们用cb.withMethod重建这个方法。mb.withCode(codeBuilder - ...)意味着我们准备重新生成方法体。我先把“打印日志”的字节码写进去再把原来的方法体指令原样copy到后面。这样就做到了“先加日志再执行原逻辑”。mm.code().ifPresent(codeModel - codeModel.forEach(codeBuilder::accept))是关键一步它把原来方法体里所有指令依次追加到新方法体中。如果你是在JDK 22或23环境跑个别内部方法名可能略有差异以官方Preview的API文档为准。但整体思路在四个版本里是一致的解析成不可变模型用Builder重新组装。4.4 打包与启动验证打包之前先准备Manifest文件。注意文件名严格区分大小写必须放在META-INF/MANIFEST.MF路径下。Manifest-Version: 1.0 Premain-Class: com.example.agent.TimingAgent Can-Redefine-Classes: true Can-Retransform-Classes: true然后用jar命令打包Agentjar --create --file timing-agent.jar \ --manifest META-INF/MANIFEST.MF \ -C target/classes .启动目标应用时把Agent加进去java -javaagent:timing-agent.jardemo -cp target/classes com.example.service.OrderService我准备一个带main方法的启动类来验证完整输出比如新增一个com.example.Mainpackage com.example; import com.example.service.OrderService; public class Main { public static void main(String[] args) { OrderService service new OrderService(); service.createOrder(10001); System.out.println(getCount service.getCount()); } }重新编译执行后预期能看到类似输出[TimingAgent] premain execute, argsdemo [TimingAgent] enhance class: com/example/service/OrderService [agent] enter method: getCount [agent] enter method: createOrder create order: 10001 getCount42看到enter method出现在业务输出之前说明Agent成功把日志插进了方法开头而且业务类本身并没有改过一个字节。这就是Java Agent最厉害的地方。注意如果你用IDEA直接运行Main类需要把-javaagent配置到VM options里否则Agent不会加载。4.5 一个更“官方”的增强思路上面例子是在方法开头插入代码。如果是更复杂的插桩比如方法返回前打印返回值直接Copy原方法体会比较麻烦因为方法可能有多个返回指令。Class-File API里还有CodeTransform这种“逐指令变换”的方式你可以遍历原方法体的每一条指令遇到areturn、ireturn、return时在这条指令之前插入你要的字节码。这种方式比整体用CodeBuilder重新组装更精细也更符合“官方写法”的气质。实际生产级Agent比如全链路trace的采集逻辑基本都是按指令级、行为级来定义的。5. 新老方案怎么选聊了这么多很多人会问我那我现在到底用ASM用Byte Buddy还是用官方Class-File API维度裸ASMByte Buddy官方Class-File API学习曲线陡峭要懂class结构平缓DSL友好中等Builder风格第三方依赖一个jar多个jar需处理传递依赖零依赖字节码版本适配随第三方版本走随第三方版本走跟随JDK性能与体积高较高启动期额外操作多中高纯JDK实现适合场景对性能极致敏感、老项目业务快速落地、托管式增强工具类、新项目、Agent框架我的建议分三种情况如果只想快速给业务系统做一个方法耗时统计、敏感接口审计的开发期工具直接用Class-File API反正是个人工具没必要引一堆依赖。如果是给公司做个统一APM框架要长期兼容大量用户的不同JDK版本Byte Buddy的成熟度和生态优势还是明显的毕竟它底层ASM帮你踩了很多年的坑。如果是做深度字节码研究或者要在接近底层的地方定制class转换逻辑那裸ASM仍然有市场它能做到最精细的指令级控制。Class-File API在未来成熟后会更适合做“标准型”的Agent增强——因为它不需要额外考虑第三方库的版本拉锯。6. 常见问题与排查技巧实录6.1 premain不生效Agent最常见的问题是“我明明加了-javaagent怎么Premain-Class没执行”。先别急着怀疑代码检查三样东西Jar包里的META-INF/MANIFEST.MF是否拼写正确Premain-Class必须是全限定类名不能写com/example/agent/TimingAgent这种路径形式。是否用jar命令打包而不是zip命令。zip打包经常导致manifest文件没有放在META-INF目录的正确位置。启动命令里-javaagent参数是否放在-jar或者主类名称之前。JVM参数必须位于主类参数之前才有效。我见过一个同事花了两小时排查最后发现是IDEA的VM options里忘了加参数项目Local跑的时候压根没把Agent带上。6.2 NoClassDefFoundError和模块系统的坑Agent自身依赖的类在-javaagent加载阶段就可能被业务ClassLoader碰上。如果Agent用了某个三方库而这个库和业务系统里的版本冲突很容易出现NoClassDefFoundError或者奇怪的方法签名错误。最稳妥的规避方式是Agent Jar里不要带任何第三方依赖。这就是我推荐“零依赖”的一个重要原因。如果你一定要用三方库就把这些依赖用自定义的ClassLoader隔离起来绝对不要让Agent的类直接暴露给业务ClassLoader。此外JDK 9之后的模块系统对Agent也有约束。如果你的Agent Jar是模块化Jar某些情况下它无法通过-javaagent正常加载因为Agent必须走非模块化路径。常规做法是确保Agent Jar不要包含module-info.class。6.3 Agent自身类不能与业务类互相引用写Transformer时非常容易掉进一个坑在transform方法里直接new了一个业务类或者访问了业务类静态字段。这会造成ClassLoader的循环加载轻则类未被增强重则ClassCircularityError。正确的做法是Transformer里只做字节码层面的字符串、描述符、指令操作绝不实例化业务类。类名、方法名的比较全部用全限定名或描述符字符串。必要时通过Class.forName(className, false, loader)做只加载不初始化的检查但要非常小心循环依赖。6.4 热重载、retransform的坑想对已经加载完毕的类做增强需要用到Instrumentation.retransformClasses。但要注意addTransformer注册时canRetransform必须传true而且manifest里要有Can-Retransform-Classes: true。两个条件缺一不可否则retransform会被静默忽略。还有一个隐蔽问题热重载时JVM要求新的class文件版本与已加载类一致并且不允许破坏某些已生效的内部结构比如删除某个已经被JIT编译优化的方法签名。实际排查时如果发现retransform后方法行为没有变化优先怀疑hotspot的JIT优化过期类尝试用-Xint或-XX:PrintCompilation观察现象。6.5 想深入研究看什么官方文档方面JEP 457、JEP 484、JEP 478这三份必须读它们把Class-File API的设计动机和演进讲得很清楚。再配合java.lang.classfile包下的javadoc做几个小实验就能玩转。如果想看商业级项目的写法直接去读SkyWalking和Arthas的Agent模块源码。SkyWalking用的是Byte Buddy但它在字节码增强的抽象分层上做得极其干净Arthas则把agentmain场景玩得炉火纯青。照着它们的工程结构拆解一遍你会发现Agent的“官方写法”精髓不在于某个API而在于把插桩逻辑抽象成什么类需要增强、什么方法需要变换、指令怎么织入、如何做到无损回溯。最后再分享一个我实际踩坑后的选择个人做工具链和实验性质的项目我现在无脑上Class-File API一个字香。但如果你是在维护一个已经跑了很多年的Agent基础框架不要贸然替换底层字节码引擎先把新API用在增量功能上跑稳一个版本再评估是否全面迁移。字节码操作这条路稳比炫重要官方写法再香也不如线上不炸香。
返回列表