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

你想在别人的对象上放点自己的东西,但你不能改它的源码、不能加它的字段、不能碰它的接口 ------ 于是 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);        // 执行时

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

  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 挂到对象上。

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 的做法修改了目标类的接口列表,这带来两个副作用:

  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) 外部弱键 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 局限性,说清楚

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

  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 注入字段并不新鲜),但后半句才是让这个方案能真正用在生产环境里的关键 ------ 速度要快,但存在感必须为零。

七、附录:关键类速查

类 / 接口 所在模块 职责
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()

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

相关推荐
小园子的小菜1 小时前
Python 网络编程详解:TCP 与 UDP 原理 + 完整实战示例
后端
后端LV1 小时前
把限流从「注解」做活:RateLimitKeyResolver 四种真实业务的键玩法
java·后端
一帅1 小时前
InDy Advice:用一行 invokedynamic,把 Agent 藏进"平行宇宙"
后端
inhere1 小时前
miglite v0.8.0:迁移文件可以嵌进二进制了
后端
geovindu1 小时前
rust: tree
开发语言·后端·rust
imDwAaY1 小时前
Bean的生命周期
java·笔记·后端·学习·spring·dubbo
上下求索,莫负韶华1 小时前
Spring全家桶
java·后端·spring
行百里er1 小时前
Redis 性能优化——内存、慢查询、Big Key 与 Hot Key
redis·后端
打工仔折腾 AI2 小时前
Prometheus接入Pushgateway实战:二进制与Docker部署、指标推送与远程写入
后端·python·docker·容器·性能优化·prometheus·ai agent 实战