JavaAgent与Instrumentation机制
# 29.JavaAgent与Instrumentation机制
# 目录介绍
- 1. 案例引入
- 2. Java Agent 全景图
- 3. premain 启动期 Agent
- 4. agentmain 运行期 Agent
- 5. Instrumentation 核心 API
- 6. JVMTI 与 Native Agent
- 7. Arthas 黑盒揭秘
- 8. 手撕热更新 Agent
- 9. 生产场景与未来
# 1. 案例引入
# 1.1 Arthas 一行命令 watch 线上任意方法
线上某个支付接口偶发返回 null,但本地复现不出来——传统做法是加日志、重新发布、等问题再现。用 Arthas 只需 30 秒:
$ ./as.sh
[INFO] arthas-boot version: 3.7.2
[INFO] Found existing java process, please choose one and hit RETURN.
* [1]: 12345 com.example.PaymentApp
[arthas@12345]$ watch com.example.PaymentService pay '{params, returnObj, throwExp}' \
-x 3 -n 5 -e
Press Q or Ctrl+C to abort.
Affect(class count: 1 , method count: 1) cost in 89 ms.
ts=2026-05-29 18:42:11; [cost=12.3ms] result=@ArrayList[
@Object[][[@Long[1001L], @BigDecimal[99.50]]],
null, ← ★ 看到了!返回 null
@NullPointerException[NullPointerException:
Cannot invoke "User.getBalance()" because "user" is null]
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
疑惑:
- Arthas 没改我的源码也没重启 JVM,怎么"看到"方法的实参/返回值/异常?
watch命令是怎么"附加"到运行中的 JVM 的?- 为什么 IDEA Debug 必须事先
--debug启动 JVM,Arthas 却能 attach 到任意 JVM?
# 1.2 JRebel 不重启 JVM 热更新业务代码
// 改一行业务代码
public class OrderService {
- public BigDecimal discount() { return BigDecimal.ZERO; }
+ public BigDecimal discount() { return new BigDecimal("0.15"); } // ★ 改了
}
2
3
4
5
没有 JRebel:编译 → 重启 JVM(30 秒~几分钟)→ 等待 Spring 容器初始化 → 再次进入测试场景。 有 JRebel:编译保存即生效,JVM 不重启、Spring 不重启、连接池不重启——继续点几下浏览器就能看到效果。
疑惑:
- JVM 加载过的类不是"只能加载一次"吗?JRebel 怎么换实现?
- 为什么 IDEA 自带的 HotSwap 只能改方法体,JRebel 还能加字段加方法?
- JRebel 没改我的 .java,运行中的 .class 是怎么"换"的?
# 1.3 我们要回答什么
第 33 篇是卷四第 4 篇——承接 32 篇 ByteBuddy AgentBuilder/Mockito inline mock 的钩子,把"无侵入字节码增强"的基础设施一次讲透:
Java Agent 生态全景:
┌── premain(启动期) → JaCoCo / SkyWalking / NewRelic
Java Agent ───────────┤
(java 实现) └── agentmain(运行期)→ Arthas / Mockito inline / JRebel
↑
│ 通过 Attach API
│
┌── JVMTI(C/C++ 实现) → jdwp / JFR / async-profiler
Native Agent ─────────┤
└── -agentlib:xxx → ZGC profiler / HSDB
2
3
4
5
6
7
8
9
10
带着这个目标回答 6 个核心问题:
追问 ①:premain vs agentmain 到底差在哪? → §3、§4
追问 ②:Attach API 怎么"穿透"到另一个 JVM? → §4.1、§4.2
追问 ③:addTransformer/retransform/redefine 区别? → §5
追问 ④:类重定义为什么不能加字段? → §5.4
追问 ⑤:Arthas 的 watch 究竟改了什么? → §7.2
追问 ⑥:JRebel 凭什么能加字段? → §8.3
2
3
4
5
6
本篇路线:
全景图 (第2章) ←—— 总览四种 Agent 形态
↓
premain (第3章) ←—— 启动期 Agent 三件套
↓
agentmain (第4章) ←—— 运行期 Attach API 黑魔法
↓
Instrumentation API (第5章) ←—— 核心三方法 + 兼容性约束
↓
JVMTI (第6章) ←—— 浅讲 Native Agent
↓
Arthas 揭秘 (第7章) ←—— 真实生产工具拆解
↓
手撕热更新 Agent (第8章) ←—— 综合实战
↓
生产 + 未来 + 哲学 (第9章)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2. Java Agent 全景图
# 2.1 四种 Agent 形态
| 形态 | 触发时机 | 入口 | 典型工具 |
|---|---|---|---|
| premain Java Agent | JVM 启动时 | premain(String, Instrumentation) | JaCoCo / SkyWalking / NewRelic |
| agentmain Java Agent | JVM 运行中 attach | agentmain(String, Instrumentation) | Arthas / Mockito inline / JRebel |
| JVMTI Native Agent | 启动 + 运行 | C/C++ Agent_OnLoad | jdwp / JFR / async-profiler |
| JVM 内置 Agent | JVM 自身实现 | 不可见 | ZGC profiler / HotSpot Serviceability Agent |
两条核心维度:
- 生效时机:启动期(premain)vs 运行期(agentmain/attach)
- 实现语言:Java(Instrumentation API) vs C/C++(JVMTI)
# 2.2 Java Agent vs Native Agent
Java Agent Native Agent (JVMTI)
开发语言 Java C/C++
打包 .jar .so / .dll / .dylib
启动方式 -javaagent:xxx.jar -agentpath:xxx.so
或 attach API
能力范围 类字节码增强 字节码 + 内存 + GC + 线程 + 锁
性能开销 中等(有 Java 栈) 低(直接在 JVM 进程内)
实现复杂度 ★★(Instrumentation) ★★★★★(JVMTI)
代表 JaCoCo/Arthas/SkyWalking jdwp/JFR/async-profiler
2
3
4
5
6
7
8
9
95% 的工程场景用 Java Agent 就够——只有 profiler / debugger 这类需要"看到 JVM 内部状态"的工具才用 JVMTI。
# 2.3 与字节码增强框架的关系
使用层
Arthas / SkyWalking / Mockito / JRebel / JaCoCo
↓
┌─────────────┐
│ Java Agent │ ← 本篇
│ (premain / │
│ agentmain) │
└──────┬──────┘
↓
┌─────────────────┐
│ Instrumentation │ ← 本篇核心 API
│ API │
└──────┬──────────┘
↓
┌──────────────────────┐
│ ASM / ByteBuddy / │ ← 第 32 篇
│ Javassist │
└──────────────────────┘
↓
┌──────────────────────┐
│ Class 文件结构 │ ← 第 13 篇
└──────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java Agent 只提供"何时增强、增强哪些类";字节码框架(ByteBuddy 等)负责"怎么生成新字节码"——两者协作完成无侵入增强。
# 3. premain 启动期 Agent
# 3.1 MANIFEST.MF 三大配置
写一个最简单的 premain Agent 必须打包成 jar 且 MANIFEST.MF 含三个关键字段:
Manifest-Version: 1.0
Premain-Class: com.example.MyAgent ← 必须,premain 入口类
Can-Redefine-Classes: true ← 是否允许 redefineClasses
Can-Retransform-Classes: true ← 是否允许 retransformClasses
Boot-Class-Path: lib/byte-buddy.jar ← 可选,把依赖加入 BootstrapClassLoader
2
3
4
5
Boot-Class-Path 的关键作用:增强 java.lang.* 等核心类时,拦截器代码必须由 BootstrapClassLoader 加载(否则触发 NoClassDefFoundError)。
# 3.2 premain 方法签名与启动顺序
public class MyAgent {
// 签名 ① 推荐
public static void premain(String agentArgs, Instrumentation inst) { ... }
// 签名 ② 兼容
public static void premain(String agentArgs) { ... }
}
2
3
4
5
6
7
8
JVM 启动期完整顺序:
1. JVM 启动 → BootstrapClassLoader 初始化
2. 加载 java.lang.* 等核心类
3. 解析 -javaagent:xxx.jar 参数
4. 加载 Agent jar → 调用 premain ←——————— ★ 此时业务类还没加载
5. premain 内 inst.addTransformer(...) ←——— 注册类加载拦截器
6. 加载主类 main 类 + 业务类
7. 每个业务类加载时触发 Transformer ←——— 字节码增强发生在这里
8. 调用 main(String[] args)
2
3
4
5
6
7
8
核心要点:premain 早于业务类加载,所以可以拦截"全部类的首次加载"——这是 JaCoCo 能统计所有代码覆盖率的根本原因。
# 3.3 完整最小 Demo
目标:给所有类的所有 public 方法加上"开始 / 结束"日志。
// MyAgent.java
public class MyAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] premain started, args=" + agentArgs);
inst.addTransformer(new TimingTransformer(), true); // ★ 第二参 = canRetransform
}
}
// TimingTransformer.java
public class TimingTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain pd, byte[] classfileBuffer) {
// 跳过 JDK 内部类
if (className == null || className.startsWith("java/")
|| className.startsWith("sun/")) return null;
try {
return new ByteBuddy()
.redefine(classBeingRedefined != null ? classBeingRedefined
: new ByteArrayInputStream(classfileBuffer),
ClassFileLocator.Simple.of(className.replace('/', '.'), classfileBuffer))
.visit(Advice.to(TimingAdvice.class)
.on(ElementMatchers.isPublic().and(not(isConstructor()))))
.make()
.getBytes();
} catch (Exception e) {
return null; // ★ 失败返回 null = 使用原字节码
}
}
}
// TimingAdvice.java
public class TimingAdvice {
@Advice.OnMethodEnter
static long enter(@Advice.Origin String method) {
System.out.println("[ENTER] " + method);
return System.nanoTime();
}
@Advice.OnMethodExit
static void exit(@Advice.Origin String method, @Advice.Enter long start) {
System.out.println("[EXIT] " + method + " took " + (System.nanoTime() - start) + "ns");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
打包:
jar cfm my-agent.jar MANIFEST.MF com/example/*.class
# MANIFEST.MF 内容见 §3.1
2
启动:
java -javaagent:my-agent.jar -jar app.jar
# 所有 public 方法自动打印 ENTER/EXIT 日志
2
# 4. agentmain 运行期 Agent
# 4.1 Attach API 工作原理
agentmain Agent 的入口与 premain 几乎一样:
public class MyRuntimeAgent {
public static void agentmain(String args, Instrumentation inst) { ... }
}
2
3
但触发方式截然不同——通过 com.sun.tools.attach.VirtualMachine 主动 attach:
// 在另一个 JVM 进程中执行
VirtualMachine vm = VirtualMachine.attach("12345"); // ★ 目标 JVM 的 PID
vm.loadAgent("/path/to/my-agent.jar", "args");
vm.detach();
2
3
4
完整链路:
┌─────────────────────┐ ┌─────────────────────┐
│ 调用方 JVM │ │ 目标 JVM (PID=12345) │
│ │ │ │
│ VirtualMachine │ ① attach 请求 │ AttachListener │
│ .attach("12345") │ ───────────────→ │ 线程(按需启动) │
│ │ │ │
│ .loadAgent(jar) │ ② 命令通道 │ 解析命令 │
│ │ ←──────────────→ │ │
│ │ │ ③ 加载 jar │
│ │ │ ④ 反射调用 │
│ │ │ agentmain(...) │
│ │ │ │
│ .detach() │ │ ⑤ Instrumentation │
└─────────────────────┘ │ 已注入完成 │
└─────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 4.2 AttachListener 与 Unix Domain Socket
AttachListener 是 HotSpot 内置的"等待连接"线程——它默认不启动,只在被外部触发时按需启动:
Linux/macOS:
① 调用方在 /tmp/.attach_pid12345 创建一个空文件
② kill -SIGQUIT 12345 (把 SIGQUIT 信号发给目标 JVM)
③ 目标 JVM 的 signal handler 检测到 .attach_pidXXX 文件存在
④ 启动 AttachListener 线程
⑤ AttachListener 创建 Unix Domain Socket:/tmp/.java_pid12345
⑥ 调用方连接此 socket,发送 "load /path/to/agent.jar"
⑦ AttachListener 加载 jar 并调用 agentmain
Windows:
改用 Named Pipe \\.\pipe\javatool<pid> + DuplicateHandle
2
3
4
5
6
7
8
9
10
11
这就是为什么 jps/jstack/jcmd 这些工具都需要"看到目标 JVM 进程权限"——它们都通过这套机制 attach。
# 4.3 attach 一段代码到运行中的 JVM
把 §3.3 的 Agent 改成 agentmain,并写一个独立 attach 程序:
// MyRuntimeAgent.java(同一个 jar)
public class MyRuntimeAgent {
public static void agentmain(String args, Instrumentation inst) throws Exception {
System.out.println("[Agent] attached at runtime, args=" + args);
inst.addTransformer(new TimingTransformer(), true);
// ★ 关键:对已加载的类 retransform,让增强生效
Class<?>[] loaded = inst.getAllLoadedClasses();
List<Class<?>> targets = Arrays.stream(loaded)
.filter(c -> c.getName().startsWith("com.example"))
.filter(inst::isModifiableClass)
.toList();
inst.retransformClasses(targets.toArray(new Class[0]));
}
}
// MANIFEST.MF
// Agent-Class: com.example.MyRuntimeAgent ← 注意是 Agent-Class 不是 Premain-Class
// Can-Retransform-Classes: true
// AttachLauncher.java(独立执行)
public class AttachLauncher {
public static void main(String[] args) throws Exception {
String pid = args[0];
String agentPath = args[1];
VirtualMachine vm = VirtualMachine.attach(pid);
try {
vm.loadAgent(agentPath, "extra-args");
} finally {
vm.detach();
}
System.out.println("Agent attached to PID " + pid);
}
}
// 执行
$ java -cp tools.jar:my-agent.jar AttachLauncher 12345 /path/to/my-agent.jar
Agent attached to PID 12345
# 此时目标 JVM 的所有 com.example.* 方法都开始打印 ENTER/EXIT
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
这就是 Arthas 的核心机制——只是命令更丰富(详见 §7)。
# 5. Instrumentation 核心 API
# 5.1 addTransformer:埋下增强钩子
inst.addTransformer(ClassFileTransformer transformer, boolean canRetransform);
触发时机:
- 任意 ClassLoader 调用
defineClass加载新类时 - 调用
inst.retransformClasses(...)重新加载已有类时(需要 canRetransform=true)
Transformer 接口:
public interface ClassFileTransformer {
byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined, // ★ 首次加载时为 null
ProtectionDomain pd, byte[] classfileBuffer);
// 返回 null = 使用原字节码;返回 byte[] = 使用新字节码
}
2
3
4
5
6
# 5.2 retransformClasses:重新加载已有类
inst.retransformClasses(Class<?>... classes);
做的事:
- 获取每个类的原始字节码(首次加载时的 .class 内容)
- 依次调用所有已注册的 Transformer(链式处理)
- 把最终字节码替换 InstanceKlass 内部的方法字节码
关键限制:retransform 以"原始字节码"为起点——重复 retransform 不会叠加效果,每次都是从头开始变换。这是 Mockito inline 能多次切换 mock 状态的原因。
# 5.3 redefineClasses:直接替换字节码
inst.redefineClasses(new ClassDefinition(MyClass.class, newBytes));
与 retransform 的区别:
| 维度 | retransformClasses | redefineClasses |
|---|---|---|
| 输入 | 类列表(字节码由 Transformer 生成) | 类 + 完整新字节码 |
| 起点 | 原始字节码 | 你提供的字节码 |
| Transformer 链 | 会触发 | 不触发 |
| 适用 | 增强场景(多 Agent 协作) | 替换场景(JRebel 热更新) |
# 5.4 类重定义的兼容性约束
JVM 限制 redefine/retransform 时新旧字节码必须满足:
| 项目 | 允许变更 | 原因 |
|---|---|---|
| 方法体 | ✅ | 新指令直接替换 Code 属性 |
| 常量池 | ✅ 追加 | 老引用仍有效 |
| 异常表 | ✅ | 属于方法的 Code 属性 |
| 类访问标志 | ❌ | 影响 JIT 假设 |
| 父类 / 接口 | ❌ | 影响虚方法表 vtable |
| 添加字段 | ❌ | 影响对象内存布局 |
| 添加方法 | ❌ | 影响 vtable 索引 |
| 修改方法签名 | ❌ | 影响调用约定 |
致命限制:不能加字段、不能加方法——这是 IDEA HotSwap 只能改方法体的原因。JRebel 突破这个限制靠的是"自定义 ClassLoader + 类版本化"(详见 §8.3)。
JEP 159 (Enhanced Class Redefinition) 已规划但 25 年仍未实现:
目标:允许 redefine 时增加字段和方法
阻力:vtable / oop 内存布局重排,影响 JIT 已编译代码的回退
现状:仍由 JRebel/Spring Loaded 用第三方方案实现
2
3
4
# 6. JVMTI 与 Native Agent
# 6.1 JVMTI 接口体系
JVMTI(JVM Tool Interface)是 JVM 暴露给 C/C++ 的底层调试/profiler 接口:
// 入口函数(C 代码)
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
jvmtiEnv *jvmti;
vm->GetEnv((void**)&jvmti, JVMTI_VERSION_11);
// 申请能力
jvmtiCapabilities caps = {0};
caps.can_generate_method_entry_events = 1;
caps.can_generate_method_exit_events = 1;
jvmti->AddCapabilities(&caps);
// 注册回调
jvmtiEventCallbacks callbacks = {0};
callbacks.MethodEntry = &OnMethodEntry;
jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks));
// 启用事件
jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_METHOD_ENTRY, NULL);
return JNI_OK;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
JVMTI 能做"Java Agent 做不到的事":
- GC 全部事件(GC start/end、对象分配)
- 线程生命周期(thread start/end、monitor wait/notify)
- 栈遍历(任意时刻获取每个线程的完整栈)
- 方法 entry/exit 事件(无需字节码增强)
代价:用 C 写、易段错误、跨平台编译。
# 6.2 jdwp/JFR/profiler 都是 Native Agent
# IDEA Debug 启动 JVM 加的参数:
java -agentlib:jdwp=transport=dt_socket,server=y,address=*:5005 -jar app.jar
└─ jdwp.so → JVMTI → 转换为 JDWP 协议 → IDEA 通过 socket 通信
# Java Flight Recorder
java -XX:StartFlightRecording=duration=60s,filename=app.jfr -jar app.jar
└─ libjfr.so → JVMTI 内部接口 → 高性能事件采集
# async-profiler
java -agentpath:libasyncProfiler.so -jar app.jar
└─ JVMTI + perf_events → 火焰图
2
3
4
5
6
7
8
9
10
11
关于 JVMTI 完整体系,本专栏第 15 篇《JVM 性能诊断工具链》已详细展开。
# 7. Arthas 黑盒揭秘
# 7.1 启动流程:attach + Telnet/HTTP
$ ./as.sh
│
↓
arthas-boot.jar 主程序启动(独立 JVM)
│
↓
列出本机所有 java 进程,让用户选择目标 PID=12345
│
↓
通过 Attach API 把 arthas-agent.jar attach 到 PID=12345
│
↓
目标 JVM 内的 arthas-core 启动:
├── 启动 Telnet Server (默认 3658 端口)
├── 启动 HTTP Server (默认 8563 端口)
└── 注册 Instrumentation 钩子
│
↓
arthas-boot 通过 Telnet 连接目标 JVM 的 3658
│
↓
[arthas@12345]$ 进入交互式终端
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 7.2 watch/trace 的字节码层实现
watch com.X.Y method '{params, returnObj}' -x 3 的内部步骤:
① 解析命令 → 生成 Advice 类(动态生成)
class WatchAdvice {
@Advice.OnMethodEnter
static void enter(@Advice.AllArguments Object[] args) {
Watcher.recordParams(args);
}
@Advice.OnMethodExit
static void exit(@Advice.Return Object ret, @Advice.Thrown Throwable t) {
Watcher.recordResult(ret, t);
}
}
② 通过 ByteBuddy 把 Advice 织入 com.X.Y.method
new AgentBuilder.Default()
.type(ElementMatchers.named("com.X.Y"))
.transform((b,...) -> b.visit(Advice.to(WatchAdvice.class)
.on(ElementMatchers.named("method"))))
.installOn(instrumentation);
③ 调用 inst.retransformClasses(Class.forName("com.X.Y"))
↓
④ 业务代码每次调用 method() 触发 WatchAdvice
↓
⑤ Watcher 把数据通过环形队列发给 arthas-boot
↓
⑥ Telnet 终端实时打印
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
trace、tt、monitor 命令机制完全相同,只是 Advice 模板不同。
# 7.3 redefine/jad 的协作
Arthas 的"线上热修复"——jad 反编译 + 改源码 + mc 编译 + redefine 替换:
[arthas@12345]$ jad com.example.UserService
# 反编译当前 JVM 中加载的字节码 → /tmp/UserService.java
# 你修改 /tmp/UserService.java,比如修一个 NPE
[arthas@12345]$ mc /tmp/UserService.java -d /tmp
# 调用内嵌的 JavaCompiler 编译 → /tmp/UserService.class
[arthas@12345]$ redefine /tmp/UserService.class
# 调用 inst.redefineClasses(new ClassDefinition(UserService.class, newBytes))
# 注意限制:不能加字段、不能加方法(§5.4)
2
3
4
5
6
7
8
9
10
11
这是线上 P0 故障的救命稻草——不重启即可热修。
# 7.4 sc/sm 类信息查询机制
sc -d com.example.OrderService # 查看类详情
sm -d com.example.OrderService # 查看方法列表
2
实现:直接调用 inst.getAllLoadedClasses() 遍历 + 反射读取 Class metadata——无字节码增强,所以零开销。
# 8. 手撕热更新 Agent
# 8.1 需求与设计
实现一个简易热更新工具:监听本地 target/classes/ 目录,任何 .class 文件被修改都自动 redefine 到运行中的 JVM。
target/classes/com/example/OrderService.class ← 修改
↓
WatchService 触发
↓
读取新 byte[]
↓
Class.forName(name)
↓
inst.redefineClasses(...)
↓
下次方法调用即生效
2
3
4
5
6
7
8
9
10
11
# 8.2 50 行核心实现
public class HotReloadAgent {
public static void agentmain(String args, Instrumentation inst) throws Exception {
Path baseDir = Paths.get(args); // target/classes 路径
System.out.println("[HotReload] watching " + baseDir);
Thread watcher = new Thread(() -> watch(baseDir, inst), "hot-reload-watcher");
watcher.setDaemon(true);
watcher.start();
}
private static void watch(Path baseDir, Instrumentation inst) {
try {
WatchService ws = FileSystems.getDefault().newWatchService();
registerAll(baseDir, ws); // 递归注册所有子目录
while (true) {
WatchKey key = ws.take();
Path dir = (Path) key.watchable();
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() != StandardWatchEventKinds.ENTRY_MODIFY) continue;
Path classFile = dir.resolve((Path) event.context());
if (!classFile.toString().endsWith(".class")) continue;
reload(classFile, baseDir, inst);
}
key.reset();
}
} catch (Exception e) {
e.printStackTrace();
}
}
private static void reload(Path classFile, Path baseDir, Instrumentation inst) {
try {
byte[] newBytes = Files.readAllBytes(classFile);
// 由 baseDir 推算类名
String relative = baseDir.relativize(classFile).toString();
String className = relative.replace(File.separatorChar, '.')
.replaceAll("\\.class$", "");
Class<?> clazz = Class.forName(className);
inst.redefineClasses(new ClassDefinition(clazz, newBytes));
System.out.println("[HotReload] redefined " + className);
} catch (UnsupportedOperationException e) {
System.err.println("[HotReload] redefine failed (added field/method?): "
+ e.getMessage());
} catch (Exception e) {
e.printStackTrace();
}
}
private static void registerAll(Path dir, WatchService ws) throws IOException {
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes a)
throws IOException {
d.register(ws, StandardWatchEventKinds.ENTRY_MODIFY);
return FileVisitResult.CONTINUE;
}
});
}
}
// MANIFEST.MF
// Agent-Class: com.example.HotReloadAgent
// Can-Redefine-Classes: true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
使用:
// 在 IDEA 里写 attach 工具
VirtualMachine vm = VirtualMachine.attach("12345");
vm.loadAgent("hot-reload-agent.jar", "/path/to/target/classes");
vm.detach();
2
3
4
之后只要 mvn compile(或 IDEA 自动编译)— 修改即生效。这就是 IDEA HotSwap 的本质。
# 8.3 与 JRebel/Spring Loaded 的差距
我们 50 行实现 vs JRebel:
| 能力 | 我们的 HotReload | JRebel |
|---|---|---|
| 改方法体 | ✅ | ✅ |
| 加字段 | ❌(§5.4) | ✅ |
| 加方法 | ❌ | ✅ |
| 改父类 | ❌ | ✅ |
| Spring Bean 自动重建 | ❌ | ✅ |
| Hibernate Session 重建 | ❌ | ✅ |
| 反射调用兼容 | 部分 | ✅ |
JRebel 突破"不能加字段"限制的核心思路:
传统 redefine:
原 .class → JVM 内部 InstanceKlass → 直接替换字节码
问题:内存布局已固定,加字段会破坏 oop
JRebel 方案:
① 自定义 ClassLoader,每次"修改"加载一个**新版本类**(v1, v2, v3...)
② 老版本对象被新版本"代理"
③ 字段访问 → JVMTI redirect 到对应版本的字段表
④ 方法调用 → 走 invokedynamic 指向最新版本
代价:每个类有多个版本驻留内存、调用开销略高、与 JVM 强绑定
2
3
4
5
6
7
8
9
10
11
JRebel 的实现高度精妙也高度黑盒——这是它能商业化收费的根本原因。
# 9. 生产场景与未来
# 9.1 APM 探针/Mockito inline/JaCoCo 全部依赖 Agent
| 工具 | Agent 类型 | 关键 API | 用途 |
|---|---|---|---|
| JaCoCo | premain | addTransformer | 启动期注入覆盖率探针 |
| SkyWalking | premain | addTransformer + ByteBuddy | 启动期增强中间件类 |
| Pinpoint | premain | addTransformer | 同上 |
| NewRelic / DataDog APM | premain | addTransformer | 同上 |
| Mockito inline | agentmain | retransformClasses | 运行期 mock final 类 |
| JRebel | agentmain | 自定义 ClassLoader + retransform | 运行期热更新 |
| Arthas | agentmain | retransformClasses + redefineClasses | 线上诊断 + 热修复 |
| IDEA HotSwap | agentmain | redefineClasses | IDE 内热更新(仅方法体) |
结论:所有"无侵入字节码增强"的工程实现都基于本篇知识。
# 9.2 JDK 21 的安全警告与 JEP 451
JDK 21 开始,运行期 attach Agent 会打印警告:
WARNING: A Java agent has been loaded dynamically (/path/to/agent.jar)
WARNING: If a serviceability tool is in use, please run with
-XX:+EnableDynamicAgentLoading to hide this warning
WARNING: Dynamic loading of agents will be disallowed by default in a future release
2
3
4
JEP 451: Prepare to Disallow the Dynamic Loading of Agents(JDK 21 引入):
- 当前:仍可 attach,但有警告
- 未来某个版本:默认禁止运行期 attach,必须显式
-XX:+EnableDynamicAgentLoading - 更远:可能彻底移除
根因:Agent 拥有"修改任意类字节码"的超能力——恶意 attach 等同于代码执行漏洞。这与 Module System、Unsafe 限制、SecurityManager 弃用是同一波 Java 平台安全收紧的一部分。
对策:
- Mockito、Arthas 等工具会要求用户显式开启该参数
- 长期看应转向"启动期 Agent + 配置文件控制"模式
# 9.3 综合案例真相揭晓
① §1.1 Arthas 的 watch 真相:Arthas 通过 VirtualMachine.attach(pid) 把 arthas-agent.jar 推入目标 JVM(§4.1);arthas-core 拿到 Instrumentation 实例后,根据 watch com.X.Y method 命令动态生成 Advice 类,用 ByteBuddy 织入并 retransformClasses(§7.2);增强后的 method() 每次执行都把参数/返回值通过环形队列推送到 Telnet 终端。全程不需要修改业务代码、不需要重启 JVM、不需要事先开启 debug 模式——这就是 Java Agent 的工程价值。
② §1.2 JRebel 热更新真相:JRebel 也通过 agentmain 启动(IDEA 插件 attach);但它不直接调用 redefineClasses——因为 JVM 限制不能加字段加方法(§5.4)。JRebel 自己实现了版本化 ClassLoader——每次 "改 class" 等同于"加载一个新版本类",老对象通过 JVMTI 的字段重定向代理到新版本(§8.3)。这套机制不仅能加字段,还能联动 Spring 重新创建受影响的 Bean——所以它能收费。
③ 6 大追问全部作答:
| 追问 | 答案 | 章节 |
|---|---|---|
| ① premain vs agentmain | 启动期 vs 运行期 attach | §3、§4 |
| ② Attach API 穿透原理 | SIGQUIT + Unix Domain Socket | §4.2 |
| ③ 三大 API 区别 | 钩子 vs 重新加载 vs 直接替换 | §5 |
| ④ 不能加字段原因 | oop 内存布局 + vtable 索引固定 | §5.4 |
| ⑤ Arthas watch 改了什么 | ByteBuddy Advice 织入 + retransform | §7.2 |
| ⑥ JRebel 加字段秘诀 | 版本化 ClassLoader + JVMTI 重定向 | §8.3 |
# 9.4 设计哲学回扣
收官提炼三条工程哲学:
"hook 点设计"决定生态丰度:Java 之所以有 Arthas/JRebel/SkyWalking/JaCoCo 这种"开箱即用的强大工具生态",根本原因是 JVM 在两个关键时机暴露了ClassFileTransformer 钩子——启动期(premain)和运行期(agentmain)。每一个钩子都意味着一个生态位。优秀框架不是把功能做绝,而是把扩展点暴露好——Spring 的 BeanPostProcessor、Servlet 的 Filter、Netty 的 ChannelHandler、Java Agent 的 Transformer 都是同一种设计哲学。下次你做框架,先问自己:我留了哪些 hook 点?这些 hook 点足够强大吗?
"约束 = 可演进性"的辩证:你可能觉得"redefine 不能加字段"是 JVM 的弱点——但这恰恰是 JVM 强大的体现。如果允许任意修改类,那 JIT 编译过的代码、CHA(类层级分析)的假设、内联缓存(inline cache)全部要回退——性能会崩溃。JVM 选择"少而严的修改 + 让上层框架(JRebel)用 ClassLoader 扩展"——把"快"和"灵活"分到不同抽象层。这是 JEP 451 收紧 attach 的同样逻辑:拒绝某些自由 = 保护更多人的安全。设计 API 时,不要被"灵活性"绑架——明确的约束反而是抽象的礼物。
"无侵入"是基础设施工程师的最高荣誉:APM、覆盖率、热更新、mock final 类——这些功能都有一个共同特征:业务代码完全不需要知道它的存在。无侵入意味着上线零风险、回滚零成本、跨项目零修改——这正是中间件的圣杯。Java Agent 这套机制让你能为公司开发"完全透明"的工具:不需要业务团队配合、不需要修改业务代码、不需要协调发布。学会 Agent,意味着你拥有了"给整个公司所有 Java 服务赋能"的杠杆——这是一个普通业务程序员到平台工程师的关键跃迁。
# 9.5 速查表
MANIFEST.MF 关键字段:
Premain-Class: com.example.MyAgent # premain 入口
Agent-Class: com.example.MyAgent # agentmain 入口
Can-Redefine-Classes: true # 允许 redefineClasses
Can-Retransform-Classes: true # 允许 retransformClasses
Boot-Class-Path: lib/byte-buddy.jar # 加入 BootstrapClassLoader
2
3
4
5
启动方式:
# premain 启动期
java -javaagent:my-agent.jar=arg1,arg2 -jar app.jar
# JDK 21+ 显式允许运行期 attach
java -XX:+EnableDynamicAgentLoading -jar app.jar
# attach 工具
java -cp tools.jar AttachLauncher <pid> /path/to/agent.jar
2
3
4
5
6
7
8
Instrumentation 三大 API 选型:
增强首次加载的类 → addTransformer(transformer, true)
增强已加载的类 → retransformClasses(class)(重跑 Transformer 链)
直接替换字节码 → redefineClasses(new ClassDefinition(class, bytes))
查询 JVM 状态 → getAllLoadedClasses / getObjectSize / isModifiableClass
2
3
4
典型工具技术栈:
JaCoCo : premain + ASM
Arthas : agentmain + ByteBuddy + Telnet/HTTP
SkyWalking : premain + ByteBuddy + AgentBuilder
Mockito 5 : agentmain(自己 attach)+ ByteBuddy + retransform
JRebel : agentmain + 自定义 ClassLoader + JVMTI
JRE Debug : Native Agent (jdwp)
async-profiler: Native Agent + perf_events
2
3
4
5
6
7
下一篇进入 卷四第 34 篇:AOP 三种实现路线对比——承接本篇 Agent + ByteBuddy + redefineClasses 的钩子,把 AOP 的三大流派一次梳理清楚:JDK 动态代理(接口代理)、CGLIB(继承代理)、AspectJ(编译期/加载期织入),并深入 Spring AOP 内部如何根据 @EnableAspectJAutoProxy(proxyTargetClass=...) 选择代理方式、为什么 Spring 6 切换 ByteBuddy 替代 CGLIB、AspectJ LTW(Load-Time Weaving)与本篇 premain Agent 的关系——附 Spring AOP 源码级流程图与三种方案的性能/功能对照。