编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
        • 1. 案例引入
          • 1.1 Arthas 一行命令 watch 线上任意方法
          • 1.2 JRebel 不重启 JVM 热更新业务代码
          • 1.3 我们要回答什么
        • 2. Java Agent 全景图
          • 2.1 四种 Agent 形态
          • 2.2 Java Agent vs Native Agent
          • 2.3 与字节码增强框架的关系
        • 3. premain 启动期 Agent
          • 3.1 MANIFEST.MF 三大配置
          • 3.2 premain 方法签名与启动顺序
          • 3.3 完整最小 Demo
        • 4. agentmain 运行期 Agent
          • 4.1 Attach API 工作原理
          • 4.2 AttachListener 与 Unix Domain Socket
          • 4.3 attach 一段代码到运行中的 JVM
        • 5. Instrumentation 核心 API
          • 5.1 addTransformer:埋下增强钩子
          • 5.2 retransformClasses:重新加载已有类
          • 5.3 redefineClasses:直接替换字节码
          • 5.4 类重定义的兼容性约束
        • 6. JVMTI 与 Native Agent
          • 6.1 JVMTI 接口体系
          • 6.2 jdwp/JFR/profiler 都是 Native Agent
        • 7. Arthas 黑盒揭秘
          • 7.1 启动流程:attach + Telnet/HTTP
          • 7.2 watch/trace 的字节码层实现
          • 7.3 redefine/jad 的协作
          • 7.4 sc/sm 类信息查询机制
        • 8. 手撕热更新 Agent
          • 8.1 需求与设计
          • 8.2 50 行核心实现
          • 8.3 与 JRebel/Spring Loaded 的差距
        • 9. 生产场景与未来
          • 9.1 APM 探针/Mockito inline/JaCoCo 全部依赖 Agent
          • 9.2 JDK 21 的安全警告与 JEP 451
          • 9.3 综合案例真相揭晓
          • 9.4 设计哲学回扣
          • 9.5 速查表
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

JavaAgent与Instrumentation机制

# 29.JavaAgent与Instrumentation机制

# 目录介绍

  • 1. 案例引入
    • 1.1 Arthas 一行命令 watch 线上任意方法
    • 1.2 JRebel 不重启 JVM 热更新业务代码
    • 1.3 我们要回答什么
  • 2. Java Agent 全景图
    • 2.1 四种 Agent 形态
    • 2.2 Java Agent vs Native Agent
    • 2.3 与字节码增强框架的关系
  • 3. premain 启动期 Agent
    • 3.1 MANIFEST.MF 三大配置
    • 3.2 premain 方法签名与启动顺序
    • 3.3 完整最小 Demo
  • 4. agentmain 运行期 Agent
    • 4.1 Attach API 工作原理
    • 4.2 AttachListener 与 Unix Domain Socket
    • 4.3 attach 一段代码到运行中的 JVM
  • 5. Instrumentation 核心 API
    • 5.1 addTransformer:埋下增强钩子
    • 5.2 retransformClasses:重新加载已有类
    • 5.3 redefineClasses:直接替换字节码
    • 5.4 类重定义的兼容性约束
  • 6. JVMTI 与 Native Agent
    • 6.1 JVMTI 接口体系
    • 6.2 jdwp/JFR/profiler 都是 Native Agent
  • 7. Arthas 黑盒揭秘
    • 7.1 启动流程:attach + Telnet/HTTP
    • 7.2 watch/trace 的字节码层实现
    • 7.3 redefine/jad 的协作
    • 7.4 sc/sm 类信息查询机制
  • 8. 手撕热更新 Agent
    • 8.1 需求与设计
    • 8.2 50 行核心实现
    • 8.3 与 JRebel/Spring Loaded 的差距
  • 9. 生产场景与未来
    • 9.1 APM 探针/Mockito inline/JaCoCo 全部依赖 Agent
    • 9.2 JDK 21 的安全警告与 JEP 451
    • 9.3 综合案例真相揭晓
    • 9.4 设计哲学回扣
    • 9.5 速查表

# 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]
]
1
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"); }    // ★ 改了
}
1
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
1
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
1
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章)
1
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
1
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 篇
              └──────────────────────┘
1
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
1
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) { ... }
}
1
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)
1
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");
    }
}
1
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
1
2

启动:

java -javaagent:my-agent.jar -jar app.jar
# 所有 public 方法自动打印 ENTER/EXIT 日志
1
2

# 4. agentmain 运行期 Agent

# 4.1 Attach API 工作原理

agentmain Agent 的入口与 premain 几乎一样:

public class MyRuntimeAgent {
    public static void agentmain(String args, Instrumentation inst) { ... }
}
1
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();
1
2
3
4

完整链路:

┌─────────────────────┐                  ┌─────────────────────┐
│ 调用方 JVM           │                  │ 目标 JVM (PID=12345) │
│                     │                  │                     │
│ VirtualMachine      │   ① attach 请求  │  AttachListener     │
│   .attach("12345")  │ ───────────────→ │  线程(按需启动)   │
│                     │                  │                     │
│   .loadAgent(jar)   │   ② 命令通道     │  解析命令           │
│                     │ ←──────────────→ │                     │
│                     │                  │  ③ 加载 jar         │
│                     │                  │  ④ 反射调用          │
│                     │                  │     agentmain(...)  │
│                     │                  │                     │
│   .detach()         │                  │  ⑤ Instrumentation  │
└─────────────────────┘                  │     已注入完成      │
                                         └─────────────────────┘
1
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
1
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
1
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);
1

触发时机:

  • 任意 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[] = 使用新字节码
}
1
2
3
4
5
6

# 5.2 retransformClasses:重新加载已有类

inst.retransformClasses(Class<?>... classes);
1

做的事:

  1. 获取每个类的原始字节码(首次加载时的 .class 内容)
  2. 依次调用所有已注册的 Transformer(链式处理)
  3. 把最终字节码替换 InstanceKlass 内部的方法字节码

关键限制:retransform 以"原始字节码"为起点——重复 retransform 不会叠加效果,每次都是从头开始变换。这是 Mockito inline 能多次切换 mock 状态的原因。

# 5.3 redefineClasses:直接替换字节码

inst.redefineClasses(new ClassDefinition(MyClass.class, newBytes));
1

与 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 用第三方方案实现
1
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;
}
1
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 → 火焰图
1
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]$ 进入交互式终端
1
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 终端实时打印
1
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)
1
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     # 查看方法列表
1
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(...)
                                                ↓
                                          下次方法调用即生效
1
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
1
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();
1
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 强绑定
1
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
1
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 设计哲学回扣

收官提炼三条工程哲学:

  1. "hook 点设计"决定生态丰度:Java 之所以有 Arthas/JRebel/SkyWalking/JaCoCo 这种"开箱即用的强大工具生态",根本原因是 JVM 在两个关键时机暴露了ClassFileTransformer 钩子——启动期(premain)和运行期(agentmain)。每一个钩子都意味着一个生态位。优秀框架不是把功能做绝,而是把扩展点暴露好——Spring 的 BeanPostProcessor、Servlet 的 Filter、Netty 的 ChannelHandler、Java Agent 的 Transformer 都是同一种设计哲学。下次你做框架,先问自己:我留了哪些 hook 点?这些 hook 点足够强大吗?

  2. "约束 = 可演进性"的辩证:你可能觉得"redefine 不能加字段"是 JVM 的弱点——但这恰恰是 JVM 强大的体现。如果允许任意修改类,那 JIT 编译过的代码、CHA(类层级分析)的假设、内联缓存(inline cache)全部要回退——性能会崩溃。JVM 选择"少而严的修改 + 让上层框架(JRebel)用 ClassLoader 扩展"——把"快"和"灵活"分到不同抽象层。这是 JEP 451 收紧 attach 的同样逻辑:拒绝某些自由 = 保护更多人的安全。设计 API 时,不要被"灵活性"绑架——明确的约束反而是抽象的礼物。

  3. "无侵入"是基础设施工程师的最高荣誉: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
1
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
1
2
3
4
5
6
7
8

Instrumentation 三大 API 选型:

增强首次加载的类       → addTransformer(transformer, true)
增强已加载的类         → retransformClasses(class)(重跑 Transformer 链)
直接替换字节码         → redefineClasses(new ClassDefinition(class, bytes))
查询 JVM 状态         → getAllLoadedClasses / getObjectSize / isModifiableClass
1
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
1
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 源码级流程图与三种方案的性能/功能对照。

上次更新: 2026/06/10, 11:13:41
三大字节码框架对比
AOP三种实现路线对比

← 三大字节码框架对比 AOP三种实现路线对比→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式