Java Agent 开发实战——从零写一个 Java 探针,插桩、监控、动态修改
Java Agent 开发实战——从零写一个 Java 探针,插桩、监控、动态修改
适读人群:有一定 JVM 基础,想了解 APM、字节码增强原理的 Java 开发者 | 阅读时长:约 18 分钟 | 核心价值:动手写一个可运行的 Java Agent,真正理解 APM 工具的底层机制
用了好几年 SkyWalking、Arthas,直到 2023 年我们需要给一个老系统做无侵入监控时,我才第一次认真研究 Java Agent 的开发。
那个老系统代码很乱,改动有风险,业务方不让动源码。但我们需要监控每个关键方法的耗时和参数。
最后用 Java Agent + Byte Buddy 做了一个轻量级探针,两周内上线,完全无侵入,效果比预期好。
这篇文章把整个开发过程记录下来,每个步骤都有可运行的代码。
Java Agent 的两种加载方式
方式一:启动时加载(premain)
在 JVM 启动时通过 -javaagent:agent.jar 参数加载,最常用:
java -javaagent:/path/to/my-agent.jar=param1=value1 -jar app.jar方式二:运行时动态 attach(agentmain)
对已经在运行的 JVM 动态加载 Agent,不需要重启,Arthas 用的就是这个:
# 伪代码:Arthas 动态 attach 的原理
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent("/path/to/arthas-agent.jar");
vm.detach();我们这次用的是 premain 方式,因为是新部署的实例,可以加参数。
第一步:写最简单的 Agent
MANIFEST.MF(必须有):
Premain-Class: com.example.agent.MyAgent
Agent-Class: com.example.agent.MyAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: trueAgent 主类:
package com.example.agent;
import java.lang.instrument.Instrumentation;
/**
* Java Agent 入口类
* premain:JVM 启动时调用(-javaagent 参数)
* agentmain:动态 attach 时调用
*/
public class MyAgent {
/**
* JVM 启动时调用,在 main 方法之前
* @param agentArgs -javaagent:jar=xxx 中 = 后面的参数
* @param inst Instrumentation 对象,Agent 的核心接口
*/
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] 探针启动,参数: " + agentArgs);
// 打印已加载的类数量(验证 Agent 工作了)
System.out.println("[Agent] 已加载类数量: " + inst.getAllLoadedClasses().length);
// 注册 ClassFileTransformer,用来插桩
inst.addTransformer(new MyClassTransformer(), true);
}
/**
* 动态 attach 时调用
*/
public static void agentmain(String agentArgs, Instrumentation inst) {
premain(agentArgs, inst);
}
}第二步:用 Byte Buddy 做字节码插桩
手写字节码(ASM)太低级,很容易出错。我选 Byte Buddy,它的 API 更友好,而且和 Java 代码风格接近。
引入依赖:
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.14.9</version>
</dependency>实现方法耗时监控的插桩逻辑:
package com.example.agent;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import java.lang.instrument.Instrumentation;
/**
* 使用 Byte Buddy 的 AgentBuilder,实现方法耗时监控
*/
public class TimingAgent {
public static void premain(String agentArgs, Instrumentation inst) {
// 解析参数:指定要监控的包名
String targetPackage = agentArgs != null ? agentArgs : "com.example";
new AgentBuilder.Default()
// 指定要匹配的类:目标包名下的所有类
.type(ElementMatchers.nameStartsWith(targetPackage))
// 指定增强规则:对所有方法应用 TimingAdvice
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
builder.visit(
Advice.to(TimingAdvice.class)
.on(ElementMatchers.isMethod()
.and(ElementMatchers.not(ElementMatchers.isConstructor()))
.and(ElementMatchers.not(ElementMatchers.isAbstract())))
)
)
.installOn(inst);
System.out.println("[TimingAgent] 已启动,监控包: " + targetPackage);
}
}Advice 类(定义插桩逻辑):
package com.example.agent;
import net.bytebuddy.asm.Advice;
/**
* 方法耗时监控的 Advice
* @Before 在方法进入时执行,@After 在方法退出时执行(包括异常退出)
*/
public class TimingAdvice {
/**
* 在方法执行前执行
* @return 返回值会通过 @Advice.Enter 传递给 @After 方法
*/
@Advice.OnMethodEnter
public static long onEnter(
@Advice.Origin String methodName) { // @Origin 获取方法完整签名
return System.nanoTime(); // 记录开始时间
}
/**
* 在方法执行后执行(无论正常还是异常退出)
*/
@Advice.OnMethodExit(onThrowable = Throwable.class)
public static void onExit(
@Advice.Origin String methodName,
@Advice.Enter long startTime, // 从 onEnter 传递过来的开始时间
@Advice.Thrown Throwable throwable) { // 如果有异常,这里不是 null
long duration = System.nanoTime() - startTime;
long durationMs = duration / 1_000_000;
if (throwable != null) {
// 方法抛了异常
MetricsCollector.recordError(methodName, durationMs);
} else {
MetricsCollector.record(methodName, durationMs);
}
// 超过 100ms 的方法打一行日志
if (durationMs > 100) {
System.out.printf("[SLOW] %s 耗时 %dms%n", methodName, durationMs);
}
}
}第三步:指标收集器
package com.example.agent;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
/**
* 轻量级指标收集器,使用 LongAdder 降低并发竞争
*/
public class MetricsCollector {
// 方法名 -> 调用统计
private static final ConcurrentHashMap<String, MethodStats> STATS =
new ConcurrentHashMap<>();
public static void record(String method, long durationMs) {
STATS.computeIfAbsent(method, k -> new MethodStats()).record(durationMs);
}
public static void recordError(String method, long durationMs) {
STATS.computeIfAbsent(method, k -> new MethodStats()).recordError(durationMs);
}
public static void printReport() {
System.out.println("\n===== 方法耗时统计 =====");
STATS.entrySet().stream()
.filter(e -> e.getValue().totalCount.sum() > 0)
.sorted((a, b) -> Long.compare(b.getValue().totalTimeMs.sum(),
a.getValue().totalTimeMs.sum()))
.limit(20) // 只打前 20 个
.forEach(e -> {
MethodStats stats = e.getValue();
long count = stats.totalCount.sum();
long avgMs = count == 0 ? 0 : stats.totalTimeMs.sum() / count;
System.out.printf(" %-60s 调用次数=%-6d 平均耗时=%-5dms 错误次数=%d%n",
e.getKey(), count, avgMs, stats.errorCount.sum());
});
}
static class MethodStats {
final LongAdder totalCount = new LongAdder();
final LongAdder totalTimeMs = new LongAdder();
final LongAdder errorCount = new LongAdder();
void record(long durationMs) {
totalCount.increment();
totalTimeMs.add(durationMs);
}
void recordError(long durationMs) {
record(durationMs);
errorCount.increment();
}
}
}踩坑实录一:Agent JAR 要用 maven-shade-plugin 打包
Agent 的 JAR 需要是 fat jar(包含所有依赖),否则在目标 JVM 里找不到 Byte Buddy 的类。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Premain-Class>com.example.agent.TimingAgent</Premain-Class>
<Agent-Class>com.example.agent.TimingAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>踩坑实录二:插桩了 JDK 类,JVM 启动失败
我曾经把 nameStartsWith 的范围设得太宽,不小心插桩了 JDK 内部类(java.lang.*),JVM 直接启动失败,报栈溢出。
因为 Advice 代码里调用了 System.nanoTime(),而 System 类本身也被插桩了,形成了无限递归。
解法:明确排除 JDK 类和 Agent 自己的类:
new AgentBuilder.Default()
.ignore(ElementMatchers.nameStartsWith("java.")
.or(ElementMatchers.nameStartsWith("sun."))
.or(ElementMatchers.nameStartsWith("com.sun."))
.or(ElementMatchers.nameStartsWith("com.example.agent."))) // 不要插桩自己!
.type(ElementMatchers.nameStartsWith("com.example.business"))
// ...踩坑实录三:动态 attach 后,Advice 里的 System.out 输出到了哪里?
用 agentmain 动态 attach 时,System.out 输出到了目标 JVM 的标准输出,而不是你的 attach 程序的标准输出。
这意味着你要在目标服务的日志里找输出,不是在你的控制台里。第一次这么做的时候我以为 Agent 没工作,找了半天才发现。
这个探针可以作为生产可用的起点。在这个基础上可以继续扩展:上报到监控系统(Prometheus、StatsD)、记录方法参数(注意脱敏)、按阈值告警等。
核心原理搞清楚了,扩展就是体力活了。
