你想在别人的对象上放点自己的东西,但你不能改它的源码、不能加它的字段、不能碰它的接口 ------ 于是 OpenTelemetry 选择在运行时,往它的字节码里缝一个真实的字段上去。
一、一个看似不可能的需求
假设你在写一个可观测性 Agent。现在有个再普通不过的场景:
用户发了一个 HTTP 请求,线程 A 处理它,中途 executor.submit(() -> callDatabase()) 把数据库调用丢给了线程池。你的 Agent 要在数据库调用外面包一个 Span ------ 但麻烦来了:Span 的 parent context 存在线程 A 的 ThreadLocal 里,而数据库调用跑在线程 B 上。
你得在提交任务的时候,把线程 A 的 context 存到某个地方;等任务在线程 B 上执行时,再取出来。存哪儿?
最自然的答案是"存到任务对象上"。但任务对象是个 Runnable ------ 它是 JDK 的类(或者说用户用 lambda 生成的匿名类),你既不能给它加字段,也不能改它的源码,更不能让它实现一个新接口。
换个思路:用一个 Map<Runnable, Context> 不就行了?
java
// 天真版实现
Map<Runnable, Context> contextMap = new WeakHashMap<>();
contextMap.put(task, Context.current()); // 提交时
Context ctx = contextMap.get(task); // 执行时
能用,但用过的人都知道这玩意有三宗罪:
- 不是无锁的 。
WeakHashMap需要同步,在争用场景下就是一条锁队列。换成ConcurrentHashMap也不行 ------ 你需要弱引用语义,而ConcurrentHashMap不支持用弱引用做 key。 - 每次
put都在制造垃圾 。每个 entry 都要包一个WeakReference和一个ReferenceQueue的记账对象。高频路径上(想想每个 HTTP 请求、每个线程池提交),GC 压力肉眼可见。 - 查找不是 O(1) 。你没看错 ------
WeakConcurrentMap用WeakReference的 hashCode 去查找,而 JVM 为存活对象分配的是标识哈希(identity hash),需要惰性生成并写回对象头。一次哈希计算、一次 CAS 写对象头、一次 map 查找,成本远超"读一个字段"。
这三条单独看都不致命,但乘上"每个请求都要走一次"的调用量,就成了压在延迟曲线上的实打实的分量。
问题的本质是:你想在别人的对象上附加数据,但 Java 语言层面没有给你"扩展字段"的能力。
于是 OpenTelemetry 换了一个思路 ------ 既然语言不给我,那我就用字节码给自己造一个。
二、别人是怎么解这个题的
在 OTel 之前和之外,各家 APM Agent 都在用自己的方式对付同一个问题。这一节我们把几个主流方案摆在一起看。
2.1 方案 A:WeakConcurrentMap ------ 最安全的"保守派"
Elastic APM 早期走的是这条路:用一个弱键的并发哈希表,把 context 挂到对象上。
java
// 概念示意:弱键 → 强值
WeakConcurrentMap<Object, Context> contextStore = new WeakConcurrentMap<>();
contextStore.put(request, context);
它的优点是完全无侵入 ------ 不需要改任何类的字节码,对目标对象零要求。缺点前面说过:哈希开销、弱引用分配、缓存局部性差。
值得一提的是,OTel 自己也保留了这个方案作为兜底 (后面会讲 Cache.weak()),因为有些类真的没法注入字段(已经加载了、或者被 CGLIB 之类的东西改过),这时候系统需要优雅降级。
但如果只把 Elastic APM 归为"保守派",对它的贡献就说少了。事实上,OTel Java Agent 当前使用的 InDy(invokedynamic)Advice 机制,正是 Elastic APM 团队首创并捐献给 OTel 社区的 (参见 Elastic 官方博文)。传统的 inline advice 会把 advice 字节码直接复制进目标类,带来类加载器污染和调试困难等问题;Elastic APM 率先提出用 invokedynamic 指令做间接派发 ------ advice 代码留在自己的 ClassLoader 里,目标类只植入一条 invokedynamic 调用指令。这个架构后来成了 OTel javaagent 的默认 advice 模式,也直接影响了 VirtualField 在 InDy 模式下的设计约束(后面第四节会讲到)。
换句话说,Elastic APM 对 OTel 的贡献是双重的 :WeakConcurrentMap 提供了兜底存储方案,InDy Advice 则重塑了整个 advice 派发架构。
2.2 方案 B:ThreadLocal ------ 简单,但只适合单线程
Pinpoint 大量依赖 ThreadLocal。思路很简单:把 context 绑在线程上,而不是对象上。
对于"请求进来、一路同步处理、返回"这种模型,ThreadLocal 完美 ------ 零哈希、零 GC、存取就是查当前线程的一个数组。
但它的边界也很清楚:一旦任务跨了线程,ThreadLocal 就丢了 。线程池里的工作线程永远看不到提交线程的 ThreadLocal。Pinpoint 的补救办法是给每个线程池实现都写一个 interceptor,手动把值"搬"过去。能用,但每多一种线程池(用户自研的、第三方库的),就要多写一个 interceptor,而且总会漏。
2.3 方案 C:EnhancedInstance ------ SkyWalking 的"加接口"流
SkyWalking 的方案很聪明:用字节码增强让目标类实现一个新接口 EnhancedInstance,然后把这个接口要求的字段和方法注入进去。
java
// SkyWalking 的概念模型
public interface EnhancedInstance {
Object getSkyWalkingDynamicField();
void setSkyWalkingDynamicField(Object value);
}
字节码增强后,HttpServletRequest 变成了"实现了 EnhancedInstance 的 HttpServletRequest",于是你可以:
java
((EnhancedInstance) request).setSkyWalkingDynamicField(context);
效果上跟 OTel 的 VirtualField 很接近 ------ 也是注入真实字段,也是 O(1) 访问。但 SkyWalking 的做法修改了目标类的接口列表,这带来两个副作用:
- 反射会漏 。
request.getClass().getInterfaces()现在会返回一个用户完全没见过的EnhancedInstance。某些框架会遍历接口做判断,看到不认识的就懵了。 - 序列化有风险。Jackson、Kryo 这类框架在处理对象时会看实现接口,多一个接口可能触发意外的多态处理或者报错。
2.4 方案 D:VarHandle / jdk.internal.misc ------ 想得太美
理论上 Java 9+ 的 VarHandle 可以给对象做"视图"访问,但你没法凭空给一个不存在的字段创建 VarHandle。至于 sun.misc.Unsafe 直接操作对象内存 ------ 那是拿命换性能,版本一升级就崩,而且没法处理"类已经有固定布局"的约束。
2.5 一张表看清楚
| 方案 | 附加方式 | 访问复杂度 | GC 开销 | 对目标类的侵入性 |
|---|---|---|---|---|
WeakConcurrentMap (Elastic APM) |
外部弱键 Map | O(1) 但含哈希+对象头写入 | 每次 put 产生弱引用 | 无 |
ThreadLocal (Pinpoint) |
线程绑定 | O(1) | 无 | 无(但跨线程失效) |
EnhancedInstance (SkyWalking) |
注入字段 + 修改接口列表 | O(1) 字段访问 | 无 | 高(接口可见) |
| VirtualField (OTel) | 注入字段 + 隐藏接口 | O(1) 字段访问 | 无 | 低(接口被过滤) |
注:Elastic APM 的贡献不止于
WeakConcurrentMap。OTel Java Agent 的 InDy Advice 派发架构也源自 Elastic APM 的首创设计(详见),它深刻影响了 VirtualField 在不同 advice 模式下的行为差异。
最后一行就是本文的主角。它想同时拿到"字段访问的速度"和"零侵入的干净" ------ 这两样东西本来是有矛盾的,OTel 用了一个巧妙的办法把矛盾化解了。
三、VirtualField 想做到什么
先把设计目标列清楚,后面所有实现细节都是为了满足这几条:
目标 1:API 要极简,开发者不该操心实现
写 instrumentation 的人不该知道"字段注入"这回事。他应该能这么写:
java
// 声明:我要给 Runnable 加一个 PropagatedContext 类型的虚拟字段
VirtualField<Runnable, PropagatedContext> FIELD =
VirtualField.find(Runnable.class, PropagatedContext.class);
FIELD.set(task, propagatedContext); // 设置
PropagatedContext ctx = FIELD.get(task); // 获取
就这三个 API:find、get、set ------ 没有"注册"、没有"初始化"、没有"清理"。开发者写下的代码,在 library 模式和 javaagent 模式下完全一样,尽管底下的实现天差地别。
目标 2:快路径必须是"读一个字段"
WeakConcurrentMap 的那些开销,VirtualField 一个都不能有。理想情况下,get() 编译出来应该就是一条 GETFIELD 指令 ------ 因为字段真的就缝在那个对象上。
目标 3:语义要像弱键 Map(但更好的那种)
这是个容易被忽略但很重要的目标。字段跟着对象生、跟着对象死 ------ 对象被回收时,附加的数据自然也就没了 ,不需要任何 ReferenceQueue 或者清理线程。这比弱引用 Map 还干净,因为根本没有"Map entry 需要被清理"这回事。
同样的道理,字段是 transient 的,序列化时会被跳过。你在 HttpServletRequest 上挂的 Span context 不会因为你把请求对象序列化了就跟着跑出去。
目标 4:对目标类尽量隐形
这是 VirtualField 比 EnhancedInstance 高明的地方。回顾一下:SkyWalking 注入的接口是可见的 ,反射能看到、序列化框架能感知。VirtualField 注入的接口和字段全部标记为 synthetic ,然后 OTel 额外挂了一个 reflection instrumentation,在 Class.getDeclaredFields() / getMethods() / getInterfaces() 返回结果前把 synthetic 的那些悄悄过滤掉。
用户代码问"这个类有哪些字段",VirtualField 注入的那个字段不会出现在答案里。这才叫"缝口袋" ------ 口袋是缝在对象上的,但穿着衣服你看不见。
目标 5:必须有兜底
理想情况是每个类都能注入字段。但现实是:有些类已经被加载了(retransform 不一定成功)、有些类被 CGLIB/ByteBuddy 进一步增强过(它们复制方法但不一定复制字段)、有些类在 java.* 包里受模块系统保护。
这时候不能崩。自动退化到弱引用 Map ------ 慢一点,但功能完整。用户在绝大多数情况下甚至感觉不到。
四、原理:从声明到字段的四步生命线
现在进入正题。VirtualField 的完整链路横跨三个时间阶段 ------ 编译期、Agent 启动期、运行时 ------ 每一段都有专属的"演员"和"产物"。在拆解每一步之前,先建立一个全景视角。
全景:五个产物,三个阶段
VirtualField 的机制可以归结为五个关键产物在三个阶段中依次生成、相互衔接。理解了产物之间的流转关系,整个系统就清楚了。
css
┌──────────────────────────────────────────────────┐
编 译 期 │ 开发者写下: │
Muzzle Gradle │ VirtualField.find(Runnable.class, Context.class) │
Plugin │ │ │
│ │ 字节码扫描: 识别 LDC + LDC + INVOKESTATIC │
│ ▼ │
│ ┌───────────────────────────────┐ │
│ │ 产物 ❶ VirtualFieldMappings │ │
│ │ { Runnable → Context, ... } │ │
│ └──────────────┬────────────────┘ │
└─────────────────┼────────────────────────────────┘
│ 编译进 jar
┌─────────────────┼────────────────────────────────┐
Agent 启动期 │ ▼ │
│ 读取 ❶,对每对类型生成: │
│ │
│ ┌────────────────┐ ┌─────────────────────────┐│
│ │ 产物 ❷ │ │ 产物 ❸ ││
│ │ Accessor 接口 │ │ VirtualField 实现类 ││
│ │ __get__() → Obj │ │ 全局单例 INSTANCE ││
│ │ __set__(Obj) │◄──│ realGet 里有 ││
│ │ │ │ INSTANCEOF ❷ 判断 ││
│ └───────┬────────┘ └───────────┬─────────────┘│
│ │ 注入 Bootstrap CL │ │
│ ├─────────────────────────┘ │
│ │ │
│ ┌───────┴───────────────────────────────┐ │
│ │ 产物 ❹ 类型匹配器 │ │
│ │ "Runnable 的非抽象子类 → 触发字段注入" │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
│ 应用开始运行
┌─────────────────┼────────────────────────────────┐
运 行 时 │ ▼ │
(两条并行线) │ │
│ 时间线 A 时间线 B │
│ 目标类被加载时 advice 类被加载时 │
│ 匹配器 ❹ 命中 find() 被重写 │
│ │ │ │
│ ▼ ▼ │
│ RealFieldInjector 指向 ❸ 的 INSTANCE │
│ ├ 加接口 ❷ │ │
│ ├ 缝字段 │ │
│ └ 生成 getter/setter │ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ 产物 ❺ │ │ 调用方持有 │ │
│ │ 对象自带"口袋" │ │ ❸ 的引用 │ │
│ └──────┬───────┘ └───────┬─────────┘ │
│ └───────┐ ┌────────┘ │
│ ▼ ▼ │
│ ┌─────────────┐ │
│ │ 交 汇 点 │ │
│ │ INSTANCEOF ❷ │ │
│ │ ├ Yes → 字段直读 O(1) │
│ │ └ No → Map 兜底 │
│ └─────────────┘ │
└───────────────────────────────────────────────┘
五个产物的速查:
| # | 产物 | 生成阶段 | 被谁消费 | 形态 |
|---|---|---|---|---|
| ❶ | VirtualFieldMappings | 编译期 | 启动期生成器 | 编进 Module 类的方法里 |
| ❷ | Accessor 接口 | 启动期 | RealFieldInjector + realGet | 含 __get__/__set__,注入 Bootstrap CL |
| ❸ | VirtualField 实现类 | 启动期 | find() 调用方 |
全局单例,封装快慢两条路径 |
| ❹ | 类型匹配器 | 启动期 | ByteBuddy | "Runnable 的非抽象子类被加载 → 执行注入" |
| ❺ | 被注入的目标类 | 运行时 | ❸ 的 realGet/realPut | 自带字段 + 实现 ❷ 接口 |
下面逐段拆开。
第一步(编译期):从字节码里"偷看"开发者声明了什么
这一段的巧妙之处在于:开发者从来不需要"注册"虚拟字段 。他只是写了一句 VirtualField.find(Runnable.class, Context.class),编译器就顺藤摸瓜把这个声明抓了出来。
怎么抓的?关键在于 Java 编译器处理 class 字面量的方式。Runnable.class 在字节码里是一条 LDC(Load Constant)指令,直接引用常量池里的类型。所以 find(Runnable.class, Context.class) 编译出来的指令序列是固定模式的三条:
arduino
LDC Runnable.class ← 常量,可静态分析
LDC Context.class ← 常量,可静态分析
INVOKESTATIC VirtualField.find ← 方法调用
Muzzle 的 VirtualFieldCollectingMethodVisitor 就盯着这个模式:用一个容量为 2 的队列记住最近两条 LDC <class> 指令,一旦看到紧跟着的 INVOKESTATIC VirtualField.find,就从队列里取出两个类型,登记到映射表里。
这解释了 VirtualField 一个看起来"莫名其妙"的 API 约束:
VirtualField.find()的两个参数必须是 class 字面量(Runnable.class),不能是变量、不能是方法返回值。
如果你写 VirtualField.find(someClassVariable, Context.class),字节码里就不是 LDC 而是 ALOAD(从局部变量表加载),扫描器匹配不上,直接编译报错。
这个约束在工程上其实是优点而非限制 :"必须传字面量"意味着 VirtualField 的声明永远是静态可分析的 ------ Agent 在启动时就能知道全部需要缝字段的位置,不需要在运行时动态发现。这是一种用一点 API 便利性换取"全知视角"的交易。
扫描完成后,MuzzleCodeGenerator 用 ASM 在 InstrumentationModule 子类里生成 registerMuzzleVirtualFields() 方法,效果等价于:
java
// 编译期自动生成,开发者不用写
builder.register("java.lang.Runnable", "...PropagatedContext");
builder.register("java.util.concurrent.ForkJoinTask", "...PropagatedContext");
到这里,"这个模块要用哪些虚拟字段"就成了一个编译期就固定的清单 ------ 产物 ❶ VirtualFieldMappings。
第二步(启动期):批量生成两类辅助类
Agent 启动、安装每个 InstrumentationModule 时,FieldBackedImplementationInstaller 读取产物 ❶,为每对类型生成两类辅助类型,并把它们注入 Bootstrap ClassLoader。
如果模块没有虚拟字段,整个过程被短路:返回一个 NoopInstaller,三种安装操作全是空实现。"不为不存在的东西付钱。"
这一步的核心产出可以用下面这张图概括:
scss
产物 ❶ VirtualFieldMappings
{ Runnable → Context }
│
┌───────────┴───────────────┐
▼ ▼
产物 ❷ Accessor 接口 产物 ❸ VirtualField 实现类
┌─────────────────────┐ ┌─────────────────────────────────┐
│ interface │ │ class VirtualFieldImpl$R$C │
│ Accessor$R$C │ │ │
│ │ │ static final INSTANCE (单例) │
│ + __get__() → Object │◄────│ │
│ + __set__(Object) │引用 │ realGet(key): │
│ │ │ key instanceof ❷ ? │
│ extends Accessor │ │ Yes → 调 ❷.__get__() │
│ Marker (空标记) │ │ No → mapGet(key) 兜底 │
└─────────────────────┘ │ │
│ 内置 Cache.weak() 弱键 Map │
│ 作为兜底存储 │
└─────────────────────────────────┘
│
两者都注入到 Bootstrap ClassLoader ------
全应用可见,跨 ClassLoader 共享
这里有三个值得注意的设计细节:
为什么 Accessor 的 getter/setter 类型是 Object 而不是 Context? 因为接口要注入 Bootstrap ClassLoader,而 Context 类可能只在 Agent ClassLoader 里可见。如果签名里直接引用 Context,Bootstrap 层加载时就会 NoClassDefFoundError。用 Object 擦除类型,类型安全由编译期泛型保证。
为什么 realGet/realPut 需要 ASM 手写字节码? 因为要判断的 Accessor$R$C 是运行时生成的,编译时这个类不存在,Java 源码写不出 key instanceof Accessor$R$C。这是"元编程"的经典场景:用 ASM 在字节码层面表达 Java 语法无法表达的逻辑。生成的 realGet 等价于这段"不合法的 Java":
java
// ASM 生成的 realGet(等价伪代码,源码里写不出来)
private Object realGet(Object key) {
if (key instanceof Accessor$Runnable$Context) { // INSTANCEOF
return ((Accessor$Runnable$Context) key).__get__(); // INVOKEINTERFACE
}
return mapGet(key); // 兜底
}
为什么实现类是全局单例? 这对兜底路径至关重要。如果不同模块各持一份实例,它们内部的弱键 Map 就是独立的 ------ A 模块 set 的值 B 模块 get 不到。全局单例保证了无论走快路径还是兜底,语义都一致。
第三步(运行时):两条并行的时间线
到目前为止,所有准备工作都做完了。现在真正让 VirtualField 生效的,是运行时两条独立的字节码改写 同时进行,最终在一个 INSTANCEOF 检查处交汇。
css
┌──────────────────────────────────┐ ┌──────────────────────────────────────┐
│ 时间线 A:目标类被加载 │ │ 时间线 B:advice 类被加载 │
│ │ │ │
│ JVM 加载 MyRunnable │ │ JVM 加载 XxxAdvice │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ 匹配器 ❹ 命中 │ │ VirtualFieldFindRewriter │
│ "是 Runnable 的非抽象子类" │ │ 发现 VirtualField.find() 调用 │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ RealFieldInjector 改写字节码: │ │ 替换为: │
│ ① 加标记 InstalledMarker │ │ VirtualFieldImpl$R$C │
│ ② 加 Accessor 接口 ❷ │ │ .getVirtualField(R.class, C.class) │
│ ③ 缝字段 __otelVF$... │ │ │ │
│ ④ 生成 getter/setter │ │ ▼ │
│ │ │ │ 拿到 ❸ 的全局单例 INSTANCE │
│ ▼ │ │ │
│ 产物 ❺ │ │ │
│ 对象自带"口袋" │ │ │
│ (implements ❷,有真实字段) │ │ │
└───────────┬──────────────────────┘ └──────────────────────┬───────────────┘
│ │
│ advice 代码执行: FIELD.get(task) │
│ ↓ │
└──────────► ❸.realGet(task) ◄────────────────────┘
│
task instanceof ❷ ?
┌───────┴────────┐
▼ ▼
Yes (缝过) No (没缝过)
│ │
▼ ▼
CHECKCAST → ❷ mapGet(task)
.__get__() 弱键 Map 查找
│ │
▼ ▼
GETFIELD Cache.get()
一条字节码指令 哈希 + 弱引用
时间线 A:给目标类"缝口袋"
RealFieldInjector 对每个命中的类做四件事:加标记接口、加 Accessor 接口、缝字段、生成 getter/setter。缝进去的字段有四个精心选择的修饰符:
| 修饰符 | 为什么需要 |
|---|---|
private |
外部代码无法直接访问,只能通过 Accessor 接口 |
volatile |
跨线程可见性 ------ 提交线程 set,工作线程 get,没有 volatile 就是数据竞争 |
transient |
序列化时跳过 ------ Span context 不该被序列化出去 |
synthetic |
标记为编译器生成,配合反射过滤,用户看不到 |
这里有两个重要的防御性设计:
幂等重入(对抗 CGLIB) :不能只检查"字段在不在",要分别检查 字段、getter、setter 三者是否存在。原因是 CGLIB 这类字节码增强库生成子类时,只复制 public/protected 的方法 ,不复制字段 。于是会出现字段丢失但方法还在的诡异状态。三个独立的 if (!found) 判断,保证任何组合都能被正确补全。
retransform 安全 :如果类不是首次加载(classBeingRedefined != null),会先用 VirtualFieldDetector 确认这个类之前确实被缝过,才继续注入。这里有个巧妙的副作用链:hasVirtualField() 内部调用 Class.getInterfaces() ------ 这个反射调用本身会触发 reflection instrumentation,顺手把缝过的接口信息记进缓存。所以代码执行顺序必须是 先调 getInterfaces() 触发副作用,再读缓存。
隐藏机制:让注入的东西在反射里消失
这是 VirtualField 比 SkyWalking 的 EnhancedInstance 高明的地方。synthetic 标记只是"提示",Class.getDeclaredFields() 默认照返回不误。所以 OTel 额外挂了一个 internal-reflection instrumentation,拦截 getDeclaredFields()、getMethods()、getInterfaces() 三族方法,在返回前过滤掉所有 __opentelemetryVirtualField$ 前缀的成员和 VirtualFieldAccessorMarker 系列接口。
过滤有快速排除路径:先查目标类是否实现了 VirtualFieldInstalledMarker,没实现就原样返回,零开销。
"缝口袋"的完整含义在这里才显现:不只是缝个字段,还要在用户往身上摸的时候让他摸不到。
时间线 B:改写 find() 调用
VirtualFieldFindRewriter 把 advice 类里的 VirtualField.find() 替换为直接引用实现类的单例:
java
// 改写前(开发者写的)
VirtualField<Runnable, Context> f = VirtualField.find(Runnable.class, Context.class);
// 改写后(运行时字节码里的样子)
VirtualField<Runnable, Context> f = VirtualFieldImpl$R$C.getVirtualField(Runnable.class, Context.class);
// ↑ getVirtualField 内部直接 return INSTANCE,两个参数只是保持形态
重写器用和编译期扫描器同样的模式匹配 (追踪最近三条指令,确认是 LDC + LDC + INVOKESTATIC),构成双重保险 :编译期查一遍,运行时再查一遍。如果开发者绕过编译期检查(比如动态生成字节码),运行时会抛明确的 IllegalStateException。
InDy advice 模式的特殊处理 :在 inline 模式下,advice 字节码被复制进目标类,find() 改写随之内联。但在 InDy 模式下,advice 类运行在独立的 ClassLoader 里,不做 find() 改写 ------ 而是走 RuntimeVirtualFieldSupplier 的反射路径(Class.forName 从 Bootstrap 找实现类)。这条路能走通但每次反射代价高,所以约定:InDy advice 里必须把 VirtualField 存成 static final 字段,只 find() 一次。这也解释了为什么代码库里几乎所有 VirtualField 声明都是这个形态:
java
private static final VirtualField<Runnable, PropagatedContext> FIELD =
VirtualField.find(Runnable.class, PropagatedContext.class);
第四步:全局的"开关"与降级
VirtualField.find() 内部只有一行 ------ 委托给一个可替换的全局 supplier:
java
public static <U extends T, V extends F, T, F> VirtualField<U, V> find(
Class<T> type, Class<F> fieldType) {
return RuntimeVirtualFieldSupplier.get().find(type, fieldType);
}
library 模式 (无 Agent)下,supplier 是默认的 CacheBasedVirtualFieldSupplier ------ 底层就是两层弱键 Cache,纯 Map 实现,API 完全一样,性能差一些。这正好呼应了第二节"方案 A 作为兜底"的说法。
javaagent 模式 下,Agent 启动时把 supplier 替换成 RuntimeFieldBasedImplementationSupplier,走字段注入的快路径。替换有"只能设一次"的保护(if (instance != DEFAULT) 就忽略后续调用),防止后续模块把配置改乱。
字段注入开关 :otel.javaagent.experimental.field-injection.enabled(默认 true)。关掉后整个字段注入循环被跳过,所有类都不缝字段,全部走 Map 兜底。功能完整,只是慢一些。
整体数据流回顾
最后,把四步过程压缩成一次 get 调用的数据流视角。从 API 表面到真实字段,每一跳由哪个阶段的哪个产物促成:
css
开发者写: VirtualField.find(Runnable.class, Context.class)
│
│ ┌──────────────────────────────────────────────────┐
│ │ 编译期 → 产物 ❶ 记录这对类型 │
│ │ 启动期 → 产物 ❷❸ 生成接口和实现类 │
│ │ 运行时 → find() 被重写,返回 ❸ 的 INSTANCE │
│ └──────────────────────────────────────────────────┘
▼
FIELD = VirtualFieldImpl$R$C.INSTANCE (产物 ❸)
│
开发者调用: ctx = FIELD.get(task)
│
▼
❸.realGet(task)
│
├── task instanceof ❷ ?
│ ↑ 启动期生成的 Accessor 接口
│ ↑ 运行时 RealFieldInjector 缝到 task 的类上 (产物 ❺)
│
┌────────┴────────┐
▼ Yes ▼ No
task.__get__() weakMap.get(task)
│ │
▼ ▼
GETFIELD Cache.get()
读注入的字段 哈希 + 弱引用查找
│
一条字节码指令
零哈希、零锁、零 GC
读这张图的方式 :从上往下,每个箭头跨越的"边界"都对应一个产物。编译期产出 ❶ 决定了"要给谁缝什么";启动期产出 ❷❸❹ 是基础设施(接口、实现类、匹配器);运行时 ❹ 触发 ❺ 的诞生(对象有了口袋),❸ 通过 INSTANCEOF ❷ 判断 ❺ 是否存在 ------ 五个产物在这一刻全部到齐,一次 GETFIELD 完成使命。
五、跟着 mybatis-3.2 走一遍全流程
理论讲完了,现在用代码库里的真实案例把整条链路串起来。选 mybatis-3.2 模块,它的场景特别有代表性。
5.1 它想解决什么问题
MyBatis 执行 SQL 时,你希望 Span 名字是 SELECT UserMapper.findById 这种形式 ------ 既有方法名,又有 Mapper 类名。但麻烦在于:
- MyBatis 内部把 SQL 信息存在
MapperMethod.SqlCommand对象里 - 这个对象只有 SQL 语句本身,不包含"是哪个 Mapper 接口的哪个方法"
- 类名和方法名只在
MapperMethod的构造函数参数里出现一次,之后就不留痕了
所以需要在构造 SqlCommand 的时候,把 Class<?> 和 Method 这两个参数塞到 SqlCommand 对象身上,等真正执行 SQL 的时候再取出来。
这不就是 VirtualField 的标准使用场景吗。
5.2 声明:一行搞定
java
// instrumentation/mybatis-3.2/.../SqlCommandUtil.java
public class SqlCommandUtil {
private static final VirtualField<SqlCommand, ClassAndMethod> FIELD =
VirtualField.find(SqlCommand.class, ClassAndMethod.class);
public static void setClassAndMethod(SqlCommand command, Class<?> clazz, Method method) {
FIELD.set(command, ClassAndMethod.create(clazz, method.getName()));
}
@Nullable
public static ClassAndMethod getClassAndMethod(SqlCommand command) {
return FIELD.get(command);
}
}
注意 VirtualField.find(SqlCommand.class, ClassAndMethod.class) ------ 两个参数都是 class 字面量,符合编译期扫描的要求。
5.3 存入:拦截构造函数
java
// SqlCommandInstrumentation.java
class SqlCommandInstrumentation implements TypeInstrumentation {
@Override
public ElementMatcher<TypeDescription> typeMatcher() {
return named("org.apache.ibatis.binding.MapperMethod$SqlCommand");
}
@Override
public void transform(TypeTransformer transformer) {
transformer.applyAdviceToMethod(
isConstructor().and(takesArgument(1, Class.class)).and(takesArgument(2, Method.class)),
getClass().getName() + "$ConstructorAdvice");
}
public static class ConstructorAdvice {
@Advice.OnMethodExit(suppress = Throwable.class, inline = false)
public static void onExit(
@Advice.This SqlCommand command,
@Advice.Argument(1) Class<?> mapperInterface,
@Advice.Argument(2) Method method) {
SqlCommandUtil.setClassAndMethod(command, mapperInterface, method);
}
}
}
拦截 SqlCommand 的构造函数,在退出时把两个参数存进虚拟字段。之后无论这个 SqlCommand 对象跑到哪里,getClassAndMethod() 都能拿回 Mapper 类和方法名。
5.4 编译期:扫描器看到了什么
编译这个模块时,VirtualFieldCollectingMethodVisitor 扫描 SqlCommandUtil 的字节码,遇到:
vbnet
LDC org/apache/ibatis/binding/MapperMethod$SqlCommand.class ← 第一个 LDC
LDC ...ClassAndMethod.class ← 第二个 LDC
INVOKESTATIC VirtualField.find(Class, Class) ← 触发收集
于是 VirtualFieldMappings 里登记一条:("org.apache.ibatis.binding.MapperMethod$SqlCommand", "...ClassAndMethod")。
同时 Muzzle 也顺手把 SqlCommand 类本身标记为"引用",这样如果用户用的是个没有 MapperMethod$SqlCommand 的 MyBatis 版本,整个模块会被 Muzzle 静默跳过 ------ 跟 VirtualField 的机制是两套独立的安全网。
5.5 启动期:三样东西被生成
Agent 启动、安装 MyBatisInstrumentationModule 时:
css
① VirtualFieldAccessor$org_apache_ibatis_..._SqlCommand$..._ClassAndMethod(接口)
Object __get__opentelemetryVirtualField$...()
void __set__opentelemetryVirtualField$...(Object)
② VirtualFieldImpl$org_apache_ibatis_..._SqlCommand$..._ClassAndMethod(实现类)
realGet → INSTANCEOF accessor ? 直读字段 : mapGet
realPut → INSTANCEOF accessor ? 直写字段 : mapPut
③ 注册类型匹配器
hasSuperType(named("org.apache.ibatis.binding.MapperMethod$SqlCommand"))
.and(not(isAbstract()))
→ 命中时执行 RealFieldInjector
①② 被注入 Bootstrap ClassLoader。③ 挂进了 ByteBuddy 的 AgentBuilder。
5.6 运行时:一次真实的加载
现在用户的应用启动了。MyBatis 第一次使用 Mapper 时,JVM 要加载 MapperMethod$SqlCommand。
typescript
JVM 加载 org.apache.ibatis.binding.MapperMethod$SqlCommand
│
▼
ByteBuddy 的类型匹配器命中(它有 SqlCommand 这个父类型,且非抽象)
│
▼
safeToInjectFieldMatcher 检查:classBeingRedefined == null?
│ 是首次加载 → 放行
▼
RealFieldInjector 修改字节码:
├─ interfaces += VirtualFieldInstalledMarker
├─ interfaces += VirtualFieldAccessor$..._SqlCommand$..._ClassAndMethod
├─ field += private volatile transient synthetic Object
│ __opentelemetryVirtualField$..._SqlCommand$..._ClassAndMethod
├─ method += public synthetic Object __get__opentelemetryVirtualField$...()
└─ method += public synthetic void __set__opentelemetryVirtualField$...(Object)
│
▼
类加载完成 ------ 现在每个 SqlCommand 实例都自带一个口袋
之后 MapperMethod 的构造函数被字节码增强出来的 advice 调用,执行 SqlCommandUtil.setClassAndMethod(command, mapper, method):
bash
FIELD.set(command, ClassAndMethod.create(...))
│
▼
VirtualFieldImpl$....set(...) ← 注意:这是重写后的 getVirtualField 拿到的 INSTANCE,同一个对象
│
▼
realPut(command, value)
│
▼
command instanceof VirtualFieldAccessor$..._SqlCommand$..._ClassAndMethod ?
│
├─ 是(正常情况)→ PUTFIELD 写入注入的字段
└─ 否(被 CGLIB 增强过 / 未缝字段)→ mapPut 走弱键 Map
等到执行 SQL、需要 Span 名字时:
java
ClassAndMethod cnm = SqlCommandUtil.getClassAndMethod(command);
// → realGet → INSTANCEOF 成立 → GETFIELD → 一次普通字段读取
整条链路的性能开销:一次 INSTANCEOF + 一次 GETFIELD。没有哈希、没有锁、没有弱引用分配。
5.7 那些不走运的情况
同一条链路,在某些类上会退化:
| 情况 | 结果 |
|---|---|
| 类首次加载,正常增强 | ✅ 字段注入,快路径 |
目标是 java.* 包里的类(如 sun.net.www.protocol.https.HttpsURLConnectionImpl) |
⚠️ helper 会被单独补注入 Bootstrap CL,保证这类类加载时 accessor 接口一定可用(源码注释专门点名了这个场景) |
| 类被 CGLIB 二次增强,字段丢失 | ⚠️ visitEnd 里三个 if (!foundXxx) 各自补全 |
otel.javaagent.experimental.field-injection.enabled=false |
⚠️ 全部走弱键 Map |
| library 模式(无 Agent) | ⚠️ 全部走 CacheBasedVirtualField |
InDy advice 里没缓存 find() 结果 |
⚠️ 每次走反射(能跑但慢),VirtualFieldChecker 在测试模式下会警告 |
注意最后一行那个"降级不降功能"的特征 :无论走哪条路,HashMap 语义都是完整的,用户代码永远不需要知道自己在快路径还是慢路径。
六、回头看:这个设计的得与失
6.1 三个层面的"隐形"
VirtualField 最有意思的地方在于,它在三个不同的层面上同时做到了"看不见":
arduino
层面一:语言层
用户看不到字段,因为字段是运行时注入的字节码
↓
层面二:反射层
用户摸不到字段,因为 reflection instrumentation 过滤了 synthetic 成员
↓
层面三:API 层
用户不需要知道字段存在,因为 API 就是普通的 get/set
大多数"给对象加数据"的方案只能做到其中一层或两层。EnhancedInstance 做到了第一层和第三层(API 简单、字段真实),但反射层漏了。WeakConcurrentMap 做到了第三层,但前两层压根没有(性能也就上不去)。三层全占,这才是 VirtualField 的独特之处。
6.2 用"编译期约束"换"运行期确定性"
回顾整条链路,有一个贯穿始终的特点:能静态确定的事情,绝不拖到运行时。
find()必须传 class 字面量 → 编译期就能枚举全部虚拟字段- 编译期生成
registerMuzzleVirtualFields()→ 启动时不需要扫描类路径 - 启动期就把 accessor 接口和实现类成批生成好 → 类加载时只做注入,不做决策
代价是 API 上多了一条"看起来不合理"的约束(不能用变量)。但收益是:VirtualField 的行为完全可预测、可测试,没有"运行时发现新类型"这种不确定性。对基础设施代码来说,这种确定性远比"API 灵活一点"重要。
这跟 Muzzle 的设计哲学一脉相承:宁可让编译器多干点活,也不让运行时有意外。
6.3 复杂度是否值得?
老实说,VirtualField 是这套代码库里最费解的部分之一。为了做一个 get/set,牵扯了:编译期字节码扫描、ASM 手写字节码、Bootstrap ClassLoader 注入、动态接口生成、反射过滤 instrumentation、六种降级路径......
那这个复杂度换来了什么?
拿最典型的场景算一笔账:java.util.concurrent 的 executors instrumentation,每个线程池提交和每个任务执行都会各走一次 VirtualField 的 get/set。在一个每秒几万请求、每个请求提交若干异步任务的系统里,这是每秒几十万次的访问。
- 弱键 Map 方案:每次一次标识哈希计算(可能写对象头)+ 一次并发 map 查找 + 可能的弱引用分配
- 字段方案:一次
INSTANCEOF+ 一次GETFIELD
这两者在纳秒尺度上可能是 20 倍以上的差距。乘上调用量,就是一条可以测量出来的延迟曲线差异,以及一批本可以被省掉的 GC 压力。
对一个"要在别人的进程里长期潜伏、还不能被察觉"的组件来说,这个账算得过来。而且复杂度被封装在框架层 了 ------ 写 instrumentation 的人看到的永远只有 find / get / set 三个方法。
6.4 局限性,说清楚
不必神化这个设计,它也有明确的边界:
-
注入字段会改变类的结构 。虽然反射被过滤了,但
-verbose:class或者某些底层工具仍然能看到类被改过。对于有类校验、完整性检查的环境,这是需要评估的风险(这也是那个field-injection.enabled=false开关存在的意义)。 -
对"值引用 key"的循环引用会泄漏。官方 javadoc 明确警告:"discouraged to use a virtual field for keeping values that might reference their key, as it may cause memory leaks." 字段跟着对象走,如果值反过来强引用对象,就是标准的循环引用 ------ 弱引用 Map 至少能打破这个环,字段方案不行。
-
字段类型被抹成
Object。为了 Bootstrap 的可见性,实际注入的字段类型是Object,类型安全只由编译期泛型保证。如果有人绕过 API 用反射塞错类型,运行时才会炸。 -
InDy advice 模式下有使用约束 。必须自己缓存
find()结果,否则退化成反射调用。这个约定不写在 API 里,只在测试检查器里有提示。
6.5 一句话总结
如果要用一句话概括 VirtualField 的设计哲学,我会这么说:
"既然语言不让我给你的类加字段,那我就用字节码加一个 ------ 然后想办法让它看起来像是从来没加过。"
前半句是技术,后半句是工程。很多方案都能做到前半句(ASM 注入字段并不新鲜),但后半句才是让这个方案能真正用在生产环境里的关键 ------ 速度要快,但存在感必须为零。
七、附录:关键类速查
| 类 / 接口 | 所在模块 | 职责 |
|---|---|---|
VirtualField |
instrumentation-api |
对开发者暴露的 API(find/get/set) |
RuntimeVirtualFieldSupplier |
instrumentation-api |
可替换的全局 supplier;默认 CacheBasedVirtualFieldSupplier |
Cache.weak() / WeakLockFreeCache |
instrumentation-api |
弱键缓存,兜底与 library 模式的底层存储 |
VirtualFieldCollectingMethodVisitor |
muzzle |
编译期 :从字节码里提取 find() 声明的类型对 |
MuzzleCodeGenerator |
muzzle |
编译期 :生成 registerMuzzleVirtualFields() |
VirtualFieldMappings |
muzzle |
类型对清单 {owner → fieldType} |
VirtualFieldImplementationInstallerFactory |
javaagent-tooling |
启动期入口;把 supplier 换成字段版 |
FieldAccessorInterfacesGenerator |
javaagent-tooling |
启动期:生成 accessor 接口 |
VirtualFieldImplementationsGenerator |
javaagent-tooling |
启动期 :生成实现类(含 realGet/realPut) |
FieldBackedImplementationInstaller |
javaagent-tooling |
启动期:注册匹配器、编排三种注入 |
RealFieldInjector |
javaagent-tooling |
运行时:缝字段 + 加接口 + 生成 getter/setter |
VirtualFieldFindRewriter |
javaagent-tooling |
运行时 :把 find() 调用重写为 getVirtualField() |
VirtualFieldDetector |
javaagent-bootstrap |
判断类是否已缝过某个字段(retransform 幂等) |
ReflectionHelper |
internal-reflection instrumentation |
在反射 API 返回前过滤 synthetic 成员 |
VirtualFieldChecker |
javaagent-tooling |
测试期检查 InDy advice 是否违规调用 find() |
缝一个口袋不难,难的是让穿衣服的人一辈子都不知道自己身上多了个口袋。