线上某个订单接口的 P99 从 80ms 涨到 600ms,日志里看不出任何异常,方法内部一处埋点都没有,想加日志就得改代码、走测试、排队等发版。这种时刻,Java Agent就是那把不用动业务代码、不用重启服务(或者只在启动参数里加一行)就能把方法耗时、入参出参、调用链路全部摸清楚的刀。它的正式名字叫Java 插桩(Instrumentation),本质是 JVM 提供的一套钩子:允许你在类被加载进方法区之前,拿到它的字节码数组,改完再交还给 JVM。不用改业务源码,不用重新编译业务 jar,甚至不用让业务方知道你的存在。
我把这套东西用在三类场景里最多:一是无侵入的耗时与异常采集,给指定包下的方法统一加上入口出口打点;二是线上问题的动态诊断,服务跑着的时候 attach 进去,抓几秒钟的调用现场再退出来;三是故障注入与压测造数,在测试环境里让某个下游调用必定超时,验证降级逻辑到底走没走通。这三类活儿的共同点是——业务代码不能改,或者改了代价太大。
下面这些内容适合谁看:写过 Java、知道 ClassLoader 大概是什么、但没真正写过 agent 的同学,以及写过 agent 但被NoClassDefFoundError、VerifyError、改了字节码却不生效折磨过的同学。我会从 JVM 的加载时序讲起,把两条入口的区别、字节码改写工具的取舍、一个能直接跑起来的耗时统计 agent、以及一次完整的“agent 不生效”排查链路全部摊开讲。
1. Java Agent 的两个入口与它们背后的加载时序
很多人第一次接触 agent 是从-javaagent:xxx.jar这行启动参数开始的,但真正决定你能不能干成事儿的,是这行参数被 JVM 解析的时机。premain和agentmain不是两个 API 版本,而是两套完全不同的加载时序,能力边界差得很远,选错了会浪费大量时间。
1.1 premain 在 main 之前跑,但业务类一个都还没加载
JVM 启动时,参数解析阶段就会把-javaagent指定的 jar 找出来,读取MANIFEST.MF里的Premain-Class,用系统类加载器把这个类加载进来,然后反射调用它的premain(String, Instrumentation)方法,这整件事发生在你的main方法执行之前。这是一个非常关键的事实:premain 跑完的那一刻,业务代码里的OrderService、UserController一个都还没被加载。
这带来了两个直接后果。好处是你拿到Instrumentation实例的时间足够早,addTransformer注册的转换器可以拦截后续所有类的加载,覆盖面最全。坏处是你没法在这一刻直接retransformClasses(OrderService.class)——这个类根本还不存在,Class.forName会把它提前加载进来,但那样会打乱框架的类初始化顺序,Spring 的启动流程尤其容易被搞坏。
所以 premain 模式的正确姿势是:只注册 transformer,不主动碰业务类。让 JVM 在加载业务类的自然过程中回调你的transform方法。这也是所有基于-javaagent的 APM 探针的标准做法。
还有一个坑必须提前说:premain方法里抛出的任何未捕获异常,都会导致JVM 启动直接失败。不是打印个警告然后继续,是整个进程起不来。所以 premain 内部一定要用 try-catch 把主体逻辑包住,宁可不生效也不能让业务起不来。
public static void premain(String agentArgs, Instrumentation inst) { try { AgentConfig config = AgentConfig.parse(agentArgs); inst.addTransformer(new TimingTransformer(config), true); } catch (Throwable t) { // 吞掉异常,绝不能让业务启动失败 System.err.println("[timing-agent] init failed: " + t); } }1.2 MANIFEST.MF 里那几个字段,写错一个整件事就废了
agent jar 和普通 jar 的唯一区别就在MANIFEST.MF。我见过太多次“代码没问题、就是不生效”,最后发现是 manifest 里少写了一行。几个必填和关键字段:
| 字段 | 作用 | 不写的后果 |
|---|---|---|
Premain-Class | 指定启动时调用的类 | -javaagent直接报错,进程起不来 |
Agent-Class | 指定运行时 attach 调用的类 | attach 时报Agent-Class找不到 |
Can-Retransform-Classes | 允许对已加载类重新转换 | retransformClasses抛UnsupportedOperationException |
Can-Redefine-Classes | 允许重定义类 | redefineClasses不可用,一般用不上 |
Boot-Class-Path | 追加到 bootstrap 类加载器的搜索路径 | 增强代码里引用的辅助类找不到 |
Premain-Class和Agent-Class可以指向同一个类,只要这个类同时提供premain和agentmain两个静态方法就行,签名分别是(String, Instrumentation)或者只接收(String)的单参版本。我一般写双参版本,省事。
打包的时候有个高频翻车点:如果你用maven-shade-plugin打胖包(agent 里通常会依赖 ASM 或 ByteBuddy,必须打进去),再用maven-jar-plugin配 manifest,两个插件会打架——shade 会重新生成 jar,把 jar-plugin 写的 manifest 覆盖掉。正确做法是把manifestEntries配在 shade 插件的ManifestResourceTransformer里:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <manifestEntries> <Premain-Class>com.example.agent.TimingAgent</Premain-Class> <Agent-Class>com.example.agent.TimingAgent</Agent-Class> <Can-Retransform-Classes>true</Can-Retransform-Classes> <Can-Redefine-Classes>true</Can-Redefine-Classes> </manifestEntries> </transformer> </transformers> <relocations> <relocation> <pattern>org.objectweb.asm</pattern> <shadedPattern>com.example.agent.shaded.asm</shadedPattern> </relocation> </relocations> </configuration> </execution> </executions> </plugin>注意那个relocations。这一步很多人会跳过,直到某天业务系统本身也依赖了 ASM,版本还和你打进去的不一样,于是业务侧启动直接NoSuchMethodError。把依赖包名整体重定位,是最省心的隔离手段,代价只是 jar 体积大一点。
1.3 agentmain 是运行时入口,能力更强但限制更硬
agentmain走的是另一条路:JVM 已经跑起来了,业务类已经加载了几万个,这时候你通过 attach 机制把 agent jar 送进去,JVM 读取Agent-Class加载并调用agentmain。这条路的好处是“随时上车”,不需要重启;坏处是所有类都已经加载完了,你必须主动触发重新转换,而且重新转换有硬性限制。
retransformClasses能干的和不能干的,边界非常清晰:
- 能改:方法体内部的字节码,包括插桩、改逻辑、加 try-catch。
- 不能改:增删字段、增删方法、改方法签名、改类的继承关系、改实现的接口、改类的访问修饰符。
原因是 JVM 在类加载完成后,方法区里的字段布局、方法表、vtable 都已经定型了,重新转换只允许替换方法体这部分“可替换”的内容。硬要改结构,JVM 会抛UnsupportedOperationException,错误信息里通常写着class redefinition failed: attempted to change the schema。
这个限制直接决定了业务场景的选型:如果你要新增一个字段缓存耗时统计结果,retransform 做不到,只能退而求其次用静态 Map 或者ThreadLocal存。我一般用ConcurrentHashMap<String, LongAdder>按方法维度聚合,简单可靠,也不用担心对象生命周期。
2. Instrumentation 的三板斧与类加载器那条暗线
Instrumentation接口方法不算多,但真正天天打交道的就三个:addTransformer、retransformClasses、appendToBootstrapClassLoaderSearch。前两个决定“改什么”,第三个决定“改完之后引用的类能不能找到”,而第三个恰恰是绝大多数人栽跟头的地方。
2.1 transform 方法的返回值和调用契约
ClassFileTransformer只有一个核心方法:
byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException;几个细节必须抠清楚。第一,className是内部名格式,用斜杠分隔,比如com/example/OrderService,不是点号,第一次写的时候很容易在这里匹配不上,然后怀疑人生。第二,返回值语义:返回null表示“我不改,用原始字节码”,返回一个byte[]表示“用我改的”。第三,classBeingRedefined为null表示这是一次正常的类加载,非null表示这是retransformClasses触发的重新转换——这个参数是你区分两种时机的唯一依据。
addTransformer有两个重载,addTransformer(transformer)和addTransformer(transformer, canRetransform)。第二个参数默认是false,意味着这个 transformer只在类首次加载时被调用,retransform 时会被跳过。这是我见过最多的“改了不生效”原因:代码里明明调了retransformClasses,transformer 里也打了日志,但日志就是不出现,因为注册的时候忘了传true。
还有一点值得知道:transformer 抛异常不会导致类加载失败。JVM 会捕获异常、打印到 stderr,然后当成这个 transformer 返回了null。这个行为在排查时是双刃剑——好处是 agent 的 bug 不会拖垮业务,坏处是你的 bug 被默默吞掉了,只在日志里留一行烂账。所以 transformer 内部一定要自己加 try-catch 并打日志,别指望 JVM 帮你兜。
2.2 retransformClasses 的输入到底是哪份字节码
这里有个反直觉的点,我当初也误解过:retransformClasses触发转换时,传给 transformer 的classfileBuffer是类最初被加载时的原始字节码,不是上一次转换后的结果。
JVM 内部会保留每个类加载时的原始 class 文件字节。重新转换时,它以这份原始字节为起点,依次跑一遍所有canRetransform=true的 transformer,前一个的输出作为后一个的输入,最后把结果拿去替换方法体。这意味着:重新转换是幂等的、可叠加的,你不用担心多次 retransform 导致增强代码被套娃。
但也带来一个副作用:如果你的 agent 注册了多个 transformer 做不同的增强(比如一个做耗时统计、一个做异常捕获),它们在 retransform 时会按注册顺序依次执行,后一个看到的是前一个改过的字节码。如果前一个用了 ASM 的COMPUTE_FRAMES改变了栈帧,后一个再解析时可能因为栈帧信息不符合预期而出错。我现在的做法是尽量把多个增强合并到一个 transformer 里,在一个ClassVisitor里按方法名分派,避免串联带来的不确定性。
2.3 appendToBootstrapClassLoaderSearch:解决那类“类找不到”的报错
这是最容易忽略、也最让人抓狂的一环。先说清楚双亲委派下的现实:你的 agent jar 是通过-javaagent送进去的,agent 自己的类由system class loader加载。而业务类、框架类可能由各种自定义加载器加载。当你增强的代码里invokeStatic了一个你自己写的辅助类,比如com.example.agent.AgentLog.info(),这个指令的字面量会被写进业务类的常量池。
问题来了:业务类在运行到这一行时,会用它自己的类加载器去解析com.example.agent.AgentLog。如果业务类是由某个自定义加载器加载的,而那个加载器并不认识你的 agent jar,就会直接NoClassDefFoundError。更糟的是 Spring Boot 的LaunchedURLClassLoader、Tomcat 的WebappClassLoader,它们在加载业务类时并不遵循你期待的那套委托关系。
三种解法,我按推荐度排序:
- 最稳的:增强代码里只用 JDK 类。耗时统计只需要
System.nanoTime()和java.util.concurrent.ConcurrentHashMap,一个字节都不用自己写。这是我最常用的方式,零加载器风险。 - 次稳的:把辅助类塞进 bootstrap。调
inst.appendToBootstrapClassLoaderSearch(new JarFile(helperJar)),这样任何加载器都能通过父委派找到它。缺点是 bootstrap 加载的类不能引用应用类,得写得很“干净”。 - 最后手段:运行时 attach 时调
inst.appendToSystemClassLoaderSearch(...),把 agent jar 补进系统加载器。这条路在 attach 模式下几乎是必须的,否则agentmain里加载的类业务侧完全看不见。
对于运行时 attach 的场景,第 3 条基本是标配动作,标准写法是:agentmain里第一件事就把自己的 jar 路径取出来,追加到系统加载器搜索路径,然后再干别的。
3. 字节码改写工具:ASM、Javassist、ByteBuddy 的真实取舍
工具选型这事儿,网上经常看到“ASM 性能最好、ByteBuddy 最方便”这种一句话结论,但实际项目里决定选型的往往不是性能,而是“你团队里有没有人能看懂VerifyError的堆栈”。
3.1 抽象层级的差异决定了维护成本
| 维度 | ASM | Javassist | ByteBuddy |
|---|---|---|---|
| 抽象层级 | 字节码指令级 | 源码片段字符串 | 类型/方法声明级 |
| 上手成本 | 高,需要懂 JVM 指令集 | 中,写 Java 片段字符串 | 中低,链式 API |
| 性能 | 最高,无额外开销 | 中 | 中,有运行时类型生成开销 |
| 出错可读性 | 差,VerifyError信息晦涩 | 中 | 较好 |
| 适合场景 | 追求极致轻量、逻辑简单 | 快速改方法体 | 复杂匹配、批量增强 |
我自己的选择逻辑很直接:如果是生产探针、要长期挂在线上跑、对启动耗时敏感,选 ASM,控制到极致。如果是一次性诊断工具、跑几分钟就退出来,用 ByteBuddy,半小时能写完的东西没必要死磕指令集。Javassist 我基本只在维护老项目时用,它的CtClass有个坑——用完必须detach(),否则会一直占用内存,agent 长时间运行会逐渐泄漏。
ByteBuddy 的另一个优势是匹配能力。ElementMatchers.nameStartsWith("com.example.service")这种声明式匹配,比在transform里手写字符串判断要清晰得多,尤其是要排除$内部类、排除Synthetic方法这类细节,它有现成的谓词。
3.2 COMPUTE_FRAMES 是个甜蜜的陷阱
ASM 的ClassWriter有两个常用标志:COMPUTE_MAXS和COMPUTE_FRAMES。前者只重算操作数栈和局部变量表的最大值,后者还会重算栈帧(StackMapTable)。
COMPUTE_FRAMES看起来更省事,但它内部要判断两个类型是否有公共父类,默认实现是调Class.forName去加载类。这在 agent 环境下会炸——目标类可能还没加载,或者加载器不对,直接抛TypeNotPresentException或者更诡异的ClassNotFoundException。而且这个调用是递归的,一个类涉及十几层继承时,性能开销相当可观。
我的经验是:能用COMPUTE_MAXS就别用COMPUTE_FRAMES。如果插入的代码不改变控制流(只是简单的方法进入/退出插桩),COMPUTE_MAXS完全够用。如果确实需要COMPUTE_FRAMES(比如插入了分支跳转),一定要重写getCommonSuperClass,用自己的类加载器去查,或者干脆维护一份类型层次缓存。
还有一个配套细节:ClassReader.accept的第二个参数建议传ClassReader.EXPAND_FRAMES。它会展开栈帧,让 ASM 在写入时能正确重建,否则某些 JDK 版本上会出现ClassFormatError: StackMapTable format error。
3.3 构造方法和静态块必须跳过
这是踩过最疼的坑。给构造方法插桩时,AdviceAdapter.onMethodEnter会在super()调用之后、方法体之前插入代码。听起来没问题,但如果你在onMethodEnter里用了newLocal分配局部变量,ASM 会把这部分变量放在this之后的位置,而super()调用之前的那个“未初始化对象”状态会导致字节码验证失败,运行时抛VerifyError: Bad local variable type。
静态初始化块<clinit>同理,而且更危险——如果你插桩的代码本身触发了一次类加载,而这个类正是当前正在初始化的类,就会卡死在类初始化锁上,表现为服务启动时无限期 hang 住。
所以我的 transformer 里永远有一行前置判断:
if (name.startsWith("<")) return mv; // 跳过 <init> 和 <clinit><init>和<clinit>的增强需求其实是有的,比如统计构造耗时。真要做的化,得用更精细的手段,比如只统计静态工厂方法、或者在构造方法末尾(RETURN之前)插桩,而不是在开头。但绝大多数场景下,跳过它们收益远大于损失。
4. 从零写一个方法耗时统计 Agent
理论讲完,直接上能跑的代码。这个 agent 的目标:拦截com.example.service包下所有 public 方法的耗时,超过阈值的打印一行日志,其余忽略。生产环境里这个阈值判断很关键,全量打印会把磁盘打爆。
4.1 项目结构就三个类
工程结构极其简单,不需要 Spring,不需要任何框架:
timing-agent/ ├── pom.xml └── src/main/java/com/example/agent/ ├── TimingAgent.java // premain + agentmain 入口 ├── TimingTransformer.java // 字节码改写逻辑 └── TimingConfig.java // 参数解析依赖只有org.ow2.asm:asm和org.ow2.asm:asm-commons,版本用 9.x,别用太老的,老版本对新 class 文件版本(JDK 17+ 的 61+)支持不全,会抛IllegalArgumentException: Unsupported class file major version。这个报错信息还算友好,一看就知道是版本问题。
4.2 transformer 的核心写法
用AdviceAdapter比裸写MethodVisitor省事得多,它帮你处理了操作数栈的偏移问题:
public class TimingTransformer implements ClassFileTransformer { private static final String PREFIX = "com/example/service/"; private static final long THRESHOLD_NS = TimeUnit.MILLISECONDS.toNanos(50); @Override public byte[] transform(ClassLoader loader, String className, Class<?> beingRedefined, ProtectionDomain pd, byte[] buffer) { if (className == null || !className.startsWith(PREFIX)) return null; if (className.contains("$")) return null; // 跳过内部类,先跑通再说 try { ClassReader cr = new ClassReader(buffer); ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); ClassVisitor cv = new ClassVisitor(Opcodes.ASM9, cw) { @Override public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] ex) { MethodVisitor mv = super.visitMethod(access, name, desc, sig, ex); if (name.startsWith("<")) return mv; if ((access & Opcodes.ACC_ABSTRACT) != 0 || (access & Opcodes.ACC_NATIVE) != 0) return mv; return new AdviceAdapter(Opcodes.ASM9, mv, access, name, desc) { int startVar; @Override protected void onMethodEnter() { startVar = newLocal(Type.LONG_TYPE); invokeStatic(Type.getType(System.class), Method.getMethod("long nanoTime()")); storeLocal(startVar); } @Override protected void onMethodExit(int opcode) { if (opcode == Opcodes.ATHROW) return; invokeStatic(Type.getType(System.class), Method.getMethod("long nanoTime()")); loadLocal(startVar); math(SUB, Type.LONG_TYPE); // 差值压栈,调用静态记录方法(记录方法只用 JDK 类) dup2(); push(THRESHOLD_NS); lcmp(); Label skip = new Label(); jump(IFEQ, skip); // <= 阈值直接跳过 // ... 记录逻辑 mark(skip); } }; } }; cr.accept(cv, ClassReader.EXPAND_FRAMES); return cw.toByteArray(); } catch (Throwable t) { System.err.println("[timing-agent] transform fail: " + className + " " + t); return null; // 失败就用原始字节码,业务不能挂 } } }这段代码里有几个关键决策值得解释。第一,return null用在两类情况:类名不匹配、以及 transform 自身异常。前者是性能优化(JVM 拿到 null 就不做校验和替换,省掉一次 verify),后者是安全兜底。第二,COMPUTE_MAXS而不是COMPUTE_FRAMES,理由前面说过,这里插入的代码不改变控制流。第三,ATHROW分支直接返回,因为异常路径上统计耗时的意义不大,还容易把异常对象搞丢。
真正写记录逻辑的时候,千万不要在这里 new 对象或者调业务类。我最初的做法是在onMethodExit里invokeStatic一个日志类的方法,结果在某个自定义加载器的服务上直接NoClassDefFoundError。后来改成全部用 JDK 类,把结果丢进一个ConcurrentHashMap<String, LongAdder>,再由一个独立线程每秒刷一次日志。加锁和 IO 绝对不能放在被增强的方法里,否则你统计的耗时本身就包含统计的开销。
4.3 运行时 attach 的三个必要动作
agentmain比premain多三步操作,少一步就白干:
public static void agentmain(String args, Instrumentation inst) throws Exception { // 1. 把 agent jar 追加到系统类加载器,否则后续类加载看不见 Path selfJar = Paths.get(TimingAgent.class.getProtectionDomain() .getCodeSource().getLocation().toURI()); inst.appendToSystemClassLoaderSearch(new JarFile(selfJar.toFile())); // 2. 注册 transformer,第二个参数必须 true inst.addTransformer(new TimingTransformer(), true); // 3. 主动触发已加载类的重新转换 for (Class<?> clazz : inst.getAllLoadedClasses()) { if (clazz.getName().startsWith("com.example.service") && inst.isModifiableClass(clazz)) { try { inst.retransformClasses(clazz); } catch (Throwable t) { System.err.println("[timing-agent] retransform fail: " + clazz.getName()); } } } }getAllLoadedClasses返回的是 JVM 里所有已加载类,动辄几万个,必须用前缀快速过滤。isModifiableClass的判断也不能省——数组类、基本类型、Lambda 生成的Synthetic类都不可修改,直接 retransform 会抛异常。
至于 attach 的发起端,需要一个独立的 main 程序:
VirtualMachine vm = VirtualMachine.attach(String.valueOf(pid)); vm.loadAgent(agentJarPath, "threshold=50"); vm.detach();JDK 9 之后有个默认限制:JVM 不允许 attach 到自己。同一进程内调用VirtualMachine.attach会抛IOException: Can not attach to current VM。要么加启动参数-Djdk.attach.allowAttachSelf=true,要么老老实实起一个外部进程来 attach。我在生产上用后者,因为改启动参数意味着又要重启一次。
4.4 怎么确认它真的生效了
别急着看业务日志,先确认三件事,顺序不能乱:
第一,-javaagent参数有没有被识别。启动时加上-verbose:class,grep 一下你的 agent 类名,能看到它被加载就说明参数对了。
第二,transformer 有没有被调用。在transform方法入口打一行System.err.println,启动后立刻能看到大量输出。如果一行都没有,问题一定在 manifest 或者参数上。
第三,字节码到底改了没有。JVM 没有现成命令能 dump 出运行中的字节码,可以用jcmd <pid> VM.class_hierarchy先确认类加载了,再用javap -c -p看你本地编译出来的目标类做对照。更直接的办法是在transform里把cw.toByteArray()写到临时文件,用javap反编译看一眼,检查插入的nanoTime指令在不在。
5. 一次 agent 不生效的完整排查链路
前面讲的都是“正常路径”,但真实工作里八成时间花在“它为什么不动”。我把最近一次排查的完整链路记下来,这个顺序是从成本最低到成本最高排的,照着走能少走很多弯路。
5.1 第一层:先怀疑打包,别怀疑代码
现象是:加了-javaagent参数,服务正常启动,业务日志里一行统计都没有,也没有任何报错。
第一步动作不是看代码,是unzip -p agent.jar META-INF/MANIFEST.MF。当时输出的内容里,Premain-Class那一行确实在,但后面紧跟着一个Created-By: Maven JAR Plugin,而 jar 里的类路径是BOOT-INF/classes/com/example/agent/TimingAgent.class——这是个 Spring Boot 的可执行 jar 结构,agent 类被塞进了BOOT-INF目录下,JVM 用标准类加载器根本找不到。
根因是打包插件用错了。业务项目用的是 Spring Boot 的spring-boot-maven-plugin,我顺手把 agent 模块也套用了一套配置,结果打出来的 jar 是“可执行 jar”,不是“普通 jar”。agent 必须是普通 jar,类在根目录下。改成 shade 插件之后,问题消失。
这一类问题的变体特别多:manifest 写在了maven-jar-plugin但被 shade 覆盖、Premain-Class写成了点号分隔的全限定名(这个是对的)但写成了斜杠、jar 里有多份 MANIFEST.MF。打包问题占了我遇到的 agent 故障的一半以上,所以永远先查这里。
5.2 第二层:transformer 被调用了,但目标类没进来
第二类现象:transform里打的日志刷屏了,几千行,但过滤一下发现全是框架类,com/example/service开头的一行都没有。
这时候要看的是类名的实际格式。我在一次排查中发现,业务代码在 Spring 环境下被 CGLIB 代理了,实际加载的类名是com.example.service.OrderServiceImpl$$EnhancerBySpringCGLIB$$a1b2c3。我的判断逻辑写的是className.startsWith("com/example/service/"),斜杠没问题,但$$后面的部分让整个字符串仍然以这个前缀开头,所以理论上能匹配上……问题出在下一行if (className.contains("$")) return null;,我把内部类的跳过错杀成了代理类跳过,把所有 CGLIB 代理全过滤掉了。
这个坑的教训是:$的判断要更精确,只跳过真正的匿名内部类的数字后缀,或者干脆不跳过,让 ASM 自己处理。代理类的增强本身是有效的,只是耗时数据会算两遍(代理一次、原方法一次),需要根据场景决定要不要按类名去重。
5.3 第三层:字节码改了,行为没变
第三类最迷惑:日志显示目标类确实被 transform 了,cw.toByteArray()的长度和原始 buffer 也不一样,说明字节码真的改了,但业务侧没有任何输出。
这种情况我遇到两次,根因完全不同。第一次是类被加载了两次。同一个类名被两个不同的类加载器各加载了一份,我只增强了其中一份,业务实际跑的是另一份。判断方法是打印loader参数,看是不是同一个实例。解决方式是放宽匹配条件,不要按加载器过滤,所有加载器加载的同名类都增强。
第二次是增强的方法根本没被调用。我统计的是OrderService.createOrder,但业务走的实际是OrderService.batchCreateOrder,前者是个空壳转发。这种错误很低级,但排查起来很费时间,因为字节码完全正确。后来我养成了一个习惯:在visitMethod里把匹配到的方法名全部打出来,先确认“我改的方法是不是业务真正调用的那个”。
还有一个隐藏变体:方法被final修饰、或者调用方用了invokespecial(比如super.xxx()),这种情况下 JVM 可能做内联优化,你的插桩代码在 JIT 之后被优化掉了。判断方法是加-XX:-Inline关掉内联看行为是否恢复,或者用-XX:CompileCommand=dontinline,com/example/service/OrderService::createOrder精确排除。生产环境不会关内联,但排查阶段这是有效手段。
5.4 第四层:类加载器隔离导致的 NoClassDefFoundError
这个坑我在第 2.3 节提过原理,这里说具体表现。增强后的业务类在运行到插桩代码时,抛了一堆NoClassDefFoundError: com/example/agent/TimingRecorder,但这个类明明在 agent jar 里。
根因是业务类由一个自定义类加载器加载(当时是某个中间件自带的),这个加载器的父加载器链里没有 agent jar。修复方式就是前面说的两种:要么把插桩逻辑改成纯 JDK 调用,要么调appendToBootstrapClassLoaderSearch。我最终选了前者,因为那个中间件的加载器实现有定制逻辑,appendToSystemClassLoaderSearch都不一定能生效,而 bootstrap 是最保险的。
顺带说一个验证技巧:在transform里对目标类打印loader.getClass().getName(),这样能直接看到是哪个加载器加载的,比猜快得多。
6. 生产环境的边界感:开销、灰度和怎么退出来
技术能跑通是一回事,敢不敢挂到生产上是另一回事。我给自己定了几条硬性约束,都是在吃过亏之后加上去的。
6.1 增强代码本身的开销必须可测量
方法级插桩的开销主要来自三块:System.nanoTime()两次调用(约 20-30 纳秒)、一次ConcurrentHashMap查找加LongAdder累加(约 50-100 纳秒)、以及每个方法多出来的局部变量和栈帧空间。单次看微不足道,但在 QPS 高的核心接口上,一次调用可能涉及几十个方法,累积起来就是可观的数字。
我做过的对比是:一个 QPS 约 8000 的查询接口,加上全量方法耗时统计后,P99 从 12ms 涨到 15ms 左右,涨幅 25%。这个代价在排查问题时完全值得,但长期挂着绝对不能接受。所以我的 agent 里有一条硬规则:只统计超过阈值的方法,低于阈值的数据一律不记录,把开销压到“两次 nanoTime + 一次比较”这个量级。
另一条规则是不要在插桩代码里做字符串拼接。哪怕日志不打出来,"method=" + name这种表达式也会在每次调用时创建 StringBuilder,触发大量年轻代分配。要么用String.format的惰性形式,要么只在超阈值时才拼。
6.2 灰度上线与优雅退出
agent 的灰度比业务代码简单得多,因为它是按实例生效的。我的做法是:先在一台实例上加-javaagent重启,观察 30 分钟,看 CPU、GC 频率、P99 有没有异常波动。没问题再扩到整个集群的 10%,再全量。
运行时 attach 的灰度就更灵活了,直接对单台实例 attach,抓 5 分钟数据,然后“退出来”。这里的“退出来”有个坑:agent 一旦被 attach,就没法真正卸载。retransformClasses把增强代码写进了方法体,除非你再 retransform 一次把原始字节码换回去,否则它会一直生效到进程重启。
想干净地退出,需要在 agent 里保留原始字节码的引用,或者重新注册一个“擦除型” transformer,让它返回原始字节码并触发 retransform。我用的后者:
// 擦除:注册一个返回原始字节的 transformer,然后 retransform inst.addTransformer(new ClassFileTransformer() { public byte[] transform(ClassLoader l, String name, Class<?> c, ProtectionDomain pd, byte[] buffer) { return buffer; // 返回原始字节,覆盖之前的增强 } }, true); for (Class<?> clazz : targets) inst.retransformClasses(clazz);注意retransformClasses的输入始终是原始字节码,所以这个“擦除”transformer 拿到的buffer就是原始版本,返回它等于把增强完全撤销。这个机制设计得其实挺优雅。
最后再分享一个实际用下来最省事的习惯:给 agent 加一个“开关参数”,比如-javaagent:agent.jar=enabled=false。发版前把开关关掉挂上去,出问题时用 attach 打开,不用改启动参数不用重启。这个模式我用了大半年,比任何事后的排查手段都管用。