InDy Advice:用一行 invokedynamic,把 Agent 藏进"平行宇宙"

传统字节码增强是"把代码抄进你家",InDy 是"在你家放一个电话"。 这一个字的区别,决定了 Agent 的辅助类到底会不会污染你的 ClassLoader。

一、一个"看起来很干净"的崩溃

先讲个故事。

你维护着一个 Java Agent。某个周五下午,你收到一条用户反馈:

less 复制代码
java.lang.ClassCastException: class com.example.tracing.UserContext
    cannot be cast to class com.example.tracing.UserContext
    (com.example.tracing.UserContext is in unnamed module of loader 'app';
     com.example.tracing.UserContext is in unnamed module of loader
     io.opentelemetry.javaagent.tooling.AgentClassLoader @1a2b3c)

第一次看到这个报错的人,大脑会短路两秒:同一个类,为什么不能转成它自己?

答案只有一个:这两个 UserContext 不是同一个类 。它们名字一样、字节码一样、包名一样,但被两个不同的 ClassLoader 加载了。在 JVM 的世界里,类的身份是 (ClassLoader, 全限定名) 这个二元组决定的 ------ 同一个名字被两个 ClassLoader 加载,就是两个互不相干的类。它们之间的强制转换,等价于把 Cat 转成 Dog。

这就是所有 Java Agent 都要面对的头号天敌:类空间污染。

而这一章要讲的 InDy Advice,就是 OTel Java Agent 用来终结这场战争的那把武器。

二、问题的根源:inline advice 是一台"垃圾倾倒机"

2.1 传统 Advice 到底做了什么

字节码增强的核心工具有两个流派:ASM 手搓指令,或者 ByteBuddy 声明式 Advice。OTel 用的是后者。ByteBuddy 的 Advice 默认工作模式叫 inline(内联) ,它做的事情非常直白 ------ 把 advice 方法的字节码,原封不动地复制到目标方法里。

就像 C 语言的 inline 关键字:编译期把函数体展开到调用点。

scss 复制代码
┌──────────────────────────────────────────────────────────────┐
│ 你写的 advice                        用户的目标类              │
│                                                              │
│  public static void        ┌───────► public void execute() {  │
│  onEnter(Object target) {  │           // ↓ 你的字节码被复制  │
│    Span span =             │           Span span =            │
│      GrpcHelper.start();   │             GrpcHelper.start();  │
│  }                         │           doQuery();  // 原方法体  │
│                            │           // ↓ 你的字节码被复制  │
│                            │           GrpcHelper.end(span);  │
│                            │         }                        │
│                            └──────────────────────────────────┘
└──────────────────────────────────────────────────────────────┘

问题就出在箭头那一步。

2.2 第一宗罪:helper 类的"搭便车"

你的 advice 不是孤立的。onEnter 里调用了 GrpcHelper.start(),GrpcHelper 里又引用了 SpanBuilder、GrpcRequest、ContextStorage...... 这是一棵依赖树。

inline 之后,这棵树上的每一个类,都必须对目标类的 ClassLoader 可见。为什么?因为复制过去的字节码里,invokestatic GrpcHelper.start 这条指令的参数是常量池里的符号引用 ,JVM 解析它的时候,用的是当前类的定义类加载器(defining class loader)------ 也就是目标类的 ClassLoader。

于是 Agent 只能做一件很粗暴的事:把 GrpcHelper、SpanBuilder、ContextStorage...... 全部注入(inject) 到目标类的 ClassLoader 里。

markdown 复制代码
Agent ClassLoader                   应用 ClassLoader
┌──────────────────┐               ┌──────────────────────────┐
│ GrpcHelper       │  注入/复制     │ GrpcHelper   ← 住进来了   │
│ SpanBuilder      │ ────────────► │ SpanBuilder  ← 也住进来了 │
│ GrpcRequest      │               │ GrpcRequest  ← 全都来了   │
└──────────────────┘               │                          │
                                   │ 用户自己的类...           │
                                   └──────────────────────────┘

现在请回想文章开头那个 ClassCastException。用户的应用里恰好也有个叫 io.opentelemetry.instrumentation.grpc.GrpcHelper 的类(或者更常见的:用户依赖的另一个库里有同名类),会发生什么?

两个 ClassLoader 各加载了一份,类型体系撕裂,ClassCastException、LinkageError、IncompatibleClassChangeError 轮番登场。

Agent 本该是空气,结果变成了雾霾。

2.3 第二宗罪:把限制写进 DNA

inline 不是"推荐模式",它是字节码复制的物理约束下不得不然的结果。这些约束会直接扭曲你写 advice 的方式:

约束 为什么 后果
advice 方法必须是 static 字节码被复制过去,没有 this 的语义 面向对象?不存在的
不能引用 advice 类的实例字段 同上 状态只能塞进 @Advice.Local
局部变量跨 enter/exit 传递要靠 @Advice.Enter 没有独立栈帧,进出是两个方法体片段 代码可读性暴跌
异常表合并限制 两段字节码拼进一个方法,异常表要对得上 try-catch 要绕道写
helper 类必须注入目标 CL 符号引用按定义类加载器解析 前面说的污染

OTel 项目里有一段很直白的注释,来自 AdviceInspector:

Having a non-inline advice is treated as a marker that the instrumentation can use indy.

翻译成人话:"写了 inline = false 的 advice,就是作者在举牌子说'我这个模块准备好用 InDy 了'。" 这个约定我们后面第五章还会细讲。

2.4 第三宗罪:没有栈帧,等于没有现场

线上出问题时,你打开异常堆栈,希望看到"哪一层是 Agent 干的"。inline 之下,advice 的字节码已经跟目标方法的方法体融为一体,堆栈里根本没有你的名字。

php 复制代码
Exception in thread "main"
    at com.example.UserService.handle(UserService.java:42)
    ...

Agent 出 bug 时,你只能看到用户的方法,看不到 Agent 的方法。破案全靠猜。

三、行业怎么解这道题

这个问题不是 OTel 独有的,每个 Agent 项目都被逼着做了选择。

3.1 方案 A:暴力 shade(DD-Trace-Java 路线)

把所有 Agent 内部依赖的包名重写掉:

lua 复制代码
com.google.common        →  datadog.trace.agent.shaded.com.google.common
io.opentelemetry.api     →  datadog.trace.agent.shaded.io.opentelemetry.api

优点:简单有效,一次 shade 全家受益。名字都改了,怎么可能还撞车。

缺点:

  • jar 体积肉眼可见地膨胀(同一个库被 shade 了两份)
  • 堆栈里全是 datadog.trace.agent.shaded.xxxxx,看一次血压高一次
  • SPI 机制失效 ------ META-INF/services/ 里写死的类名不能改,改了 ServiceLoader 就找不到
  • 反射按类名查找的库(Jackson 反序列化、各种 XML 绑定框架)会被 shade 搞崩

Shade 是"给所有东西改个名字然后祈祷没人按名字找",本质是用重命名换隔离。

3.2 方案 B:独立 Plugin ClassLoader(SkyWalking 路线)

SkyWalking 给每个 plugin 一个独立的 ClassLoader,看起来很像 InDy 的隔离。但关键区别在于:

它的 interceptor 字节码,仍然要被注入到目标方法里。

ClassLoader 隔离只解决了"Agent 内部类之间不打架",没解决"Agent 的类跑到用户 CL 里去"这个根本问题。helper 类该注入还是得注入。

这叫隔离了一半 ------ 就像给房子装了防盗门,但窗户还开着。

3.3 方案 C:不隔离(Pinpoint 路线)

所有 Agent 代码往 Bootstrap ClassLoader 里一塞,全局可见。简单,但在多 ClassLoader 环境(OSGi、应用服务器、Tomcat 多 WebApp)里会迎来各种灵异事件。

3.4 核心矛盾

把三条路线放在一起看,矛盾就浮出水面了:

swift 复制代码
        ┌─────────────────────────────────────────┐
        │  advice 代码要和被增强的类交互            │
        │  (调用它的方法、读它的字段)             │
        └────────────────┬────────────────────────┘
                         │
              ┌──────────┴──────────┐
              ▼                     ▼
   ┌────────────────────┐  ┌────────────────────┐
   │ 同 CL:能交互       │  │ 异 CL:能隔离       │
   │ 但必然污染          │  │ 但符号引用解析不了  │
   └────────────────────┘  └────────────────────┘
              ✗                       ✗

"既要能访问目标类,又要不污染目标 CL" ------ 这在传统字节码增强的框架里是一对死锁。除非...... 你换一个链接方式。

四、InDy:从"复制代码"到"打个电话"

4.1 一个被遗忘的 JVM 特性

Java 7 引入了一个当时看起来跟普通业务开发八竿子打不着的东西:invokedynamic 指令。

它的设计初衷是给 JVM 上的动态语言(JRuby、Groovy、Nashorn)用的 ------ 让"这个调用到底调到哪个方法"这个决定,推迟到第一次执行时由用户代码来决定,而不是编译期写死。

一条 invokedynamic 指令长这样:

java 复制代码
INVOKEDYNAMIC adviceMethod(Ljava/lang/Object;)V
    <bootstrap-method>
    <static-args...>

JVM 第一次执行到这条指令时,它不会像 invokestatic 那样直接去常量池找 MethodRef,而是:

  1. 调用你指定的 bootstrap 方法(一个普通的 Java 静态方法)
  2. 把 Lookup、方法名、方法类型、以及你塞的静态参数交给它
  3. bootstrap 方法返回一个 CallSite 对象
  4. CallSite 里有一个 MethodHandle ------ 这才是真正的目标方法
  5. 后续调用直接走这个 MethodHandle,不再经过 bootstrap

关键点来了 :bootstrap 方法是在链接(linkage)阶段 执行的,而这时候 JVM 只是要一个 CallSite,它不关心这个 MethodHandle 指向的方法属于哪个 ClassLoader。

于是 InDy 的核心 trick 成立了:

不在目标类里放字节码,而是在目标类里放一个"电话号码"。 真正接电话的人,住在另一个 ClassLoader 里。

scss 复制代码
   传统 inline 模式                      InDy 模式
┌──────────────────────┐        ┌──────────────────────┐
│ TargetClass.method() │        │ TargetClass.method() │
│   [advice 字节码]     │        │   INVOKEDYNAMIC ─────┼──┐
│   [原方法体]          │        │   [原方法体]          │  │
│   [advice 字节码]     │        │   INVOKEDYNAMIC ─────┼──┼─┐
└──────────────────────┘        └──────────────────────┘  │ │
   helper 类全注入过来                                     │ │
   (污染!)                                              │ │
                                                          ▼ ▼
                                    ┌─────────────────────────────────┐
                                    │ InstrumentationModuleClassLoader │
                                    │  ├─ AdviceClass                  │
                                    │  ├─ GrpcHelper                   │
                                    │  └─ 依赖树上的所有类              │
                                    └─────────────────────────────────┘
                                      独立 CL,目标应用看不见

一句话总结这个范式转变:

inline advice InDy advice
目标方法里放什么 advice 的字节码副本 一条 INVOKEDYNAMIC 指令
helper 类住哪 注入到目标 CL 留在独立 CL
advice 是否 static 必须 不必须(是普通静态方法,但可自由引用)
异常堆栈 无独立栈帧 有独立栈帧
隔离程度 无 完整

4.2 一个残酷但有趣的细节

InDy 这么好,那为什么 OTel 里还有 130 个模块在写 inline = false 而不是全部?

(是的,我数过了 ------ grep -rl "inline = false" instrumentation/*/javaagent/src/main/java/ 在 v2.32.0 上是 130 个文件。)

因为 InDy 的迁移是逐模块 进行的。每个模块的 advice 都要先改造成非内联形式(inline = false),验证通过后才能启用。项目为此甚至在 IndyTypeTransformerImpl 里塞了一个 VirtualFieldChecker:

java 复制代码
private static ClassFileLocator getAdviceLocator(ClassLoader classLoader) {
  ClassFileLocator classFileLocator = ClassFileLocator.ForClassLoader.of(classLoader);
  return new AdviceLocator(
      classFileLocator,
      (slashClassName, bytes) -> {
        // verify that advice does not call VirtualField.find
        // NOTE: this check is here to help converting the advice for the indy instrumentation,
        // it can be removed once the conversion is completed
        VirtualFieldChecker.check(bytes);
        return bytes;
      });
}

这段代码的含义是:inline advice 里的 VirtualField.find() 调用会在编译期被重写成直接调用生成的实现类(详见 VirtualField 一文);但非内联 advice 做不到这个重写,所以你得手动把结果缓存到静态字段里。 这个 checker 就是迁移期的"脚手架",防止有人踩坑。

注释里那句 "it can be removed once the conversion is completed",是整个项目里最有野心的一句话 ------ InDy 是终局,inline 是历史遗留。

五、源码解析:一条 INVOKEDYNAMIC 的完整一生

好,理论讲完了。现在我们把引擎盖掀开,跟着一条 INVOKEDYNAMIC 指令从"出生"走到"接通"。

整个链路有两个世界:

markdown 复制代码
   ┌───────────────────────────────┐
   │ Bootstrap ClassLoader         │   ← 全局可见
   │   IndyBootstrapDispatcher     │      所有人都能看见
   └───────────┬───────────────────┘
               │ MethodHandle 委托(两跳设计)
               ▼
   ┌───────────────────────────────┐
   │ Agent ClassLoader             │   ← 应用看不见
   │   IndyBootstrap               │
   │   IndyModuleRegistry          │
   │   InstrumentationModuleCL     │   ← 真正接电话的人
   └───────────────────────────────┘

5.1 出生:编译期生成 INVOKEDYNAMIC

当 InstrumentationModuleInstaller 判定某个模块该走 InDy 路线时,它会用 IndyTypeTransformerImpl 而不是标准的 TypeTransformerImpl:

java 复制代码
public IndyTypeTransformerImpl(
    AgentBuilder.Identified.Extendable agentBuilder, InstrumentationModule module) {
  this.agentBuilder = agentBuilder;
  this.instrumentationModule = module;
  this.adviceMapping =
      Advice.withCustomMapping()
          .with(new ForceDynamicallyTypedAssignReturnedFactory(
              new Advice.AssignReturned.Factory().withSuppressed(Throwable.class)))
          .bootstrap(
              IndyBootstrap.getIndyBootstrapMethod(),                       // ← 关键!
              IndyBootstrap.getAdviceBootstrapArguments(instrumentationModule),
              TypeDescription.Generic.Visitor.Generalizing.INSTANCE);
}

Advice.WithCustomMapping.bootstrap(...) 是在告诉 ByteBuddy:

别内联,改生成 INVOKEDYNAMIC,bootstrap 方法用我给你的这个。

但这里有个反直觉的细节 :getIndyBootstrapMethod() 返回的,是 IndyBootstrapDispatcher 上的方法:

java 复制代码
private static final Method indyBootstrapMethod;

static {
  try {
    indyBootstrapMethod =
        IndyBootstrapDispatcher.class.getMethod(
            "bootstrap",
            MethodHandles.Lookup.class,
            String.class,
            MethodType.class,
            Object[].class);
    // ...

也就是说,生成到用户字节码里的 bootstrap 引用,指向 Bootstrap ClassLoader 里的 IndyBootstrapDispatcher,而不是 Agent ClassLoader 里的 IndyBootstrap。

为什么?因为 JVM 解析 INVOKEDYNAMIC 的 bootstrap 方法时,用的是目标类的类加载器。目标类是用户的类,它的 CL 根本看不见 Agent CL。所以 bootstrap 方法必须放在一个"全世界都能看见"的地方 ------ Bootstrap 层。

但真正的逻辑又不能放在 Bootstrap 层(那里要保持极简)。于是有了两跳设计:

复制代码
第一跳(Bootstrap CL 可见)        第二跳(Agent CL 执行)
IndyBootstrapDispatcher.bootstrap ──MethodHandle──► IndyBootstrap.bootstrap

5.2 出生证明:三个静态参数

getAdviceBootstrapArguments() 决定了每条 INVOKEDYNAMIC 指令里塞进常量池的"身份证":

java 复制代码
static Advice.BootstrapArgumentResolver.Factory getAdviceBootstrapArguments(
    InstrumentationModule instrumentationModule) {
  String moduleName = instrumentationModule.getClass().getName();
  return (adviceMethod, exit) ->
      (instrumentedType, instrumentedMethod) ->
          asList(
              JavaConstant.Simple.ofLoaded(moduleName),                        // args[0] 模块类名
              JavaConstant.Simple.ofLoaded(adviceMethod.getDescriptor()),      // args[1] advice 方法描述符
              JavaConstant.Simple.ofLoaded(adviceMethod.getDeclaringType().getName())); // args[2] advice 类名
  );
}

这三位参数,就是运行时找到"接电话的人"的全部线索:

参数 含义 运行时用途
args[0] InstrumentationModule 的类名 去 IndyModuleRegistry 查/建隔离 ClassLoader
args[1] advice 方法的描述符 重建 MethodType,精确定位方法
args[2] advice 类的全限定名 在隔离 CL 里 loadClass

值得注意的是 JavaConstant.Simple.ofLoaded(...) ------ 这些字符串是被当作已加载常量 存进常量池的,运行时不需要额外的 Class.forName 就能取到,属于微优化。

5.3 出生时的另一层保险:强行改注释

还有一个很容易被忽略的细节。就算你打算走 InDy,源文件里的 advice 可能仍然写着 @Advice.OnMethodEnter(suppress = Throwable.class) ------ 也就是 inline 取了默认值 true。

难道要靠开发者手动把每个 advice 都改成 inline = false?

不用。IndyInliningPoolStrategy 在 TypePool 层面做了动态改写:

java 复制代码
public class AdviceInliningPoolStrategy implements AgentBuilder.PoolStrategy {
  private final AgentBuilder.PoolStrategy poolStrategy;
  private final boolean inline;   // InDy 场景下传 false

  @Override
  public TypePool typePool(ClassFileLocator classFileLocator, ClassLoader classLoader) {
    TypePool typePool = poolStrategy.typePool(classFileLocator, classLoader);
    return new TypePoolWrapper(typePool, inline);
  }
  // ...

它包了一层 TypePool,当 ByteBuddy 去解析 advice 类的 TypeDescription 时,getDeclaredMethods() 返回的 MethodDescription 被换成包装版:

java 复制代码
@Override
public AnnotationList getDeclaredAnnotations() {
  AnnotationList annotations = method.getDeclaredAnnotations();
  // ...
  String annotationTypeName = annotation.getAnnotationType().getActualName();
  // we are only interested in OnMethodEnter and OnMethodExit annotations
  if (!Advice.OnMethodEnter.class.getName().equals(annotationTypeName)
      && !Advice.OnMethodExit.class.getName().equals(annotationTypeName)) {
    return annotation;
  }
  // replace value for "inline" attribute
  return replaceAnnotationValue(
      annotation, "inline", oldVal -> AnnotationValue.ForConstant.of(inline));
}

也就是说:ByteBuddy 看到的 advice 注解里,inline 值被偷偷改成了 false。原始 class 文件一个字节没动,但行为变了。

这手"我看到的东西和你看到的不一样",是整个 InDy 实现里最优雅的一招 ------ 它让"切换模式"变成了一个纯粹的运行时决策,不需要重新编译模块。

5.4 第一通电话:JVM 触发 bootstrap

第一次执行目标方法里的 INVOKEDYNAMIC 时,JVM 调用 bootstrap。Bootstrap 层的第一站:

java 复制代码
// javaagent-bootstrap/src/.../IndyBootstrapDispatcher.java
public static CallSite bootstrap(
    MethodHandles.Lookup lookup,
    String adviceMethodName,
    MethodType adviceMethodType,
    Object... args) {
  CallSite callSite = null;
  if (bootstrap != null) {
    try {
      callSite = (CallSite) bootstrap.invoke(lookup, adviceMethodName, adviceMethodType, args);
    } catch (Throwable e) {
      logger.log(FINE, "Error bootstrapping indy instruction", e);
    }
  }
  if (callSite == null) {
    // The MethodHandle pointing to the Advice could not be created for some reason,
    // fallback to a Noop MethodHandle to not crash the application
    MethodHandle noop = generateNoopMethodHandle(adviceMethodType);
    callSite = new ConstantCallSite(noop);
  }
  return callSite;
}

三件事值得单独拎出来说:

① bootstrap 字段是 volatile MethodHandle,初始为 null。

它在哪儿被赋值?在 Agent CL 里的 IndyBootstrap 静态初始化块:

java 复制代码
static {
  try {
    indyBootstrapMethod = IndyBootstrapDispatcher.class.getMethod("bootstrap", ...);
    MethodType bootstrapMethodType = MethodType.methodType(
        CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class, Object[].class);

    IndyBootstrapDispatcher.init(
        MethodHandles.lookup().findStatic(IndyBootstrap.class, "bootstrap", bootstrapMethodType));

    AdviceBootstrapState.initialize();
  } catch (Exception e) {
    throw new IllegalStateException(e);
  }
}

这是一次跨 ClassLoader 的"接线" :Bootstrap 层留了一个空插槽,Agent 层通过 MethodHandles.Lookup.findStatic 把自己的方法句柄插进去。因为 MethodHandle 是 JVM 的一等公民,它不受 ClassLoader 可见性约束 ------ 一旦拿到句柄,跨 CL 调用畅通无阻。

② 兜底策略:generateNoopMethodHandle。

如果 bootstrap.invoke 抛异常,或者句柄压根还没接上(bootstrap == null),dispatcher 不会让应用崩溃,而是返回一个 no-op 的 CallSite:

java 复制代码
public static MethodHandle generateNoopMethodHandle(MethodType methodType) {
  Class<?> returnType = methodType.returnType();
  MethodHandle noopNoArg;
  if (returnType == void.class) {
    noopNoArg = MethodHandles.constant(Void.class, null).asType(MethodType.methodType(void.class));
  } else {
    noopNoArg = MethodHandles.constant(returnType, getDefaultValue(returnType));
  }
  return MethodHandles.dropArguments(noopNoArg, 0, methodType.parameterList());
}

它先构造一个"永远返回默认值"的常量句柄(void 返回 null、int 返回 0、对象返回 null),再用 dropArguments 把参数全丢掉,凑成跟原方法一样的签名。

那个 getDefaultValue 的实现有点小聪明 ------ 用反射构造一个单元素原生数组再取第 0 位,白嫖 JVM 的原生类型默认值:

java 复制代码
private static Object getDefaultValue(Class<?> classOrPrimitive) {
  if (classOrPrimitive.isPrimitive()) {
    // arrays of primitives are initialized with the correct primitive default value (e.g. 0 for
    // int.class)
    // we use this fact to generate the correct default value reflectively
    return Array.get(Array.newInstance(classOrPrimitive, 1), 0);
  } else {
    return null; // null is the default value for reference types
  }
}

这是整条链路最重要的安全设计 :字节码增强失败时,应用必须能继续跑。宁可 advice 不生效,也不能让用户的 execute() 抛 NoSuchMethodError。

③ 日志只打 FINE 级别。

java 复制代码
logger.log(FINE, "Error bootstrapping indy instruction", e);

不吵不闹,符合"Agent 应该像空气"的哲学。

5.5 第二跳:真正的接线员

接下来进 IndyBootstrap。它先处理一个非常现实的问题 ------ SecurityManager:

java 复制代码
private static CallSite bootstrap(Lookup lookup, String adviceMethodName,
    MethodType adviceMethodType, Object[] args) {
  if (System.getSecurityManager() == null) {
    return internalBootstrap(lookup, adviceMethodName, adviceMethodType, args);
  }
  // callsite resolution needs privileged access to call Class#getClassLoader() and
  // MethodHandles$Lookup#findStatic
  return java.security.AccessController.doPrivileged(
      // using an anonymous class here instead of lambda because using a lambda here could trigger
      // a nested bootstrap call from lambda instrumentation which could lead to stack overflow
      new PrivilegedAction<CallSite>() {
        @Override
        public CallSite run() {
          return internalBootstrap(lookup, adviceMethodName, adviceMethodType, args);
        }
      });
}

注意那段注释 ------ 这里刻意用了匿名内部类而不是 lambda 。因为 lambda 在运行时会走 LambdaMetafactory,而 lambda 表达式本身可能被某个 instrumentation 增强,从而触发又一次 bootstrap。而这次嵌套 bootstrap 又需要 lambda,于是无限递归,StackOverflow。

为了避开这个死循环,项目愿意放弃 lambda 的简洁语法,写一个啰嗦的匿名类。这种"为了正确性牺牲优雅"的取舍,是基础设施代码的常态。

然后进入核心:

java 复制代码
private static CallSite bootstrapAdvice(
    MethodHandles.Lookup lookup,
    String adviceMethodName,
    MethodType invokedynamicMethodType,
    String moduleClassName,        // args[0]
    String adviceMethodDescriptor, // args[1]
    String adviceClassName)        // args[2]
    throws NoSuchMethodException, IllegalAccessException, ClassNotFoundException {

  try (AdviceBootstrapState nestedState = AdviceBootstrapState.enter(
      lookup.lookupClass(), moduleClassName, adviceClassName,
      adviceMethodName, adviceMethodDescriptor)) {

    if (nestedState.isNestedInvocation()) {
      // ... 嵌套防护,见 5.7
    }

    InstrumentationModuleClassLoader instrumentationClassloader =
        IndyModuleRegistry.getInstrumentationClassLoader(
            moduleClassName, lookup.lookupClass().getClassLoader());

    // Advices are not inlined. They are loaded as normal classes by the
    // InstrumentationModuleClassloader and invoked via a method call from the instrumented method
    Class<?> adviceClass = instrumentationClassloader.loadClass(adviceClassName);
    MethodType actualAdviceMethodType =
        MethodType.fromMethodDescriptorString(adviceMethodDescriptor, instrumentationClassloader);

    MethodHandle methodHandle =
        instrumentationClassloader
            .getLookup()
            .findStatic(adviceClass, adviceMethodName, actualAdviceMethodType)
            .asType(invokedynamicMethodType);
    // ...

四步走,干净利落:

scss 复制代码
1. lookup.lookupClass()          → 拿到被增强的类(用户应用里的类)
2. .getClassLoader()             → 拿到用户应用的 ClassLoader
3. IndyModuleRegistry.get...     → 查/建 (应用CL, 模块CL) 对应的隔离沙箱
4. classLoader.loadClass(advice) → 在沙箱里加载 advice
5. lookup().findStatic(...)      → 拿到 MethodHandle
6. .asType(invokedynamicType)    → 类型适配(泛型擦除后的签名差异)

最关键的一点 :那行注释 ------ Advices are not inlined. They are loaded as normal classes by the InstrumentationModuleClassloader。

advice 变成了一个普通的类,在独立的 ClassLoader 里,通过一次普通的方法调用被执行。

5.6 接线员的工作间:InstrumentationModuleClassLoader

这是 InDy 隔离的物理载体。先看它的类注释,写得比很多文档都清楚:

java 复制代码
/**
 * Class loader used to load the helper classes from {@link InstrumentationModule}s, so that those
 * classes have access to both the agent/extension classes and the instrumented application classes.
 *
 * <p>This class loader implements the following classloading delegation strategy:
 * <ul>
 *   <li>First, injected classes are considered (usually the helper classes from the InstrumentationModule)
 *   <li>Next, the class loader looks in the agent or extension class loader, depending on where the
 *       InstrumentationModule comes from
 *   <li>Finally, the instrumented application class loader is checked for the class
 * </ul>
 */

三步委托,跟 Java 默认的"双亲委派"正好相反:

scss 复制代码
InstrumentationModuleClassLoader.loadClass(name)
  │
  ├─ Step 1: getInjectedClass(name)          ← self-first!自己的 helper 类
  │            (injected 优先于 parent)
  │
  ├─ Step 2: shouldLoadFromAgent(name)?
  │            → agentOrExtensionCl          ← Agent 内部 API
  │            (前缀匹配 io.opentelemetry.javaagent,且未被 hidden)
  │
  └─ Step 3: instrumentedCl                  ← 用户应用的类
               (被增强的那个库)

源码:

java 复制代码
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
  synchronized (getClassLoadingLock(name)) {
    Class<?> result = findLoadedClass(name);

    // This CL is self-first: Injected class are loaded BEFORE a parent lookup
    if (result == null) {
      BytecodeWithUrl injected = getInjectedClass(name);
      if (injected != null) {
        byte[] bytecode = injected.getBytecode();
        // ... defineClassWithPackage ...
      }
    }
    if (result == null && shouldLoadFromAgent(name)) {
      result = tryLoad(agentOrExtensionCl, name);
    }
    if (result == null) {
      result = tryLoad(instrumentedCl, name);
    }
    // ...

注意 loadClass(String) 那个看起来多余的 override:

java 复制代码
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
  // We explicitly override loadClass from ClassLoader to ensure
  // that loadClass is properly excluded from our internal ClassLoader Instrumentations
  // (e.g. LoadInjectedClassInstrumentation, BooDelegationInstrumentation)
  // Otherwise this will cause recursion in invokedynamic linkage
  return loadClass(name, false);
}

这个 override 在功能上完全多余(父类 loadClass(String) 本来就是这么干的)。它存在,只是为了让方法解析时明确指向这个类自己的方法 ,从而让仪器化的排除规则(LoadInjectedClassInstrumentation)能正确匹配到它。否则,当 Agent 的类加载器仪器化逻辑试图拦截 loadClass 时,会在 invokedynamic 链接过程中递归。

这又是那种"看起来莫名其妙,删掉就 StackOverflow"的代码。

关于 agentClassNamesMatcher 和 hiddenAgentPackages:

java 复制代码
public InstrumentationModuleClassLoader(ClassLoader instrumentedCl, ClassLoader agentOrExtensionCl) {
  this(instrumentedCl, agentOrExtensionCl,
      new StringMatcher("io.opentelemetry.javaagent", StringMatcher.Mode.STARTS_WITH));
}

只有 io.opentelemetry.javaagent.* 前缀的类,才允许从 Agent CL 加载。其余的(比如 io.opentelemetry.api.*、io.opentelemetry.sdk.*)都会 fall through 到 instrumentedCl。

而 hiddenAgentPackages 是一个可变黑名单,由实验性模块按需添加:

java 复制代码
if (module instanceof ExperimentalInstrumentationModule) {
  ExperimentalInstrumentationModule experimentalModule = (ExperimentalInstrumentationModule) module;
  hiddenAgentPackages.addAll(experimentalModule.agentPackagesToHide());
}

用途:某模块想确保自己不能访问 Agent 的某些包,以此来验证自己的隔离性,或者避免撞上某个不该用的内部 API。

5.7 LookupExposer:一个为了绕过 JVM bug 而存在的类

这是 InDy 实现里最"考古"的一块。回到 5.5 的源码,注意这一行:

java 复制代码
MethodHandle methodHandle =
    instrumentationClassloader
        .getLookup()                              // ← 为什么不用 MethodHandles.lookup()?
        .findStatic(adviceClass, adviceMethodName, actualAdviceMethodType)

getLookup() 的实现:

java 复制代码
public MethodHandles.Lookup getLookup() {
  if (cachedLookup == null) {
    try {
      MethodType getLookupType = MethodType.methodType(MethodHandles.Lookup.class);
      // we don't mind the race condition causing the initialization to run multiple times here
      Class<?> lookupExposer = loadClass(LookupExposer.class.getName());
      // Note: we must use MethodHandles instead of reflection here to avoid a recursion
      // for our internal ReflectionInstrumentationModule which instruments reflection methods
      cachedLookup =
          (MethodHandles.Lookup)
              MethodHandles.publicLookup()
                  .findStatic(lookupExposer, "getLookup", getLookupType)
                  .invoke();
    } catch (Throwable e) {
      throw new IllegalStateException(e);
    }
  }
  return cachedLookup;
}

被加载的 LookupExposer 长得平平无奇:

java 复制代码
/**
 * This class is injected into every InstrumentationModuleClassLoader so that the bootstrap
 * can use a {@link MethodHandles.Lookup} with a lookup class from within the
 * InstrumentationModuleClassLoader, instead of calling {@link MethodHandles#lookup()} which uses
 * the caller class as the lookup class.
 *
 * <p>This circumvents a nasty JVM bug that's described <a
 * href="https://github.com/elastic/apm-agent-java/issues/1450">here</a>. The error is reproduced
 * in {@code InstrumentationModuleClassLoaderTest}
 */
public class LookupExposer {
  private LookupExposer() {}

  public static MethodHandles.Lookup getLookup() {
    return MethodHandles.lookup();
  }
}

为什么需要它?

MethodHandles.lookup() 返回的 Lookup,其 lookupClass 是调用者所在的类 。如果 Agent 直接在 IndyBootstrap 里调 MethodHandles.lookup(),得到的 Lookup 的 lookupClass 是 IndyBootstrap(Agent CL 里的类),根本无权访问隔离 CL 里刚加载的 advice 类。

正确做法是:让隔离 CL 内的一个类去调 MethodHandles.lookup(),这样 lookupClass 就是隔离 CL 里的类,权限边界正确。

LookupExposer 干的正是这件事。而且它是"永远注入"的:

java 复制代码
private static final Map<String, BytecodeWithUrl> ALWAYS_INJECTED_CLASSES =
    singletonMap(
        LookupExposer.class.getName(), BytecodeWithUrl.create(LookupExposer.class).cached());

每个 InstrumentationModuleClassLoader 不需要 installModule 就能拿到它。

注释里那个链接指向的是 Elastic APM 的一个真实 issue ------ 说明这个 JVM bug 不止坑了 OTel 一家。能把别人踩过的坑变成自己代码里的一行注释和一条测试,这是开源协作最动人的地方之一。

(顺带一提,注释里还有一句:"we must use MethodHandles instead of reflection here to avoid a recursion for our internal ReflectionInstrumentationModule which instruments reflection methods" ------ 用反射找方法会触发"增强反射"的 instrumentation,又递归了。在这个项目里,几乎每一处绕路都有注释说明为什么。)

5.8 嵌套防护:当接线员打电话给自己

现在讲 InDy 里最刁钻的边界情embracing-invokedynamic-to-tame-class-loaders-in-java-agents.md况。

场景 :你的 advice 所在的模块,依赖了日志框架。某次 bootstrap 过程中,agent 打了一行日志。而这行日志的框架恰好也被 instrument 了 (比如 log4j、logback 都有 instrumentation),于是日志的 INVOKEDYNAMIC 触发了又一次 bootstrap。

而这次嵌套的 bootstrap,还在等外层 bootstrap 把 advice 类加载完......

经典死锁 / StackOverflow。

IndyBootstrap 的类注释里 ADT 级别的警告写得很委婉:

java 复制代码
if (nestedState.isNestedInvocation()) {
  // avoid re-entrancy and stack overflow errors, which may happen when bootstrapping an
  // instrumentation that also gets triggered during the bootstrap
  // for example, adding correlation ids to the thread context when executing logger.debug.
  MutableCallSite mutableCallSite = nestedState.getMutableCallSite();
  if (mutableCallSite == null) {
    mutableCallSite = new MutableCallSite(
        IndyBootstrapDispatcher.generateNoopMethodHandle(invokedynamicMethodType));
    nestedState.initMutableCallSite(mutableCallSite);
  }
  return mutableCallSite;
}

解决方案:先给个"占位电话",事后再换号。

scss 复制代码
外层 bootstrap(第一次)
  ├─ AdviceBootstrapState.enter(...)  → recursionDepth = 0
  ├─ 加载隔离 CL、加载 advice 类
  │    └─ 触发日志 → 日志 INVOKEDYNAMIC → bootstrap
  │         └─ 内层 enter(...) → recursionDepth = 1 → isNestedInvocation() = true
  │              └─ 返回 MutableCallSite(noop)  ← 先给个"什么也不做"的占位
  ├─ 加载完成,拿到真正的 MethodHandle
  └─ 发现 nestedState.getMutableCallSite() != null
       ├─ nestedBootstrapCallSite.setTarget(methodHandle)   ← 换号!
       └─ MutableCallSite.syncAll([nestedBootstrapCallSite]) ← 通知 JIT 失效重编

最终代码:

java 复制代码
MutableCallSite nestedBootstrapCallSite = nestedState.getMutableCallSite();
if (nestedBootstrapCallSite != null) {
  // There have been nested bootstrapping attempts
  // Update the callsite of those to run the actual instrumentation
  logger.log(FINE,
      "Fixing nested instrumentation invokedynamic instruction bootstrapping for instrumented"
          + " class {0} and advice {1}.{2}, the instrumentation should be active now",
      new Object[] {lookup.lookupClass().getName(), adviceClassName, adviceMethodName});
  nestedBootstrapCallSite.setTarget(methodHandle);
  MutableCallSite.syncAll(new MutableCallSite[] {nestedBootstrapCallSite});
  return nestedBootstrapCallSite;
} else {
  return new ConstantCallSite(methodHandle);   // 正常路径:常量调用点
}

正常路径返回 ConstantCallSite,这是性能的关键。

ConstantCallSite 向 JVM 承诺:"我的目标永远不会变。" JIT 编译器可以因此把方法句柄完全内联进调用点 ,效果跟直接 invokestatic 几乎无异。

java 复制代码
} else {
  return new ConstantCallSite(methodHandle);
}

而嵌套路径只能用 MutableCallSite(因为它必须可变),并用 syncAll 强制让所有线程看到新目标。

这是一个用"极少数情况下的可变性"换"绝大多数情况下的极致性能"的设计。

5.9 状态簿记:AdviceBootstrapState

上一节提到的 recursionDepth,实现是这样的:

java 复制代码
class AdviceBootstrapState implements AutoCloseable {

  private static final ThreadLocal<Map<Key, AdviceBootstrapState>> stateForCurrentThread =
      ThreadLocal.withInitial(HashMap::new);

  private final Key key;
  private int recursionDepth;
  @Nullable private MutableCallSite nestedCallSite;

  /**
   * We have to eagerly initialize to not cause a lambda construction during enter(...).
   */
  private static final Function<Key, AdviceBootstrapState> CONSTRUCTOR = AdviceBootstrapState::new;

  private AdviceBootstrapState(Key key) {
    this.key = key;
    // enter will increment it by one, so 0 is the value for non-recursive calls
    recursionDepth = -1;
  }

四个细节:

① ThreadLocal<Map<Key, State>> ------ 按线程追踪。因为 bootstrap 是同步发生的,线程本地就够。

② Key 由五元组构成 ------ (instrumentedClass, moduleClassName, adviceClassName, adviceMethodName, adviceMethodDescriptor)。这是"一条 INVOKEDYNAMIC 指令"的唯一标识。同一个线程可能同时在 bootstrap 多条不同的指令(比如嵌套的不同模块),所以需要 Map 而不是单个状态。

③ CONSTRUCTOR 被提成静态常量 ------ 注释解释了原因:

We have to eagerly initialize to not cause a lambda construction during enter(...).

方法引用 AdviceBootstrapState::new 在第一次求值 时会走 LambdaMetafactory,而 LambdaMetafactory 可能被 lambda instrumentation 增强,又触发嵌套 bootstrap。所以把它提成静态常量,在类初始化时就求值完,避开运行时构造 lambda 的风险。

④ initialize() 的预加载:

java 复制代码
static void initialize() {
  // Eager initialize everything because we could run into recursions doing this during advice
  // bootstrapping
  stateForCurrentThread.get();
  stateForCurrentThread.remove();
  try {
    Class.forName(Key.class.getName());
  } catch (ClassNotFoundException e) {
    throw new IllegalStateException(e);
  }
}

在 Agent 启动早期(IndyBootstrap 静态块里)就主动把 ThreadLocal 的初始化和 Key 类加载都跑一遍。目的是"把可能递归的路径提前走完",让真正 bootstrap 时这些路径都已经预热,不会再触发类加载。

⑤ 引用计数式的 close:

java 复制代码
@Override
public void close() {
  if (recursionDepth == 0) {
    Map<Key, AdviceBootstrapState> stateMap = stateForCurrentThread.get();
    stateMap.remove(key);
    if (stateMap.isEmpty()) {
      // Do not leave an empty map dangling as thread local
      stateForCurrentThread.remove();
    }
  } else {
    recursionDepth--;
  }
}

最外层退出时清理,内层只递减。还有个贴心的小细节:Map 空了就把 ThreadLocal 也移掉,避免在线程池场景下留下一个空 Map 长期占着。

5.10 完整链路图

把上面所有章节拼起来:

scss 复制代码
【编译期】
  InstrumentationModuleInstaller.useIndy() == true
      │
      ▼
  IndyTypeTransformerImpl
      ├─ Advice.withCustomMapping().bootstrap(IndyBootstrapDispatcher::bootstrap, args)
      ├─ AdviceInliningPoolStrategy(inline=false)  → 偷偷把注解改成非内联
      └─ ForceDynamicallyTypedAssignReturnedFactory → 强制 DYNAMIC 赋值类型
      │
      ▼
  目标方法里生成:
      INVOKEDYNAMIC adviceMethod(...)
          IndyBootstrapDispatcher.bootstrap
          "com.example.MyModule", "(Lfoo/Bar;)V", "com.example.MyAdvice"

【运行期 · 首次执行】
  JVM 执行 INVOKEDYNAMIC
      │  (bootstrap 必须对目标类可见 → 放 Bootstrap CL)
      ▼
  IndyBootstrapDispatcher.bootstrap()                    [Bootstrap CL]
      │  volatile MethodHandle,由 Agent CL 静态块 init() 注入
      ▼
  IndyBootstrap.bootstrap()                              [Agent CL]
      │  (SecurityManager 分支:doPrivileged)
      ▼
  IndyBootstrap.bootstrapAdvice()
      │
      ├─ AdviceBootstrapState.enter(...)  → 引用计数 / 嵌套检测
      │     └─ 若嵌套 → 返回 MutableCallSite(noop),稍后 setTarget 修正
      │
      ├─ IndyModuleRegistry.getInstrumentationClassLoader(module, 目标CL)
      │     └─ ClassLoaderValue<Map<CL, InstrumentationModuleCL>>
      │           └─ 按 (目标CL, 模块CL) 二元组缓存,无则由 installModule 创建
      │
      ├─ instrumentationClassloader.loadClass(adviceClassName)
      │     └─ InstrumentationModuleClassLoader 三步委托:
      │           injected → agent CL → instrumented CL
      │
      ├─ instrumentationClassloader.getLookup()          ← LookupExposer 注入
      │     └─ .findStatic(adviceClass, adviceMethodName, actualType)
      │
      └─ return new ConstantCallSite(methodHandle)        ← 正常路径
            (或 MutableCallSite + setTarget + syncAll)    ← 嵌套修正

【运行期 · 后续每次执行】
  INVOKEDYNAMIC 已链接为 ConstantCallSite
      → JIT 直接内联 MethodHandle
      → 等价于一次普通 invokestatic

【注销 / 卸载】
  目标 CL 被 GC → ClassLoaderValue 里的 InstrumentationModuleCL 一并被回收

六、完整示例:从 executors 模块看一个真实的 InDy advice

光看框架代码容易晕,我们找一个真实模块走一遍。

6.1 为什么选 executors

instrumentation/executors 是异步上下文传播的核心模块(详见异步传播一文),它的 advice 逻辑复杂 ------ 要处理 CallDepth、lambda 包装、PropagatedContext 附加 ------ 正是那种"用 inline 写会很扭曲"的场景。

6.2 拦截点声明

java 复制代码
class JavaExecutorInstrumentation implements TypeInstrumentation {

  @Override
  public ElementMatcher<TypeDescription> typeMatcher() {
    return executorNameMatcher().and(isExecutor()); // Apply expensive matcher last.
  }

  @Override
  public void transform(TypeTransformer transformer) {
    transformer.applyAdviceToMethod(
        named("execute").and(takesArgument(0, Runnable.class)).and(takesArguments(1)),
        getClass().getName() + "$SetExecuteRunnableStateAdvice");
    // Netty uses addTask as the actual core of their submission; there are non-standard variations
    // like execute(Runnable,boolean) that aren't caught by standard instrumentation
    transformer.applyAdviceToMethod(
        named("addTask").and(takesArgument(0, Runnable.class)).and(takesArguments(1)),
        getClass().getName() + "$SetExecuteRunnableStateAdvice");
    // ... 还有 submit / schedule / invokeAny / invokeAll / invoke 等
  }

executorNameMatcher().and(isExecutor()) 这个顺序也有讲究 ------ 注释写着 Apply expensive matcher last,先把最贵的匹配器放后面,短路求值能省下大量开销。

6.3 advice 本体

java 复制代码
@SuppressWarnings("unused")
public static class SetExecuteRunnableStateAdvice {

  public static class ExecuteRunnableAdviceScope {
    private final CallDepth callDepth;
    @Nullable private final PropagatedContext propagatedContext;
    private final Runnable task;

    // ... 构造函数、getTask() ...

    public static ExecuteRunnableAdviceScope start(CallDepth callDepth, Runnable task) {
      if (callDepth.getAndIncrement() > 0) {
        return new ExecuteRunnableAdviceScope(callDepth, null, task);
      }
      Context context = Context.current();
      if (!ExecutorAdviceHelper.shouldPropagateContext(context, task)) {
        return new ExecuteRunnableAdviceScope(callDepth, null, task);
      }
      if (ContextPropagatingRunnable.shouldDecorateRunnable(task)) {
        task = ContextPropagatingRunnable.propagateContext(task, context);
        return new ExecuteRunnableAdviceScope(callDepth, null, task);
      }
      PropagatedContext propagatedContext =
          ExecutorAdviceHelper.attachContextToTask(context, RUNNABLE_PROPAGATED_CONTEXT, task);
      return new ExecuteRunnableAdviceScope(callDepth, propagatedContext, task);
    }

    public void end(@Nullable Throwable throwable) {
      if (callDepth.decrementAndGet() > 0) {
        return;
      }
      ExecutorAdviceHelper.cleanUpAfterSubmit(
          propagatedContext, throwable, RUNNABLE_PROPAGATED_CONTEXT, task);
    }
  }

  @AssignReturned.ToArguments(@ToArgument(value = 0, index = 1))
  @Advice.OnMethodEnter(suppress = Throwable.class, inline = false)
  public static Object[] enterJobSubmit(
      @Advice.This Object executor, @Advice.Argument(0) Runnable task) {
    CallDepth callDepth = CallDepth.forClass(executor.getClass());
    ExecuteRunnableAdviceScope adviceScope = ExecuteRunnableAdviceScope.start(callDepth, task);
    return new Object[] {adviceScope, adviceScope.getTask()};
  }

  @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false)
  public static void exitJobSubmit(
      @Advice.Argument(0) Runnable task,
      @Advice.Thrown @Nullable Throwable throwable,
      @Advice.Enter Object[] enterResult) {
    ExecuteRunnableAdviceScope adviceScope = (ExecuteRunnableAdviceScope) enterResult[0];
    adviceScope.end(throwable);
  }
}

注意 inline = false ------ 这就是模块在举牌子说"我可以走 InDy"。

6.4 InDy 加持下的好处,逐条对照

① ExecuteRunnableAdviceScope 是一个普通类,不是 helper 必须注入的那种

在 inline 模式下,ExecuteRunnableAdviceScope 会被当作 helper 类注入到用户的 ClassLoader 里(因为 advice 的字节码里引用了它)。在 InDy 模式下,它安静地待在 InstrumentationModuleClassLoader 里,用户应用永远看不见它。

对它而言,最大的好处是 ------ 它可以放心地引用 Agent 内部的类 (PropagatedContext、CallDepth、ContextPropagatingRunnable......),不用担心这些类跑到用户 CL 里引发冲突。

② @Advice.OnMethodEnter / OnMethodExit 之间的状态传递更自然

虽然这里仍然用了 @Advice.Enter 返回 Object[](这是 ByteBuddy 的 Advice API 约定,跟是否内联无关),但注意 ExecuteRunnableAdviceScope.start() 里可以写完整的业务逻辑,包括多个分支、提前返回、构造对象。在严格的内联约束下,这些代码往往要被拆成好多个 static 方法。

③ 异常堆栈里有名字了

如果 attachContextToTask 抛异常(理论上被 suppress = Throwable.class 兜住,但假设 suppress 被关掉),堆栈长这样:

css 复制代码
java.lang.RuntimeException: ...
    at io.opentelemetry.javaagent.instrumentation.executors.ExecutorAdviceHelper.attachContextToTask(...)
    at io.opentelemetry.javaagent.instrumentation.executors.JavaExecutorInstrumentation$SetExecuteRunnableStateAdvice$ExecuteRunnableAdviceScope.start(...)
    at ...SetExecuteRunnableStateAdvice.enterJobSubmit(...)     ← 有独立的栈帧!
    at java.base/java.util.concurrent.ThreadPoolExecutor.execute(...)

对比 inline 版本 ------ enterJobSubmit 那帧根本不存在,只能看到用户的方法直接抛了异常。

6.5 helper 类的可见性控制:injectedClassNames() / exposedClassNames()

InDy 不是"一刀切全部隔离"。有些 helper 类必须 出现在用户 CL 里,否则功能就断了。InstrumentationModule 为此提供了两个钩子:

java 复制代码
/**
 * Returns a list of helper class names that must be defined in the class loader of the
 * instrumented library instead of an isolated instrumentation module class loader.
 *
 * <p>Override this method when a helper class must access package-private members of an
 * instrumented library class.
 */
public List<String> injectedClassNames() {
  return emptyList();
}

/**
 * Returns a list of instrumentation helper class names that must be visible to the application
 * class loader while remaining loaded by an isolated instrumentation module class loader.
 *
 * <p>Override this method when an isolated helper class must be loaded through the application
 * class loader, for example when providing an SPI implementation loaded by {@link
 * java.util.ServiceLoader}.
 */
public List<String> exposedClassNames() {
  return emptyList();
}

两个名字很像,含义完全不同,务必分清:

方法 类定义在哪 应用能否 Class.forName 到 典型场景
injectedClassNames() 用户 CL(完全注入) 能 需要访问目标库的 package-private 成员
exposedClassNames() 隔离 CL(定义),但向用户 CL 暴露 能(通过 HelperInjector.addExposedClass) 需要被 ServiceLoader 按名字加载的 SPI 实现

exposedClassNames 的实现藏着一处 ClassLoader 泄漏防护:

java 复制代码
if (!forMuzzleCheck && instrumentedCl != null && !module.exposedClassNames().isEmpty()) {
  // Using a weak reference because HelperInjector.addExposedClass places the supplier into
  // a weak map where instrumentedCl is the key. We must ensure that the value of the map
  // does not strongly reference the key, otherwise we would leak class loaders.
  WeakReference<ClassLoader> classLoaderWeakReference = new WeakReference<>(this);
  for (String className : module.exposedClassNames()) {
    HelperInjector.addExposedClass(
        instrumentedCl,
        className,
        () -> {
          ClassLoader cl = classLoaderWeakReference.get();
          return cl != null ? tryLoad(cl, className) : null;
        });
  }
}

注释里解释了完整的原因链 :addExposedClass 把 supplier 放进一个以 instrumentedCl 为 key 的弱 Map;如果 supplier 强引用了 this(隔离 CL),而隔离 CL 的 parent 链上又引用着 instrumentedCl,就形成了 value → key 的强引用,弱 Map 永远不会释放 key,ClassLoader 泄漏。

修法 :把 this 换成一个 WeakReference,在 supplier 里 get() 一下,拿到 null 就返回 null。

这类 bug 的特点是:本地跑测试永远发现不了,只有长跑的应用在反复热部署时才爆内存。 而它只值三行代码 + 两行注释。

6.6 谁来创建隔离 ClassLoader:IndyModuleRegistry

java 复制代码
public class IndyModuleRegistry {

  private static final ConcurrentHashMap<String, InstrumentationModule> modulesByClassName =
      new ConcurrentHashMap<>();

  /**
   * Weakly references the {@link InstrumentationModuleClassLoader}s for a given application class
   * loader. For internal instrumentation, the agent classloader key is used, for extensions the key
   * is the extension classloader.
   * <p>
   * The {@link InstrumentationModuleClassLoader} are kept alive by a strong reference from the
   * instrumented class loader realized via {@link ClassLoaderValue}.
   */
  private static final ClassLoaderValue<Map<ClassLoader, InstrumentationModuleClassLoader>>
      internalOrExtensionsClassLoaders = new ClassLoaderValue<>();

缓存结构是 两级 Map:

ini 复制代码
internalOrExtensionsClassLoaders : ClassLoaderValue
  └─ key = 应用的 ClassLoader (instrumentedCL)
       └─ value = Map<ClassLoader, InstrumentationModuleClassLoader>
                    ├─ key = Agent CL      → 所有内置模块共用一个隔离 CL
                    ├─ key = ExtensionCL#1 → 扩展 1 的模块共用一个隔离 CL
                    └─ key = ExtensionCL#2 → 扩展 2 的模块共用一个隔离 CL

源码里的注释解释了这个分区的原因:

java 复制代码
// Because extensions have their own classloader, extension modules are loaded in a common
// InstrumentationModuleClassLoader per extension and instrumented CL (with extension CL as key)
// Non-extension modules are loaded in a common InstrumentationModuleClassLoader per
// instrumented CL (with agent CL as key)
moduleCl =
    internalOrExtensionsClassLoaders
        .computeIfAbsent(classLoader, ConcurrentHashMap::new)
        .computeIfAbsent(
            agentOrExtensionCl,
            k -> new InstrumentationModuleClassLoader(classLoader, agentOrExtensionCl));

设计意图很清晰:

  • 内置模块之间不需要互相隔离(它们是一起发布、一起测试的),共用一个隔离 CL 可以省下大量重复类加载
  • 不同扩展之间需要隔离(扩展是独立开发的,可能依赖同一个库的不同版本)
  • 所有隔离 CL 都对目标应用隔离

关于 ClassLoaderValue 这个弱引用机制,注释里那句 "kept alive by a strong reference from the instrumented class loader" 是关键:

javascript 复制代码
应用 CL (instrumentedCl)  ──强引用──►  ClassLoaderValue 里存的 Map
                                              └──► InstrumentationModuleClassLoader

引用方向是"从应用 CL 指向隔离 CL",而不是反过来。这样:

  • 应用 CL 活着 → 隔离 CL 被强引用,不会被回收 ✓
  • 应用 CL 被 GC(比如 WebApp 卸载)→ 隔离 CL 一并被回收 ✓

这是 Java Agent 里处理 ClassLoader 引用关系的基本功:永远让"生命周期更短的那个"持有强引用,否则就是泄漏。

6.7 模式选择:到底谁说了算

回到最开始那个问题:一个模块到底走 inline 还是 InDy?InstrumentationModuleInstaller.useIndy() 给出了一个四级优先级的决策链:

java 复制代码
private boolean useIndy(InstrumentationModule instrumentationModule) {
  // ① 用户/模块显式声明优先
  if (instrumentationModule instanceof ExperimentalInstrumentationModule) {
    HelperClassStrategy helperClassStrategy =
        ((ExperimentalInstrumentationModule) instrumentationModule).helperClassStrategy();
    switch (helperClassStrategy) {
      case INJECTED: return false;
      case ISOLATED: return true;
      case DEFAULT:  // fallthrough to the next check
    }
  }

  // ② 全局开关(v3-preview 会强制打开)
  if (!AgentCommonConfig.get().isV3Preview() && !AgentDistributionConfig.get().isIndyEnabled()) {
    return false;
  }
  // ③ Muzzle 编译期扫描的结论
  if (instrumentationModule instanceof InstrumentationModuleMuzzle) {
    Boolean useIsolated =
        ((InstrumentationModuleMuzzle) instrumentationModule).getMuzzleUseIsolatedHelperClasses();
    if (useIsolated != null) {
      return useIsolated;
    }
  }
  // ④ 运行时检查 advice 注解
  return adviceInspector.useIsolatedAdvice(instrumentationModule);
}

对应的枚举 API:

java 复制代码
enum HelperClassStrategy {
  /**
   * Depending on whether the instrumentation uses inline advice or not, helper classes are either
   * loaded in the same classloader as the instrumented library, or into an isolated classloader.
   */
  DEFAULT,
  /**
   * Helper classes are loaded in the same classloader as the instrumented library, and are
   * visible to the application.
   */
  INJECTED,
  /**
   * Helper classes are loaded into an isolated classloader, and aren't visible to the application.
   */
  ISOLATED
}

这两处的措辞值得玩味:INJECTED 和 ISOLATED 都明确提到了"用户应用是否可见" ------ 隔离的本质是可见性控制。

开关本身:

java 复制代码
configProperties.getBoolean("otel.javaagent.experimental.indy", v3Preview)

otel.instrumentation.common.v3-preview 一旦开启,indy 自动变 true。CI 里也是这么测的:

kotlin 复制代码
// conventions/src/main/kotlin/io.opentelemetry.instrumentation.javaagent-testing.gradle.kts
"-Dotel.javaagent.experimental.indy=$testIndy",

也就是说,每个 instrumentation 模块的测试,都会在 InDy 开/关两种模式下各跑一遍。 这不是可选的质量保证,是默认配置。

6.8 AdviceInspector:一个"心照不宣的约定"

第 ④ 级的决策逻辑,是整篇文章最有人情味的一段:

java 复制代码
for (String adviceClassName : adviceClassNames) {
  TypeDescription type = typePool.describe(adviceClassName).resolve();
  MethodList<MethodDescription.InDefinedShape> methodList = type.getDeclaredMethods();
  for (MethodDescription.InDefinedShape method : methodList) {
    for (AnnotationDescription annotation : method.getDeclaredAnnotations()) {
      if (Advice.OnMethodEnter.class.getName().equals(annotation.getAnnotationType().getName())
          || Advice.OnMethodExit.class.getName().equals(annotation.getAnnotationType().getName())) {
        AnnotationValue<?, ?> value = annotation.getValue("inline");
        // While it is possible to use non-inline advice with the non-indy instrumentation, by
        // adding the advice classes as helper classes, we assume that nobody relies on that.
        // Having a non-inline advice is treated as a marker that the instrumentation can use
        // indy.
        // Similarly inline advice could be used with indy instrumentation, but we assume that
        // if inline advice is used, then it is not indy ready.
        if (value.getState().isDefined() && Boolean.FALSE.equals(value.resolve())) {
          nonInlineCount++;
        } else {
          inlineCount++;
        }
      }
    }
  }
}
// mixed inline and non-inline advice
if (inlineCount > 0 && nonInlineCount > 0) {
  return null;
}
if (inlineCount > 0) {
  return false;
}
if (nonInlineCount > 0) {
  return true;
}

// no advice annotations were used so the instrumentation is using an AgentBuilder.Transformer
// injected class names makes sense only for indy instrumentation
if (!instrumentationModule.injectedClassNames().isEmpty()) {
  return true;
}
// exposed class names makes sense only for indy instrumentation
if (!instrumentationModule.exposedClassNames().isEmpty()) {
  return true;
}

// we aren't able to tell whether the instrumentation is ready for indy instrumentation or not
return null;

它做的事情就是数注解:

kotlin 复制代码
所有 advice 都是 inline=true   → false   (走传统模式)
所有 advice 都是 inline=false  → true    (走 InDy)
混合                            → null    → 上层兜底为 false(保守)
没有 advice 注解但声明了
  injected/exposedClassNames    → true    (只有 InDy 才支持这俩)
实在判断不出来                  → null    → false

最后那句 return result != null ? result : false; 带着注释:

java 复制代码
// we aren't able to tell whether the instrumentation is ready for indy instrumentation or not,
// so we assume that it is not

不确定时,保守。 这是安全关键代码该有的态度。

6.9 一个"活在 Java 7 之前"的兼容层

有个问题:INVOKEDYNAMIC 是 Java 7 才有的指令。如果用户的应用里有个 Java 6 编译的 class 文件 (比如某个老掉牙的库),JVM 会拒绝加载含有 INVOKEDYNAMIC 的它。

ForwardIndyAdviceTransformer 专门处理这个:

java 复制代码
private static boolean isAtLeastJava7(TypeDescription typeDescription) {
  ClassFileVersion classFileVersion = typeDescription.getClassFileVersion();
  return classFileVersion != null && classFileVersion.getJavaVersion() >= 7;
}

@Override
public DynamicType.Builder<?> transform(...) {
  // java 7+ class files already support invokedynamic
  if (isAtLeastJava7(typeDescription)) {
    return builder;
  }
  // ... 否则生成一个 forwarder 类
}

做法很聪明 ------ 把 INVOKEDYNAMIC 挪到一个新生成的、Java 8 版本的 helper 类里,目标方法里只放一条普通的 INVOKESTATIC:

java 复制代码
String adviceClassName = (String) bootstrapMethodArguments[2];
String forwardClassDotName =
    classLoader == null
        ? bootForwardClassPackage + ".Forward$$" + counter.incrementAndGet()
        : adviceClassName + "$$Forward$$" + counter.incrementAndGet();
// ...
Supplier<byte[]> forwardClassBytes =
    generateForwardClass(
        forwardClassSlasName, name, descriptor,
        bootstrapMethodHandle, bootstrapMethodArguments);
injectedClasses.put(forwardClassDotName, forwardClassBytes);

// replace invokedynamic with invokestatic to the generated forwarder class
// the forwarder class will contain the original invokedynamic instruction
super.visitMethodInsn(
    Opcodes.INVOKESTATIC, forwardClassSlasName, name, descriptor, false);
return;

生成的 forwarder 类长这样:

java 复制代码
public class MyAdvice$$Forward$$42 {
  public static void onEnter(Object target) {
    INVOKEDYNAMIC onEnter(Ljava/lang/Object;)V      // ← 原始的 indy 指令挪到这来了
        IndyBootstrapDispatcher.bootstrap
        "com.example.MyModule", "(Lfoo/Bar;)V", "com.example.MyAdvice"
  }
}
java 复制代码
private static Supplier<byte[]> generateForwardClass(...) {
  return () -> {
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, forwardClassSlasName, null,
        Type.getInternalName(Object.class), null);
    MethodVisitor mv = cw.visitMethod(
        Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, methodName, methodDescriptor, null, null);
    GeneratorAdapter ga = new GeneratorAdapter(
        mv, Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, methodName, methodDescriptor);
    ga.loadArgs();
    mv.visitInvokeDynamicInsn(   // ← 原样复制 indy 指令
        methodName, methodDescriptor, bootstrapMethodHandle, bootstrapMethodArguments);
    ga.returnValue();
    // ...

注意 forwarder 的类版本是硬编码的 Opcodes.V1_8 ------ 因为 Java 7+ 才支持 indy,用 V1_8 最省事。

代价 :多了一个 helper 类被注入到目标 CL (因为 INVOKESTATIC 的符号引用要能被目标方法解析)。但这是必要的妥协 ------ 毕竟 Java 7 之前的世界,本来就没有 indy 可用。

七、性能:一次"要不要迁移"的实测思考

InDy 引入了 MethodHandle、CallSite、额外的 ClassLoader 跳转,那它比 inline 慢吗?

分两个阶段看:

scss 复制代码
┌─────────────────────────────────────────────────────────────┐
│ 阶段一:链接(只发生一次)                                     │
│   bootstrap → 加载隔离 CL → loadClass(advice) → findStatic  │
│   成本:几十微秒 ~ 几毫秒(取决于类加载量)                     │
│   一个目标类的每条 INVOKEDYNAMIC 指令,只付一次                │
├─────────────────────────────────────────────────────────────┤
│ 阶段二:调用(每次执行)                                       │
│   ConstantCallSite → MethodHandle → JIT 内联                 │
│   成本:≈ invokestatic                                       │
└─────────────────────────────────────────────────────────────┘

关键在于 ConstantCallSite。

CallSite 家族有三个成员:

类型 可变性 JIT 优化空间
ConstantCallSite 永不可变 最大 ------ 可以完全内联并常量折叠
MutableCallSite 可变,需 syncAll 同步 中等 ------ JIT 可以内联,但每次 setTarget 后需失效重编
VolatileCallSite 可变,每次都读 最小 ------ 每次调用都要重新读取目标

InDy 正常路径返回 ConstantCallSite,等于给 JIT 写了一封保证书:"这个调用点永远不会变,放心内联。"

编译到机器码之后,INVOKEDYNAMIC + ConstantCallSite 的执行路径和直接调用一个静态方法几乎没有区别。

所以结论是:

InDy 的代价集中在"类加载那一刻",而它的收益是全生命周期的。 对于长跑的服务(Java Agent 的典型场景),这个交易稳赚。

项目自己的 benchmark-overhead 模块也在跑 InDy:

java 复制代码
// benchmark-overhead/src/test/java/io/opentelemetry/agents/Agent.java
Collections.singletonList("-Dotel.javaagent.experimental.indy=true")

因为性能开销是这个项目最核心的 SLO 之一,每个版本都要在 OpenJDK / OpenJ9、多种 Java 版本上量化。InDy 能不能成为默认,靠的不是"设计优雅",是 benchmark 数据。

八、总结与思考

8.1 一句话本质

InDy Advice 把"代码复制"换成了"方法句柄间接寻址",从而把 advice 及其依赖树从用户的 ClassLoader 里彻底解放出来。

ini 复制代码
inline:  TargetClass ──[复制字节码]──► Helper 类必须住在目标 CL
InDy:    TargetClass ──[INVOKEDYNAMIC]──► ConstantCallSite
                                              └─► MethodHandle
                                                    └─► 隔离 CL 里的 Advice

8.2 值得偷师的四个设计

① 用"两跳"跨越 ClassLoader 边界

bootstrap 方法必须在 Bootstrap CL(所有人都能看见),实现逻辑必须在 Agent CL(保持 Bootstrap 层极简)。MethodHandle 是连接两个世界的桥梁 ------ 它不是符号引用,是 JVM 的一等公民,不受 CL 可见性约束。

启发 :当你在跨 ClassLoader 通信时被"符号引用按定义类加载器解析"卡住,想想 MethodHandle 和 Lookup。它们是 JVM 留给这类问题的正规出口。

② 编译期做决策,运行期做执行

  • 编译期:Muzzle 扫描 advice 字节码,判断该模块能否隔离(getMuzzleUseIsolatedHelperClasses())
  • 运行期:IndyTypeTransformerImpl 只用做执行,不用重新分析

启发:把"需要看字节码才能做的判断"尽量提前到构建期。运行期只做最轻量的决策。

③ 永远有兜底路径

generateNoopMethodHandle 是整条链路的"安全气囊"。bootstrap 失败时返回 no-op,应用继续跑,只是没有遥测。

java 复制代码
if (callSite == null) {
  // The MethodHandle pointing to the Advice could not be created for some reason,
  // fallback to a Noop MethodHandle to not crash the application
  MethodHandle noop = generateNoopMethodHandle(adviceMethodType);
  callSite = new ConstantCallSite(noop);
}

启发:监控组件的第一职责是"不影响业务"。任何可能失败的操作,都必须有"什么都不做"的降级路径。

④ 把"递归风险"当成一等公民

AdviceBootstrapState.initialize() 预热 ThreadLocal、CONSTRUCTOR 提为静态常量避开 lambda 构造、IndyBootstrap.bootstrap 刻意用匿名类而非 lambda ------ 三处不同的地方,都在防同一个问题:bootstrap 期间的嵌套触发。

启发:当你的代码运行在"会被自己增强的 JVM"里,任何看起来无害的语言特性(lambda、反射、日志)都可能变成递归的入口。提前预热所有可能触发类加载的路径,是唯一的解法。

8.3 代价与局限

公平地说,InDy 不是免费的午餐:

代价 说明
启动成本 每条 INVOKEDYNAMIC 首次执行都要走一遍 bootstrap,比 inline 的"直接用"多了类加载开销
复杂度 多了一个 ClassLoader 层级、一套嵌套防护、一个 LookupExposer、一个 pre-Java7 兼容层
迁移成本 130 个模块要逐个改造,每个都要在 InDy 开关两侧跑测试
调试心智 出问题时,你得先搞清楚自己在哪个 CL、哪个 CallSite 状态上
需要 Java 7+ 目标类 老 class 文件要靠 ForwardIndyAdviceTransformer 打补丁

而且别忘了 5.2 节说过的:这是个进行中的迁移 。VirtualFieldChecker 的注释里那句 "it can be removed once the conversion is completed",说明连框架作者自己都还站在半路上。

8.4 回扣开头

回到文章开头那个 ClassCastException:

vbnet 复制代码
class com.example.tracing.UserContext cannot be cast to class com.example.tracing.UserContext

在 InDy 的世界里,这个异常根本不会发生 。因为 Agent 的 UserContext 从来没有离开过 InstrumentationModuleClassLoader,用户的应用从头到尾只认识自己那一份。

scss 复制代码
   用户应用 CL                     隔离 CL
┌──────────────────┐         ┌──────────────────┐
│ UserContext      │         │ UserContext      │
│  (应用的)        │         │  (Agent 的)     │
│                  │         │                  │
│  我的 execute()  │         │  advice 住这里    │
│    └─ INVOKEDYNAMIC ──────►│                  │
└──────────────────┘         └──────────────────┘
     两个 Universe,永不相交

Agent 的终极形态不是"随叫随到的助手",而是"住在隔壁房间的邻居" ------ 你可以打电话给它,但它永远不会进你家门,更不会动你的家具。

InDy Advice 就是那部电话。


Use invokedynamic to link the call site, and let the JVM do the rest. ------ 某种意义上,这也是 Java 平台二十年来最重要的设计哲学之一: 把"什么时候决定"和"决定什么"分开,你就能同时得到灵活性和性能。

相关推荐
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 实战
SimonKing2 小时前
SSE、WebSocket 连接丢 Redis 里?那可踩大坑了!
java·后端·程序员
YYYing.2 小时前
【设计模式系列 (五) 】原型模式
开发语言·后端·设计模式·原型模式·c/c++
FYKJ_20102 小时前
springboot鲜花销售系统91056-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·mysql·django·课程设计