SkyWalking字节码增强原理:Java Agent与Byte Buddy的协作
SkyWalking字节码增强原理:Java Agent与Byte Buddy的协作
适读人群:想了解APM工具底层实现、对Java Agent感兴趣的开发者 | 阅读时长:约18分钟
开篇故事
有个同学问我:"SkyWalking 我什么代码都没改,就在启动参数里加了一行 -javaagent:/path/to/skywalking-agent.jar,为什么所有的 HTTP 请求和数据库操作都自动被追踪了?"
这个问题触到了我最喜欢的技术点之一:Java Agent。
简单回答:SkyWalking 利用 Java Agent 的 Instrumentation API,在类被 JVM 加载时拦截,用 Byte Buddy 修改字节码,在目标方法的前后插入追踪代码。全程不需要修改源码,不需要重新编译,甚至不需要重启(attach 模式)。
这个机制是 APM 工具、代码覆盖率工具(JaCoCo)、热部署工具的共同基础。今天把它讲清楚。
一、Java Agent 基础
1.1 Java Agent 是什么
Java Agent 是一种特殊的 JAR 包,通过 -javaagent 参数或运行时 Attach 挂载到 JVM。它可以在类被加载到 JVM 之前,修改类的字节码。
1.2 两种挂载方式
1.3 Agent 的 premain 入口
// SkyWalking Agent 的入口(概念示意)
public class SkyWalkingAgent {
/**
* JVM 启动时调用(-javaagent 方式)
* 注意:这个方法必须是 public static void,参数固定
*/
public static void premain(String agentArgs, Instrumentation instrumentation) {
System.out.println("[SW] Agent 启动,参数: " + agentArgs);
// 向 JVM 注册类文件转换器
// 之后每个类被加载时,都会经过这个 transformer
instrumentation.addTransformer(new SkyWalkingTransformer(), true);
}
/**
* 运行时 Attach 时调用
*/
public static void agentmain(String agentArgs, Instrumentation instrumentation) {
premain(agentArgs, instrumentation);
// 重新转换已经加载的类
// instrumentation.retransformClasses(...)
}
}MANIFEST.MF 文件(Agent JAR 必须有):
Manifest-Version: 1.0
Premain-Class: org.apache.skywalking.apm.agent.SkyWalkingAgent
Agent-Class: org.apache.skywalking.apm.agent.SkyWalkingAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true二、Byte Buddy 字节码操作
2.1 为什么用 Byte Buddy 而不是 ASM
| 工具 | 使用难度 | 抽象层次 | 适用场景 |
|---|---|---|---|
| ASM | 高(需要了解JVM字节码指令) | 最低(操作指令级别) | 性能极致优化 |
| Javassist | 中(操作源码级别) | 中 | 简单增强 |
| Byte Buddy | 低(流式API,类型安全) | 高 | 框架开发、APM工具 |
SkyWalking 选择 Byte Buddy 的原因:API 友好、类型安全、性能好,特别适合框架级的字节码增强。
2.2 Byte Buddy 基础用法
// 添加Maven依赖
// <dependency>
// <groupId>net.bytebuddy</groupId>
// <artifactId>byte-buddy</artifactId>
// <version>1.14.10</version>
// </dependency>
package com.laozhang.agent.demo;
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 拦截 Spring MVC 的 Controller 方法
*/
public class SpringMvcInstrumentation {
public static void install(Instrumentation instrumentation) {
new AgentBuilder.Default()
// 匹配目标类:包名匹配
.type(ElementMatchers.nameStartsWith("org.springframework.web.servlet.DispatcherServlet"))
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
builder
// 匹配目标方法:doDispatch 方法
.method(ElementMatchers.named("doDispatch")
.and(ElementMatchers.takesArguments(2)))
// 用 Advice 插入代码
.intercept(Advice.to(DispatcherServletAdvice.class))
)
// 不允许某些Bootstrap类的修改(避免无限递归)
.disableClassFormatChanges()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.installOn(instrumentation);
}
}2.3 Advice 类:定义增强逻辑
package com.laozhang.agent.demo;
import net.bytebuddy.asm.Advice;
import jakarta.servlet.http.HttpServletRequest;
/**
* Advice 类:定义在目标方法前后插入的代码
* 注意:Advice 的方法必须是 static 的!
* Byte Buddy 会把 Advice 方法的字节码直接插入目标方法,而不是调用
*/
public class DispatcherServletAdvice {
/**
* @Advice.OnMethodEnter:在目标方法入口插入
* @Advice.Argument:绑定目标方法的参数
* @Advice.Local:局部变量,在 Enter 和 Exit 之间传递数据
*/
@Advice.OnMethodEnter(suppress = Throwable.class)
public static void onEnter(
@Advice.Argument(0) HttpServletRequest request,
@Advice.Local("startTime") long startTime
) {
startTime = System.currentTimeMillis();
System.out.println("[Agent] 请求开始 " + request.getMethod() + " " + request.getRequestURI());
}
/**
* @Advice.OnMethodExit:在目标方法出口插入(包括异常出口)
* @Advice.Thrown:捕获方法抛出的异常(null表示正常返回)
*/
@Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
public static void onExit(
@Advice.Argument(0) HttpServletRequest request,
@Advice.Local("startTime") long startTime,
@Advice.Thrown Throwable throwable
) {
long elapsed = System.currentTimeMillis() - startTime;
if (throwable != null) {
System.out.println("[Agent] 请求异常 " + request.getRequestURI()
+ " 耗时=" + elapsed + "ms 异常=" + throwable.getMessage());
} else {
System.out.println("[Agent] 请求成功 " + request.getRequestURI()
+ " 耗时=" + elapsed + "ms");
}
}
}三、完整实现:简版追踪 Agent
3.1 项目结构
simple-tracing-agent/
├── pom.xml
└── src/main/
├── java/com/laozhang/agent/
│ ├── AgentMain.java # premain/agentmain入口
│ ├── TracingAgentBuilder.java # Agent构建逻辑
│ └── advice/
│ ├── HttpAdvice.java # HTTP请求追踪
│ └── JdbcAdvice.java # JDBC追踪
└── resources/
└── META-INF/
└── MANIFEST.MF3.2 完整 Agent 入口
package com.laozhang.agent;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import java.lang.instrument.Instrumentation;
public class AgentMain {
public static void premain(String agentArgs, Instrumentation instrumentation) {
System.out.println("[SimpleAgent] 启动,版本 1.0.0");
// 安装 HTTP 追踪
installHttpTracing(instrumentation);
// 安装 JDBC 追踪
installJdbcTracing(instrumentation);
}
private static void installHttpTracing(Instrumentation instrumentation) {
new AgentBuilder.Default()
.ignore(ElementMatchers.nameStartsWith("com.laozhang.agent")) // 排除自身
.type(ElementMatchers.nameContains("DispatcherServlet"))
.transform((builder, type, loader, module, domain) ->
builder.method(ElementMatchers.named("doDispatch"))
.intercept(Advice.to(HttpAdvice.class))
)
.installOn(instrumentation);
}
private static void installJdbcTracing(Instrumentation instrumentation) {
new AgentBuilder.Default()
.ignore(ElementMatchers.nameStartsWith("com.laozhang.agent"))
// 拦截 JDBC PreparedStatement.execute 方法
.type(ElementMatchers.isSubTypeOf(java.sql.PreparedStatement.class))
.transform((builder, type, loader, module, domain) ->
builder.method(ElementMatchers.nameStartsWith("execute"))
.intercept(Advice.to(JdbcAdvice.class))
)
.installOn(instrumentation);
}
}3.3 JDBC 追踪 Advice
package com.laozhang.agent.advice;
import net.bytebuddy.asm.Advice;
public class JdbcAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
public static void onEnter(
@Advice.This java.sql.PreparedStatement statement,
@Advice.Local("startTime") long startTime
) {
startTime = System.currentTimeMillis();
// 尝试获取 SQL(不同驱动实现方式不同)
try {
System.out.println("[Agent-JDBC] SQL执行开始: " + statement.toString());
} catch (Exception ignored) {}
}
@Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
public static void onExit(
@Advice.Local("startTime") long startTime,
@Advice.Thrown Throwable throwable
) {
long elapsed = System.currentTimeMillis() - startTime;
if (throwable != null) {
System.out.println("[Agent-JDBC] SQL执行失败 耗时=" + elapsed + "ms");
} else {
System.out.println("[Agent-JDBC] SQL执行成功 耗时=" + elapsed + "ms");
}
}
}四、踩坑实录
坑1:Agent 修改了 Bootstrap 类导致无限递归
症状:JVM 启动后立即 StackOverflowError 或死锁。
根因:Agent 拦截了系统级类(如 java.lang.String、ClassLoader),而 Agent 自己的逻辑也依赖这些类,形成无限递归。
解决:用 AgentBuilder.ignore() 排除 JDK 核心包:
new AgentBuilder.Default()
.ignore(ElementMatchers.nameStartsWith("java.")
.or(ElementMatchers.nameStartsWith("sun."))
.or(ElementMatchers.nameStartsWith("com.sun."))
.or(ElementMatchers.nameStartsWith("com.laozhang.agent"))) // 排除自身!坑2:Advice 类里用了业务依赖,ClassNotFoundException
症状:Agent 启动时报 ClassNotFoundException,找不到某个依赖类。
根因:Agent 的类加载器和应用的类加载器不同,Agent 看不到应用的依赖。
解决方案:
- 把 Agent 需要的依赖打进 Agent JAR(fat jar)
- 或者用 Shade 插件,把依赖 relocate 到不冲突的包名
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<relocations>
<relocation>
<pattern>net.bytebuddy</pattern>
<shadedPattern>com.laozhang.agent.bytebuddy</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>坑3:@Advice.Local 变量类型必须和实际类型完全一致
症状:Agent 启动时报 IllegalStateException: Variable with name 'startTime' is not defined。
根因:@Advice.Local 在 Enter 和 Exit 方法里必须用完全一致的类型声明,哪怕类型可以自动装箱,也必须用相同的原始类型。
// Enter 里声明了 long
@Advice.Local("startTime") long startTime
// Exit 里必须也是 long,不能是 Long
@Advice.Local("startTime") long startTime // ✅
@Advice.Local("startTime") Long startTime // ❌ 类型不一致,报错五、总结与延伸
Java Agent + Byte Buddy 的威力在于:在不修改源码、不重新编译的情况下,对任意 Java 类进行行为增强。
这套技术的应用场景:
- APM 追踪(SkyWalking、Pinpoint、Jaeger Java Agent)
- 代码覆盖率(JaCoCo)
- 热部署(JRebel、DCEVM)
- 安全扫描(RASP:运行时应用自我保护)
- 性能分析(Arthas 的某些功能)
关键要点:
premain是 Agent 的入口,通过Instrumentation注册转换器- Byte Buddy 的
AgentBuilder负责匹配目标类和方法 @Advice把增强代码内联到目标方法(性能比反射调用好)- 必须排除 JDK 核心类和 Agent 自身,避免无限递归
