VirtualField:给别人的类"缝口袋"的全过程

1 阅读23分钟

你想在别人的对象上放点自己的东西,但你不能改它的源码、不能加它的字段、不能碰它的接口 —— 于是 OpenTelemetry 选择在运行时,往它的字节码里缝一个真实的字段上去。

一、一个看似不可能的需求

假设你在写一个可观测性 Agent。现在有个再普通不过的场景:

用户发了一个 HTTP 请求,线程 A 处理它,中途 executor.submit(() -> callDatabase()) 把数据库调用丢给了线程池。你的 Agent 要在数据库调用外面包一个 Span —— 但麻烦来了:Span 的 parent context 存在线程 A 的 ThreadLocal 里,而数据库调用跑在线程 B 上。

你得在提交任务的时候,把线程 A 的 context 存到某个地方;等任务在线程 B 上执行时,再取出来。存哪儿?

最自然的答案是"存到任务对象上"。但任务对象是个 Runnable —— 它是 JDK 的类(或者说用户用 lambda 生成的匿名类),你既不能给它加字段,也不能改它的源码,更不能让它实现一个新接口。

换个思路:用一个 Map<Runnable, Context> 不就行了?

// 天真版实现
Map<Runnable, Context> contextMap = new WeakHashMap<>();
contextMap.put(task, Context.current());   // 提交时
Context ctx = contextMap.get(task);        // 执行时

能用,但用过的人都知道这玩意有三宗罪:

  1. 不是无锁的。WeakHashMap 需要同步,在争用场景下就是一条锁队列。换成 ConcurrentHashMap 也不行 —— 你需要弱引用语义,而 ConcurrentHashMap 不支持用弱引用做 key。
  2. 每次 put 都在制造垃圾。每个 entry 都要包一个 WeakReference 和一个 ReferenceQueue 的记账对象。高频路径上(想想每个 HTTP 请求、每个线程池提交),GC 压力肉眼可见。
  3. 查找不是 O(1)。你没看错 —— WeakConcurrentMap 用 WeakReference 的 hashCode 去查找,而 JVM 为存活对象分配的是标识哈希(identity hash),需要惰性生成并写回对象头。一次哈希计算、一次 CAS 写对象头、一次 map 查找,成本远超"读一个字段"。

这三条单独看都不致命,但乘上"每个请求都要走一次"的调用量,就成了压在延迟曲线上的实打实的分量。

问题的本质是:你想在别人的对象上附加数据,但 Java 语言层面没有给你"扩展字段"的能力。

于是 OpenTelemetry 换了一个思路 —— 既然语言不给我,那我就用字节码给自己造一个。

二、别人是怎么解这个题的

在 OTel 之前和之外,各家 APM Agent 都在用自己的方式对付同一个问题。这一节我们把几个主流方案摆在一起看。

2.1 方案 A:WeakConcurrentMap —— 最安全的"保守派"

Elastic APM 早期走的是这条路:用一个弱键的并发哈希表,把 context 挂到对象上。

// 概念示意:弱键 → 强值
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,然后把这个接口要求的字段和方法注入进去。

// SkyWalking 的概念模型
public interface EnhancedInstance {
    Object getSkyWalkingDynamicField();
    void setSkyWalkingDynamicField(Object value);
}

字节码增强后,HttpServletRequest 变成了"实现了 EnhancedInstance 的 HttpServletRequest",于是你可以:

((EnhancedInstance) request).setSkyWalkingDynamicField(context);

效果上跟 OTel 的 VirtualField 很接近 —— 也是注入真实字段,也是 O(1) 访问。但 SkyWalking 的做法修改了目标类的接口列表,这带来两个副作用:

  1. 反射会漏。request.getClass().getInterfaces() 现在会返回一个用户完全没见过的 EnhancedInstance。某些框架会遍历接口做判断,看到不认识的就懵了。
  2. 序列化有风险。Jackson、Kryo 这类框架在处理对象时会看实现接口,多一个接口可能触发意外的多态处理或者报错。

2.4 方案 D:VarHandle / jdk.internal.misc —— 想得太美

理论上 Java 9+ 的 VarHandle 可以给对象做"视图"访问,但你没法凭空给一个不存在的字段创建 VarHandle。至于 sun.misc.Unsafe 直接操作对象内存 —— 那是拿命换性能,版本一升级就崩,而且没法处理"类已经有固定布局"的约束。

2.5 一张表看清楚

方案附加方式访问复杂度GC 开销对目标类的侵入性
WeakConcurrentMap (Elastic APM)外部弱键 MapO(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 的人不该知道"字段注入"这回事。他应该能这么写:

// 声明:我要给 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 的机制可以归结为五个关键产物在三个阶段中依次生成、相互衔接。理解了产物之间的流转关系,整个系统就清楚了。

                    ┌──────────────────────────────────────────────────┐
  编 译 期           │  开发者写下:                                      │
  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) 编译出来的指令序列是固定模式的三条:

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() 方法,效果等价于:

// 编译期自动生成,开发者不用写
builder.register("java.lang.Runnable", "...PropagatedContext");
builder.register("java.util.concurrent.ForkJoinTask", "...PropagatedContext");

到这里,"这个模块要用哪些虚拟字段"就成了一个编译期就固定的清单 —— 产物 ❶ VirtualFieldMappings。

第二步(启动期):批量生成两类辅助类

Agent 启动、安装每个 InstrumentationModule 时,FieldBackedImplementationInstaller 读取产物 ❶,为每对类型生成两类辅助类型,并把它们注入 Bootstrap ClassLoader。

如果模块没有虚拟字段,整个过程被短路:返回一个 NoopInstaller,三种安装操作全是空实现。"不为不存在的东西付钱。"

这一步的核心产出可以用下面这张图概括:

         产物 ❶ 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":

// 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 检查处交汇。

 ┌──────────────────────────────────┐  ┌──────────────────────────────────────┐
 │ 时间线 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() 替换为直接引用实现类的单例:

// 改写前(开发者写的)
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 声明都是这个形态:

private static final VirtualField<Runnable, PropagatedContext> FIELD =
    VirtualField.find(Runnable.class, PropagatedContext.class);

第四步:全局的"开关"与降级

VirtualField.find() 内部只有一行 —— 委托给一个可替换的全局 supplier:

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 表面到真实字段,每一跳由哪个阶段的哪个产物促成:

开发者写:  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 声明:一行搞定

// 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 存入:拦截构造函数

// 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 的字节码,遇到:

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 时:

① 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。

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):

FIELD.set(command, ClassAndMethod.create(...))
    │
    ▼
VirtualFieldImpl$....set(...)   ← 注意:这是重写后的 getVirtualField 拿到的 INSTANCE,同一个对象
    │
    ▼
realPut(command, value)
    │
    ▼
command instanceof VirtualFieldAccessor$..._SqlCommand$..._ClassAndMethod ?
    │
    ├─ 是(正常情况)→ PUTFIELD 写入注入的字段
    └─ 否(被 CGLIB 增强过 / 未缝字段)→ mapPut 走弱键 Map

等到执行 SQL、需要 Span 名字时:

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 最有意思的地方在于,它在三个不同的层面上同时做到了"看不见":

层面一:语言层
  用户看不到字段,因为字段是运行时注入的字节码
       ↓
层面二:反射层
  用户摸不到字段,因为 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 局限性,说清楚

不必神化这个设计,它也有明确的边界:

  1. 注入字段会改变类的结构。虽然反射被过滤了,但 -verbose:class 或者某些底层工具仍然能看到类被改过。对于有类校验、完整性检查的环境,这是需要评估的风险(这也是那个 field-injection.enabled=false 开关存在的意义)。

  2. 对"值引用 key"的循环引用会泄漏。官方 javadoc 明确警告:"discouraged to use a virtual field for keeping values that might reference their key, as it may cause memory leaks." 字段跟着对象走,如果值反过来强引用对象,就是标准的循环引用 —— 弱引用 Map 至少能打破这个环,字段方案不行。

  3. 字段类型被抹成 Object。为了 Bootstrap 的可见性,实际注入的字段类型是 Object,类型安全只由编译期泛型保证。如果有人绕过 API 用反射塞错类型,运行时才会炸。

  4. InDy advice 模式下有使用约束。必须自己缓存 find() 结果,否则退化成反射调用。这个约定不写在 API 里,只在测试检查器里有提示。

6.5 一句话总结

如果要用一句话概括 VirtualField 的设计哲学,我会这么说:

"既然语言不让我给你的类加字段,那我就用字节码加一个 —— 然后想办法让它看起来像是从来没加过。"

前半句是技术,后半句是工程。很多方案都能做到前半句(ASM 注入字段并不新鲜),但后半句才是让这个方案能真正用在生产环境里的关键 —— 速度要快,但存在感必须为零。

七、附录:关键类速查

类 / 接口所在模块职责
VirtualFieldinstrumentation-api对开发者暴露的 API(find/get/set)
RuntimeVirtualFieldSupplierinstrumentation-api可替换的全局 supplier;默认 CacheBasedVirtualFieldSupplier
Cache.weak() / WeakLockFreeCacheinstrumentation-api弱键缓存,兜底与 library 模式的底层存储
VirtualFieldCollectingMethodVisitormuzzle编译期:从字节码里提取 find() 声明的类型对
MuzzleCodeGeneratormuzzle编译期:生成 registerMuzzleVirtualFields()
VirtualFieldMappingsmuzzle类型对清单 {owner → fieldType}
VirtualFieldImplementationInstallerFactoryjavaagent-tooling启动期入口;把 supplier 换成字段版
FieldAccessorInterfacesGeneratorjavaagent-tooling启动期:生成 accessor 接口
VirtualFieldImplementationsGeneratorjavaagent-tooling启动期:生成实现类(含 realGet/realPut)
FieldBackedImplementationInstallerjavaagent-tooling启动期:注册匹配器、编排三种注入
RealFieldInjectorjavaagent-tooling运行时:缝字段 + 加接口 + 生成 getter/setter
VirtualFieldFindRewriterjavaagent-tooling运行时:把 find() 调用重写为 getVirtualField()
VirtualFieldDetectorjavaagent-bootstrap判断类是否已缝过某个字段(retransform 幂等)
ReflectionHelperinternal-reflection instrumentation在反射 API 返回前过滤 synthetic 成员
VirtualFieldCheckerjavaagent-tooling测试期检查 InDy advice 是否违规调用 find()

缝一个口袋不难,难的是让穿衣服的人一辈子都不知道自己身上多了个口袋。