传统字节码增强是"把代码抄进你家",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,而是:
- 调用你指定的 bootstrap 方法(一个普通的 Java 静态方法)
- 把
Lookup、方法名、方法类型、以及你塞的静态参数交给它 - bootstrap 方法返回一个
CallSite对象 CallSite里有一个MethodHandle------ 这才是真正的目标方法- 后续调用直接走这个 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
invokedynamicto link the call site, and let the JVM do the rest. ------ 某种意义上,这也是 Java 平台二十年来最重要的设计哲学之一: 把"什么时候决定"和"决定什么"分开,你就能同时得到灵活性和性能。