做 Java 字节码增强,Byte Buddy 基本是绕不开的选择。而在我用 Byte Buddy 做方法拦截的这一年多里,@Origin是出场率最高的注解,几乎没有之一。这个注解看起来简单——往拦截器方法参数上一放,运行时就能拿到当前被拦截方法的Method对象——但真要把它的注入语义、绑定时机、性能开销搞明白,坑其实不少。这篇文章我不打算做 API 手册式的罗列,而是从一次真实的线上拦截需求出发,把@Origin的注入类型、底层生成逻辑、性能优化路径,以及我踩过的几个坑完整讲一遍。文章里的示例以 Byte Buddy 1.16 这条版本线为基准,如果你是刚开始用 Byte Buddy 做动态代理、埋点、限流这类功能,照着例子走能少走很多弯路。
1. 先从"拦截一个方法时最需要什么"说起
1.1 拦截器里最刚需的三种信息
任何方法拦截场景,说白了都绕不开三件事:这是哪个方法、参数是什么、原来的逻辑还要不要跑。第一件事恰恰最容易被忽略。很多人刚接触 Byte Buddy 时,会把拦截器写成"一个方法管所有被拦截方法",结果进到拦截器里发现不知道该记哪个方法名,只能把@This的类名打出来,再手动拼当前线程栈——这种做法既笨又慢。
我最早做压测埋点时就吃过这个亏。当时的业务场景是一个订单复核服务,十几个接口都要记录方法名、参数个数、耗时,还要按方法名打不同的监控 tag。一开始我用@This拿对象、用@AllArguments拿参数,方法名则是每个接口单独写一个拦截器,复制粘贴了十几份几乎一样的代码。后来换到@Origin,一个拦截器通吃所有方法,代码量直接砍掉一大半。
@Origin解决的就是"运行时知道我是谁"这个问题。它把当前被拦截方法本身的元信息,以参数形式注入到你的拦截器方法里。这个信息不是从线程栈现场猜的,也不是你手动传的,而是 Byte Buddy 在生成字节码时精确绑定的——这就是它和"自己按方法名 if else"这类土办法的本质区别。
1.2 为什么偏要选 Byte Buddy 承接这个需求
Java 里做方法拦截,市面上的方案其实不少:JDK 自带Proxy、CGLIB、ASM、Javassist,再往上还有各种 AOP 框架。但Proxy只能拦接口,CGLIB 维护状态早已不如当年,JDK 17 之后模块化环境下还经常碰一鼻子灰;Javassist 的字符串拼字节码方式写起来很爽,一旦逻辑复杂,调试和排错就是噩梦;ASM 虽强,但让业务开发直接写MethodVisitor的体验,大概等于让普通人用汇编写一个管理系统。
Byte Buddy 的价值在于,它把字节码生成的复杂度封装成了流式 API,同时又保留了底层控制力。1.16 这条版本线目前维护的基线是 Java 8,往上到最近的 LTS 和当前新版本 JDK 都有对应的适配处理,用AgentBuilder走 Java Agent 路线时,对模块系统和新版反射访问限制的处理也比较成熟。这也是我在新项目里直接定 Byte Buddy 而不是回头拥抱 CGLIB 的原因——新 JDK 下的兼容成本,是选型时必须算进去的隐性开支。
1.3 @Origin 在 MethodDelegation 绑定体系里的位置
Byte Buddy 的方法拦截核心是MethodDelegation,它有一整套参数绑定注解:@This注入被拦截方法的调用者、@SuperCall注入调用原方法的能力、@AllArguments注入参数数组、@Argument注入指定下标的参数、@Pipe做转发,还有本文的主角@Origin。
这一整套注解里,@Origin是唯一一个直接描述"被拦截方法本身"的入口。定位:它不是用来执行原逻辑的,也不是用来拿参数值的,而是给拦截器一个"方法指纹"。有了这个指纹,你才能在拦截器里做方法名匹配、权限判断、反射调用、注解读取这些事。可以把它理解为快递包裹上的运单号——它不替你搬货,但让你知道这一单到底是什么、从哪里来、该往哪里去。
2. @Origin 能注入的五种数据类型:别再用 Method 一份通吃
2.1 五种类型的横向对比
@Origin最容易被低估的一点,是它远不止能注Method。根据参数类型不同,它会注入完全不同的对象形态。用一张表先看全貌:
| 参数类型 | 注入内容 | 默认 cache | 典型使用场景 |
|---|---|---|---|
java.lang.reflect.Method | 被拦截方法的反射对象 | true | 日志埋点、鉴权、反射调用 |
java.lang.reflect.Constructor | 被拦截构造器的反射对象 | true | 构造过程埋点、实例计数 |
java.lang.reflect.Executable | Method 和 Constructor 的公共父类型 | true | 一套逻辑同时处理方法和构造器 |
java.lang.invoke.MethodHandle | 可直接 invoke 的方法句柄 | true | 需要高频访问原方法的场景 |
String | 方法描述字符串 | true | 日志上报、链路 tag、监控维度 |
这个表里真正让我改变习惯的是最后一行。以前我总觉得拿Method最"正统",直到有一次做流量治理,日志量一天几十亿条,把method.getName()拼进日志里的开销虽然不夸张,但仔细 profile 之后发现,占 CPU 的其实不是那个方法调用,而是后面一连串基于Method的判断逻辑。
2.2 Executable:让方法和构造器共用一套拦截逻辑
Executable是 Java 8 引入的Method和Constructor的公共父类。如果同一套拦截逻辑既想管普通方法,又想在构造器上生效,把@Origin参数声明成Executable是最省事的路子。举个例子,我做对象生命周期审计时,希望"任何新对象创建"都打点,同时"任何关键业务方法被调用"也打点,这时候拦截器方法可以写成:
public class LifecycleInspector { public static void inspect(@Origin Executable executable, @AllArguments Object[] args) { System.out.println("[lifecycle] " + executable + " args=" + Arrays.toString(args)); } }然后分别用.method(...)和.constructor(...)指到同一个拦截器上,代码复用率直接拉满。
这里有个细节:Executable虽然能做统一入口,但如果你后续要区分"这是构造器还是方法",记得executable instanceof Method这类判断。反射模型里Executable并没有一个isMethod()方法,需要靠instanceof或者判断getParameterCount()加上getDeclaringClass()之类的组合手段。
2.3 MethodHandle:比 Method.invoke 更现代的反射消费方式
MethodHandle是很多人没用过、但性能敏感场景非常值得的类型。当@Origin参数声明为MethodHandle时,Byte Buddy 会把你被拦截方法包装成一个句柄,你可以直接调用。MethodHandle相比Method.invoke的优势,在于它走的是 invokedynamic 体系的快速路径,省掉了反射调用里大量的参数装箱、访问检查、异常包装开销。
不过要注意,MethodHandle注入不是免费的。它会受到访问控制的影响:如果被拦截方法是私有的,或者声明类比较敏感,实际绑定阶段有可能失败或需要额外处理。我在一个工具类里拦过私有辅助方法,@Origin Method一切正常,换成@Origin MethodHandle直接启动异常,报错信息指向方法句柄的访问限制。后来查了下版本对应的文档,才确认这是访问语义差异导致的。所以我的经验是:真正需要高频反射调用原方法时选 MethodHandle,普通日志埋点老老实实用 Method 或 String 就行。
2.4 String 与 namingStrategy:日志上报的最优解
@Origin参数类型为String时,注入的是一个方法描述字符串。这个字符串的具体格式由namingStrategy属性控制,Origin.NamingStrategy枚举里提供了几种策略,默认策略生成的是人可读的方法签名描述。实际打印出来类似于"类名.方法名(参数类型)"这种可读性很强的文本。
之所以说它是日志上报的最优解,是因为字符串在 Byte Buddy 绑定阶段就已经确定,且默认cache = true时会被当作常量处理,运行时就是一条 LDC 指令,成本非常低。对比Method类型,String 类型还天然规避了后续"拿着 Method 做各种反射判断"的隐形成本。我做监控 tag 的方法名维度时,现在几乎统一用@Origin String。
2.5 cache 到底在缓存什么
@Origin注解上有个容易被跳过的属性:cache,默认是true。它的含义是:注入的这个对象,要不要在类型生成阶段就解析好,存到生成的类里,而不是每次拦截调用时才现算。
cache = true时,Byte Buddy 会在动态生成的类里放一个静态字段或者直接在常量池里存常量,每次拦截方法执行,只需要把这个字段/常量取出来用,毫秒级甚至微秒级开销都可以忽略。cache = false时,Byte Buddy 生成的方法体里会嵌入一段每次调用都执行的解析逻辑,相当于每次拦截都重新做一次"按方法名和签名找反射对象"的动作。后者在性能上是灾难级的差距,但也不是毫无用处——它可以避免生成的类持有对目标类元数据的强引用,在热部署、插件动态卸载这类场景里能降低类加载器泄漏风险。
一句话:默认 cache 别动,除非你明确知道自己在处理类卸载问题。
3. 一个能跑起来的拦截器示例:从依赖到 javap 验证
3.1 引入依赖
先建一个普通 Maven 工程,引入两个依赖:
<dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy</artifactId> <version>1.16.2</version> </dependency> <dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy-agent</artifactId> <version>1.16.2</version> </dependency>byte-buddy是核心库,做运行时生成子类用它就够;byte-buddy-agent是 Java Agent 相关的模块,如果你后面打算做线上无侵入增强,迟早会用到。
3.2 完成一次带参数透传的拦截
我以一个最简单的订单服务为例。目标类:
public class OrderService { public String createOrder(String userId, int amount) { return "ORDER-" + userId + "-" + amount; } public void cancelOrder(String orderId) { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }拦截器里同时用@Origin和@SuperCall,既能拿到方法信息,又能保持原业务逻辑继续执行:
import net.bytebuddy.implementation.bind.annotation.AllArguments; import net.bytebuddy.implementation.bind.annotation.Origin; import net.bytebuddy.implementation.bind.annotation.SuperCall; import java.lang.reflect.Method; import java.util.Arrays; import java.util.concurrent.Callable; public class OriginInterceptor { public static Object trace(@Origin Method method, @AllArguments Object[] args, @SuperCall Callable<?> zuper) throws Exception { long start = System.nanoTime(); Object result = zuper.call(); long cost = System.nanoTime() - start; System.out.println("[trace] " + method.getName() + " args=" + Arrays.toString(args) + " cost=" + cost + "ns"); return result; } }然后在启动逻辑里生成增强后的子类:
import net.bytebuddy.ByteBuddy; import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; import net.bytebuddy.implementation.MethodDelegation; import net.bytebuddy.matcher.ElementMatchers; public class OriginLaunch { public static void main(String[] args) throws Exception { Class<? extends OrderService> dynamicType = new ByteBuddy() .subclass(OrderService.class) .method(ElementMatchers.isDeclaredBy(OrderService.class)) .intercept(MethodDelegation.to(OriginInterceptor.class)) .make() .load(OrderService.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) .getLoaded(); OrderService service = dynamicType.getDeclaredConstructor().newInstance(); System.out.println(service.createOrder("u001", 10)); service.cancelOrder("c001"); } }运行后控制台会打出两条 trace 日志,createOrder还会正常返回订单号。这里ElementMatchers.isDeclaredBy(OrderService.class)限定了只拦OrderService自己声明的方法,不拦从Object继承过来的toString、hashCode这些,避免干扰。
3.3 把动态类型落盘,用 javap 看注入形态
学字节码增强,最忌讳"只看到黑盒结果"。Byte Buddy 好就好在能直接把生成的类 dump 出来。在.load(...)之前,先把make()的结果写到文件:
DynamicType.Unloaded<?> unloaded = new ByteBuddy() .subclass(OrderService.class) .method(ElementMatchers.isDeclaredBy(OrderService.class)) .intercept(MethodDelegation.to(OriginInterceptor.class)) .make(); Files.write(Paths.get("OrderServiceDynamic.class"), unloaded.getBytes());然后用javap -c -p OrderServiceDynamic查看生成的增强方法。你会看到这样的结构:生成的类里多了一个静态字段(就是用来缓存Method的),createOrder方法体大致是:先把方法字段取出来,把参数数组整理好,构造Callable包装,然后INVOKESTATIC调OriginInterceptor.trace,最后把返回值做类型转换后返回。
这一步亲眼看到"方法字段是静态存的、参数是打包传的",你对cache = true为什么快就有体感了。它就是一次静态字段读取,加上一次静态方法调用,中间没有任何反射查找。
3.4 顺带处理构造器场景
如果你想验证@Origin对构造器生效,把拦截器参数改成Constructor:
public class ConstructorInterceptor { public static void onConstruct(@Origin Constructor<?> constructor, @AllArguments Object[] args) { System.out.println("[ctor] " + constructor.getName() + " args=" + Arrays.toString(args)); } }绑定方式换成.constructor(ElementMatchers.isConstructor())。不过我要提醒一句:构造器拦截的语义和普通方法不一样,@SuperCall在构造器场景下并不能像普通方法那样"包一层再调用原逻辑",所以生产环境里我处理构造器埋点,更多是直接用Advice而不是MethodDelegation。这个区别很容易踩,后面踩坑部分再细说。
4. 绑定阶段发生了什么:一个注解参数是怎么变成真实参数的
4.1 MethodDelegation 的绑定流程
很多人以为@Origin是运行时反射"猜"出来的,其实不是。Byte Buddy 在类型生成阶段就把一切定死了。流程大致是这样:
- 你在
.intercept(MethodDelegation.to(OriginInterceptor.class))里指定了拦截器类。 - Byte Buddy 扫描拦截器类的所有方法,排除构造器、静态初始化器等,把候选方法一个个拉出来做"参数绑定可行性检查"。
- 对每个候选方法的每个参数,Byte Buddy 判断有没有对应注解;
@Origin注解会交给专门的绑定器处理,绑定器先看参数类型是不是那五种合法类型,不合法直接抛异常。 - 参数全部可绑定,且返回类型和拦截目标方法兼容,这个方法才真正"被选中"。
- 如果多个方法都满足,默认取"看起来最合适"的,你也可以用
@BindingPriority来手动指定优先级。
所以你在动态类型里看到的"static 字段存 Method + INVOKESTATIC 调拦截器",是第 5 步绑定完成后,Byte Buddy 把绑定方案翻译成了字节码指令写进去的。整个过程发生在你的应用启动、执行.make()或.load()的时候,跟运行时每一次方法调用没有关系。
4.2 三种注入形态的字节码差异
同样是@Origin,不同参数类型和cache选项,生成的字节码完全不同:
| 组合 | 运行时行为 | 本质成本 |
|---|---|---|
| String + cache=true | 常量池里放字符串常量,方法体 LDC 取用 | 一条常量加载指令 |
| Method + cache=true | 生成类里放静态字段,方法体 GETSTATIC 取用 | 一次静态字段读取 |
| Method + cache=false | 方法体里嵌入解析逻辑,每次调用重新取反射对象 | 每次方法调用做一次基于签名的查找 |
| MethodHandle + cache=true | 常量池或合成方法提供句柄,方法体直接取用 | 一次加载 + 后续 invoke 的分派开销 |
这里有个值得多说一句的细节:Method + cache=false并不是简单地调Class.getDeclaredMethod,Byte Buddy 生成的是一段"按方法签名重建反射对象"的定制逻辑。问题在于,这个逻辑无论如何都要付出类加载检查、签名比对等开销,所以它在循环热路径上非常伤。我实际压测时,同一段拦截逻辑,cache = false的耗时大约是cache = true的 20 到 40 倍。这不是 Byte Buddy 实现得差,而是每次实时解析反射对象这件事本身就这么贵。
4.3 绑定失败与拦截器方法淘汰机制
绑定失败是新手最容易遇到、又最难看懂的报错。常见的姿势是把@Origin参数类型写成Object,或者把@SuperCall参数类型写成自定义的一个Callable子接口,结果启动时直接抛类似 "Cannot resolve the binding of parameter" 的异常。
原因就在 4.1 的第二步:参数类型不合法,绑定器判定该方法"不可绑定",而如果所有候选方法都不可绑定,Byte Buddy 就没法生成委托逻辑。解决方案有两个:要么改正参数类型,要么在拦截器里多写一个"兜底方法"。比如你拦截的方法既有返回String的又有返回int的,拦截器最好提供一个返回Object的方法做通用兜底,再提供一个专门处理返回值的重载,让 Byte Buddy 去按优先级选择。
5. 性能成本拆解与三种优化路径
5.1 开销其实发生在三个阶段
讨论@Origin的性能,不能只盯着拦截方法本身。一次完整的 Byte Buddy 增强,开销分布在三段:
- 建型阶段:
make()生成DynamicType。这里要解析目标类结构、匹配方法、做绑定决策。属于一次性成本,但如果你在请求路径上反复.make(),那同样会爆炸。正确姿势是启动时生成一次,缓存生成的类。 - 类加载阶段:
load()把生成的字节码定义成 Class。这段成本取决于ClassLoadingStrategy的选择。WRAPPER会新建一个类加载器,隔离性好但开销大一点;INJECTION往现有加载器里注入,启动快但要注意类名冲突。我的经验是:框架内部生成的内部类,用INJECTION配合固定类名前缀;需要隔离的插件类,再退回WRAPPER。 - 调用阶段:就是每一次拦截方法执行的额外开销,也就是
@Origin注入形态直接决定的那些指令成本。
建型和加载都是一次性的,真正十亿次累计的是调用阶段,这也是为什么社区聊@Origin性能时,焦点永远在 cache、在注入类型、在要不要用 Advice。
5.2 cache 开关与注入类型选择的实测思路
我自己的验证方法是写一个简单的 JMH 基准,对比四种组合:String cache=true、Method cache=true、Method cache=false、MethodHandle cache=true。拦截器里只做一件事:读@Origin参数,拼一个字符串。在这个基准下,结论大体稳定:
String cache=true最快,因为常量加载几乎不占时间。Method cache=true和它非常接近,静态字段读取的差距在一个数量级以内。MethodHandle cache=true在"只读不调用"的场景比前两者略慢,因为得到句柄后你总要做点事,句柄本身的构造和验证成本在短循环里会被放大。Method cache=false显著慢,慢到日常压测根本不需要统计显著性的程度。
如果你的拦截器逻辑本身就是"拿到 Method 后还要method.invoke原方法",那结论又会反转:MethodHandle在手,用invokeExact走快速路径,比Method.invoke快不少。这也是我一直强调的——没有放之四海而皆准的最佳注入类型,只有匹配你拦截器主体逻辑的选择。
5.3 Advice 内联:比上面所有方案都快的另一条路
MethodDelegation再怎么优化,本质是"生成一个委托方法,调用你的拦截器方法"。不管拦截器是静态的还是实例的,多一次方法调用,多一份栈帧。真正热路径上的埋点,我建议直接上Advice。
Advice的思路是"把模板方法的指令复制进目标方法",属于内联织入。它同样支持类似的 Origin 注入,可以把方法签名以字符串形式直接织进目标方法体里,连"拦截器方法调用"这一层都省了。比如线上的秒杀接口,每个请求都要打一个包含方法名的 trace tag,这是典型的"热路径 + 信息量低"场景,用Advice明显比MethodDelegation合适。
不过Advice的代价是开发体验差一些:模板方法里不能随便依赖实例状态,局部变量的生命周期要理解@Enter/@Exit/@Thrown这些语义,排错难度高于MethodDelegation。我的原则是:能用MethodDelegation讲清楚的业务逻辑,别为了炫技上Advice;真正 profile 出热点后再局部替换。
5.4 与 Spring 事务注解共存时的性能与语义注意点
很多项目是在 Spring 容器里用 Byte Buddy 的,这时候@Transactional这类事务注解怎么和增强后的方法相处,是绕不开的问题。先说性能:Byte Buddy 生成的子类方法如果走@SuperCall调原逻辑,事务边界仍然由 Spring 的代理负责,一般在语义上不会额外拖慢太多。真正要命的是语义问题——事务注解在生成的子类方法上到底还在不在,这直接决定事务切面还能不能识别。这一点我放到踩坑章节详细讲,因为它是"class 文件 overrider 注解为什么会丢失"这句话背后的真实场景。
6. 实战踩坑记录:注解丢失、重载误配与泛型擦除
6.1 overrider 注解为什么会丢:这是字节码层的必然
先直接回答那个高频问题:class 文件里 override 方法上的注解为什么会丢失?因为 JVM 规范里,注解是方法属性的一部分,子类 override 父类方法时,并不会自动继承父类方法上的注解。Byte Buddy 生成子类 override 方法时,默认也不会把父方法注解抄过来——它只负责生成一个语义上等价的 override,注解属性那一块是空的。
这个现象对不同消费方的影响完全不同。Spring 的注解查找工具会沿着方法层级往上翻父类方法,所以@Transactional这类注解通常还能被发现;但很多自研框架、字节码扫描器是直接对"子类方法"本身做getAnnotations(),这时拿到的就是空数组,注解就是这么"丢"的。
如果你的下游依赖注解识别,必须在生成时显式把注解补回去。Byte Buddy 提供了annotateMethod:
new ByteBuddy() .subclass(OrderService.class) .method(ElementMatchers.isDeclaredBy(OrderService.class)) .intercept(MethodDelegation.to(OriginInterceptor.class)) .annotateMethod( AnnotationDescription.Builder .ofType(MyTrace.class) .define("value", "order") .build() )顺带说一个更巧的招:很多时候你根本不用抄注解,直接在拦截器里用@Origin Method method,然后method.getAnnotations()拿到的就是原始方法上的注解。因为@Origin注入的是原始方法的反射对象,原始方法上有什么注解,这里就有什么注解。用这个思路,很多"注解丢失"问题都变成了"绕开子类方法,回到原始方法读注解"的问题。
6.2 final/private/static:哪些方法其实根本拦不到
用子类方式做增强,有几个边界是物理层面的:final方法不能 override,private方法不能 override,static方法也不参与 override。这不是 Byte Buddy 的限制,是 JVM 的限制。你在.method(...)匹配里即使写中了它们,运行时也不会走到你拦截器里,因为压根没有生成 override 方法。
这时候只有两个选择:一是接受现实,把匹配器里过滤掉这些方法,避免自己误以为"拦到"了;二是上 Java Agent,用AgentBuilder配合Advice直接改目标类的字节码,从根本上绕开子类 override 的限制。注意,走 Agent 路线时@Origin的使用方式和子类方式不同,但注入的方法元信息一样完整,甚至因为直接改原始类,注解"丢失"的问题天然不存在。
6.3 重载匹配不精确与类型转换异常
ElementMatchers匹配方法时,很容易写宽。比如isDeclaredBy(OrderService.class)会把createOrder(String, int)和createOrder(String)两个重载都拦住。每个重载会各自生成一个委托方法,每个委托方法里@Origin注入的都是对应重载的 Method,这本身没问题。
问题出在拦截器主体逻辑里。如果拦截器里默认参数是String,而某个重载的参数实际是int,用@AllArguments拿出来的数组元素类型和你预期不符,拆箱时就是ClassCastException。踩过这个坑后,我在拦截器里养成了一个习惯:先按method.getParameterTypes()判断签名,再决定怎么消费参数,而不是默认参数类型一致。
6.4 泛型信息在 @Origin 里拿不到,怎么办
@Origin注入的Method是运行时反射对象,而泛型类型在运行时已经被擦除。比如你拦截了一个List<String> process(List<Integer> data)方法,@Origin Method的getParameterTypes()拿到的是List.class,getGenericParameterTypes()才能拿到带类型参数的信息。
如果你的拦截逻辑真的需要区分List<String>和List<Integer>,靠@Origin的默认形态是不够的。两条路子:一是显式调用method.getGenericParameterTypes()或method.getAnnotatedParameterTypes(),从反射方法上取泛型信息,这条路我用下来最稳;二是在生成时用TypeDescription.Generic层面的匹配,把需要区分的泛型签名在绑定阶段就切分成不同的拦截器,让每个拦截器只处理一种泛型形态。前者适合"运行时动态判断",后者适合"编译期静态分工",按场景选。
6.5 构造器拦截别硬套 @SuperCall
最后一个坑,算是我给前面构造器场景的补刀。用MethodDelegation做构造器拦截时,@SuperCall的语义和普通方法完全不同,你不能指望它像普通方法那样"包一层后照常执行原构造逻辑"。构造器的执行要经过父类构造器调用链,字节码层面的限制比普通方法严格得多。
我的建议很直接:构造器埋点,用Advice的@OnMethodEnter/@OnMethodExit思路处理;普通方法这种"包一层"的诉求,再用MethodDelegation+@SuperCall。这两个工具各有各的舒适区,硬混在一起,往往就是线上诡异问题的来源。
最后再分享一个我实际操作中的体会:@Origin的默认形态在绝大多数情况下够用,真正决定你要不要换成MethodHandle、要不要开cache=false、要不要上Advice,不是看网上所谓的"性能对比",而是看你拦截器主体逻辑到底在做什么。先把你自己的热路径 profile 清楚,再回头选注入形态,比盲目追求"最快配置"靠谱得多。另外,Byte Buddy 生成的新类如果没指定.name(),类名会带ByteBuddy后缀,线上排查堆栈时极难认;我的习惯是生成时就给它一个可读的类名前缀,比如service$Proxy这种,一劳永逸。