本节目标:把
TimingAgent打成可执行的 agent jar,用-javaagent在类加载时测量LibraryService.borrow,并说清 Instrumentation 的边界。
适用版本:JDK 25(Temurin 25.0.5);Byte Buddy 1.18.16。
承接的问题
3.2 已经能用 ASM 和 Byte Buddy 改写字节码,但改写发生在「我们主动加载某个类」的时候:
new ByteBuddy().subclass(FineCalculator.class)
.method(named("fineCents"))
.intercept(FixedValue.value(0))
.make()
.load(FineCalculator.class.getClassLoader(), WRAPPER);
这段代码的前提是:应用自己调用 load,生成一个新类。libraryd 想测的是已经在运行的 LibraryService.borrow,而且不想在业务代码里加 System.nanoTime(),也不想改 AppMain 的装配。需要的是一条在 main 之前就生效的钩子:JVM 启动时加载 agent jar,agent 注册一个类文件转换器,在目标类被加载时改写它的字节码。
这就是 java.lang.instrument 的用途。它回答三个问题:
- agent 的入口在哪里,jar 清单要写什么。
- 转换器怎样按类名匹配,Byte Buddy 的 Advice 怎样在方法进出打点。
- 哪些类能改、什么时候改、计时数字代表什么。
这一节会真正构建 libraryd-agent.jar,用 java -javaagent:libraryd-agent.jar -cp ... AgentMain 运行,并引用本机的真实输出。
premain 与 agent jar 清单
Java Agent 有两种入口:
premain(String args, Instrumentation inst):JVM 启动时、main之前执行。agentmain(String args, Instrumentation inst):通过 attach API 附加到已经运行的 JVM。
本节用 premain,因为它不需要 attach,也不依赖 byte-buddy-agent 的 attach 能力。入口类:
package com.plume.library.agent;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import java.io.File;
import java.lang.instrument.Instrumentation;
import java.nio.file.Files;
import static net.bytebuddy.matcher.ElementMatchers.named;
public final class TimingAgent {
public static void premain(String args, Instrumentation instrumentation) {
System.out.println("agent: premain installed");
File tempDir;
try {
tempDir = Files.createTempDirectory("libraryd-agent").toFile();
} catch (java.io.IOException e) {
throw new IllegalStateException("无法创建 Agent 临时目录", e);
}
new AgentBuilder.Default()
.with(new AgentBuilder.InjectionStrategy.UsingInstrumentation(
instrumentation, tempDir))
.disableClassFormatChanges()
.type(named("com.plume.library.domain.LibraryService"))
.transform((builder, type, classLoader, module, protectionDomain) ->
builder.visit(Advice.to(TimingAdvice.class).on(named("borrow"))))
.installOn(instrumentation);
}
public static final class TimingAdvice {
@Advice.OnMethodEnter
public static long enter() {
return System.nanoTime();
}
@Advice.OnMethodExit(onThrowable = Throwable.class)
public static void exit(@Advice.Enter long start,
@Advice.Origin("#m") String method) {
long micros = (System.nanoTime() - start) / 1_000L;
System.out.println("agent: " + method + " took " + micros + " µs");
}
}
}
几个关键点:
premain的第二个参数是Instrumentation实例,installOn(instrumentation)把转换器注册进去。.type(named("...LibraryService"))按类名匹配,不匹配的类原样通过。.transform(...)只对borrow方法应用Advice,其他方法不动。InjectionStrategy.UsingInstrumentation通过Instrumentation追加辅助类,避免把生成类直接塞进应用 classpath;它不会消除 Byte Buddy 初始化时对Unsafe的探测,这一点在下面的输出里还会看到。@Advice.OnMethodEnter返回long,@Advice.OnMethodExit用@Advice.Enter取回,两个方法通过这个值传递开始时间。@Advice.Origin("#m")取方法名,输出里就是borrow。
agent jar 的清单必须包含 Premain-Class,否则 JVM 不会把它当成 agent。Can-Retransform-Classes: true 允许后续对已加载类做重转换;本节只在加载时转换,但保留这个属性,方便以后扩展。
Manifest-Version: 1.0
Premain-Class: com.plume.library.agent.TimingAgent
Can-Retransform-Classes: true
Can-Redefine-Classes: true
实际打包时,libraryd-agent.jar 还要包含 Byte Buddy 的类。agent 的类加载器是系统类加载器的一部分,它看不到应用 classpath 上的 byte-buddy.jar,除非把依赖打进来,或者用 -Xbootclasspath/a 显式加入。最小可用做法是 fat jar:
javac --release 25 -cp byte-buddy-1.18.16.jar -d out $(find src -name '*.java')
cp -R out/. fat/
(cd fat && unzip -qo byte-buddy-1.18.16.jar -x 'META-INF/MANIFEST.MF')
jar --create --file libraryd-agent.jar \
--manifest META-INF/MANIFEST.MF -C fat .
本机检查清单:
unzip -p libraryd-agent.jar META-INF/MANIFEST.MF
本机输出:
Manifest-Version: 1.0
Premain-Class: com.plume.library.agent.TimingAgent
Can-Retransform-Classes: true
Can-Redefine-Classes: true
Created-By: 25.0.5 (Eclipse Adoptium)
Created-By 是 jar 工具自动加的,不影响功能。Premain-Class 必须和实际类名一致;写错类名时 JVM 会在启动阶段报 ClassNotFoundException,而不是等到 main。
Byte Buddy Advice:进入与退出计时
Advice 是 Byte Buddy 的「方法级注入」抽象:它把一段 Java 代码编译成字节码,插入目标方法的进入点和退出点,不需要手写 MethodVisitor。对本节来说,它比 ASM 更合适,因为:
- 只需要在方法前后加两行,不改变控制流。
@Advice.Enter自动把进入点的返回值传给退出点,不需要自己分配局部变量。onThrowable = Throwable.class让异常路径也执行退出逻辑。
目标类 LibraryService 刻意留了一个 2ms 的阻塞,模拟真实的数据库或远程调用:
package com.plume.library.domain;
public final class LibraryService {
public String borrow(String isbn) {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("borrow interrupted", e);
}
return "LOAN:" + isbn;
}
}
不带 agent 运行:
java -cp out com.plume.library.agent.AgentMain
本机输出:
main: LOAN:9780132350884
main: LOAN:9780132350885
带 agent 运行:
java -Dnet.bytebuddy.safe=true -javaagent:libraryd-agent.jar \
-cp out com.plume.library.agent.AgentMain
本机输出:
agent: premain installed
agent: borrow took 3182 µs
main: LOAN:9780132350884
agent: borrow took 2527 µs
main: LOAN:9780132350885
三点观察:
agent: premain installed出现在main之前,说明premain的时机正确。- 两行
agent: borrow took ...分别对应两次borrow,微秒数每次不同,但都在 2500–3200 附近,和Thread.sleep(2)的 2ms 加上调度开销相符。 - 不加
-Dnet.bytebuddy.safe=true时,本机会多出四条sun.misc.Unsafe::objectFieldOffset的终止弃用警告;Byte Buddy 1.18.16 在 JDK 25 上仍会触发这条路径,加这个属性后走安全实现,输出干净。警告不是错误,但会污染日志,生产环境要决定是抑制还是升级 Byte Buddy。
这个计时数字不是 borrow 的业务耗时。它包含 Thread.sleep(2)、方法调用、System.nanoTime() 两次调用和 Advice 插入的少量指令。要测纯计算,必须把阻塞去掉;要测端到端延迟,应该用 JFR 或 HTTP 层指标,而不是单个方法的 agent 计时。第 9 章会用 JMH 和 JFR 做正式测量。
在 libraryd 里落地
libraryd-agent 是独立 Maven 模块,依赖只有 Byte Buddy:
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.18.16</version>
</dependency>
打包插件用 maven-jar-plugin 写清单,用 maven-shade-plugin 把 Byte Buddy 打进来:
<archive>
<manifestEntries>
<Premain-Class>com.plume.library.agent.TimingAgent</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
启动脚本把 agent 加在 -javaagent 上,位置在 -cp 之前或之后都可以,但必须在主类之前:
java -Dnet.bytebuddy.safe=true \
-javaagent:libraryd-agent/target/libraryd-agent-1.0.0-SNAPSHOT.jar \
-cp libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar \
com.plume.library.AppMain
第二个落点是配置。premain 的 args 是 -javaagent:agent.jar=key=value 里等号后面的字符串。libraryd 可以用它指定目标方法,而不是把类名写死在 agent 里:
public static void premain(String args, Instrumentation instrumentation) {
String target = (args == null || args.isBlank())
? "com.plume.library.domain.LibraryService"
: args.split(",")[0];
// 用 target 构造匹配器
}
这样同一个 agent jar 可以测不同的服务,不需要重新打包。注意 premain 的 args 是原始字符串,不是自动解析的 Map;key=value 的拆分和校验要自己写。
第三个落点是失败隔离。agent 初始化失败不应该让整个应用起不来,除非计时是硬性合规要求。premain 里捕获异常并打印,不要 throw:
try {
install(instrumentation);
} catch (RuntimeException e) {
System.err.println("agent: install failed: " + e);
}
但要注意:如果 premain 抛出未捕获异常,JVM 会终止启动。计时 agent 是观测设施,应该尽量降级为「没装上,但应用能跑」;安全审计类 agent 则可能选择失败即停止,这是产品决策。
Instrumentation 的边界
时机。 premain 在 main 之前执行,但此时很多类还没加载。AgentBuilder 注册的是转换器,目标类第一次被加载时才触发转换。LibraryService 在 AgentMain 里第一次 new 时加载,所以能被转换。如果某个类在 agent 安装前就已经被加载(比如 agent 自己引用了它),加载时转换不会再生效,需要 retransformClasses。
重转换。 Instrumentation.retransformClasses(Class<?>...) 可以对已加载类重新执行转换器,但受两个限制:类不能被修改得「不兼容」(不能增删字段、不能改方法签名),并且 agent jar 清单要有 Can-Retransform-Classes: true。Can-Redefine-Classes: true 是更严格的「重定义」,允许改方法体但不能改结构。本节只在加载时转换,所以这两个属性是给以后用的。
类加载器。 agent 类和目标类可能由不同加载器加载。AgentBuilder 默认在目标类的加载器里注入辅助类;InjectionStrategy.UsingInstrumentation 通过 Instrumentation 追加 jar 到目标加载器的搜索路径。如果目标类是 bootstrap 加载的(比如 java.lang.String),注入和转换的规则又不一样,需要 appendToBootstrapClassLoaderSearch。本节目标是应用类,不涉及这条路径。
模块。 JDK 9 之后,agent 作为无名模块运行,默认只能访问导出给它的包。修改 java.base 里的类需要 --add-opens 或 Instrumentation.redefineModule。libraryd 的 agent 只改自己的 com.plume.library.domain,不受模块边界影响。
开销。 每次 borrow 调用都会执行两次 System.nanoTime() 和一次 println。println 本身可能比 2ms 的业务还慢,尤其是输出到终端时。生产环境的 agent 应该把计时写到环形缓冲区或 Micrometer,而不是每条都打印;采样率也要可配置。不要把本节的输出当成「低开销」的证明。
常见错误与边界
「清单里没有 Premain-Class。」 本机构造一个只有类的 jar 并运行:
jar --create --file no-premain.jar -C out .
java -javaagent:no-premain.jar -cp out com.plume.library.agent.AgentMain
本机输出:
Failed to find Premain-Class manifest attribute in no-premain.jar
Error occurred during initialization of VM
agent library failed Agent_OnLoad: instrument
JVM 在启动阶段直接失败,main 不会执行。先 unzip -p agent.jar META-INF/MANIFEST.MF 确认属性存在,再检查类名拼写。
「agent jar 没带 Byte Buddy。」 只把 TimingAgent.class 打进 jar,运行时会报:
Exception in thread "main" java.lang.NoClassDefFoundError: net/bytebuddy/matcher/ElementMatcher
agent 的类加载器看不到应用 classpath 上的依赖。用 shade 或手工 fat jar 把 byte-buddy 打进来;如果依赖很多,考虑 -Xbootclasspath/a 或独立的 agent classpath,但 fat jar 是最容易验证的方式。
「忘了 Can-Retransform-Classes。」 本机用一个只写了 Premain-Class 的 agent 调用 retransformClasses(String.class),输出:
java.lang.UnsupportedOperationException: retransformClasses is not supported in this environment
清单里加 Can-Retransform-Classes: true;如果还要改方法体之外的结构,评估 Can-Redefine-Classes。
「在 agent 里直接引用目标类。」 TimingAgent 如果写 LibraryService.class,会在 premain 阶段触发目标类加载,转换器还没注册,类就以原始字节码加载了。用字符串类名(named("..."))而不是类字面量,避免提前加载。
「把 println 的耗时算进业务。」 agent: borrow took 3182 µs 包含第一次 println 的类加载和 I/O;第二次 2527 µs 更接近真实。要比较优化前后,先预热、再采样多次、取分位数,不要拿单次输出下结论。
「在 macOS 上 attach 到别人的进程。」 agentmain 走 attach API 时,macOS 要求 attach 的进程与目标进程同用户,并且目标 JVM 没有禁用 attach。premain 不受这个限制,但需要重启应用。生产环境用 premain 意味着发布周期,用 agentmain 意味着运维权限和平台约束,两者要分开评估。
小结
- Java Agent 的
premain在main之前执行,入口类由 jar 清单的Premain-Class指定。 Can-Retransform-Classes: true允许后续重转换已加载类;只做加载时转换也必须写全清单。- Byte Buddy
AgentBuilder按类名匹配,Advice在方法进出打点,@Advice.Enter传递开始时间。 - agent jar 必须自带 Byte Buddy 依赖,否则
premain阶段抛NoClassDefFoundError。 - 计时包含阻塞、I/O 和打点开销,单次
µs不是业务性能指标;正式测量用 JMH/JFR。 - agent 是观测设施,初始化失败应尽量降级;是否让应用启动失败是产品决策。
第 3 章到这里结束。三节分别回答了「类从哪里来」「字节码长什么样」「什么时候改写」;第 4 章转向并发,从 Java 内存模型的可见性开始。
阅读导航:上一节:3.2 字节码入门:javap、常量池与 ASM/Byte Buddy · 下一节:4.1 可见性与重排序:volatile 与 happens-before 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。