Skip to content

字节码与 Java Agent ​

S6-012选修高级约 90 分钟JDK 21Java 高级与架构 · JVM

1. 本节定位 ​

《Arthas 与线上问题诊断》 说「Arthas = attach + 字节码增强」——本节把这句话亲手实现出来。

★ 本节先读字节码,再亲手写一个能把方法「改写」的 Agent,两条路全部实测:

text
静态 -javaagent:[agent] 已改写 …AgentTarget:1679 → 2072 字节 ✓✓(类加载时改写)
动态 attach  :前 4 次调用「裸奔」→ attach → 改写 → 第 5 次起每次多出耗时日志 ✓✓✓

★★ 还踩了两个真实深坑并解决(比顺风顺水更有价值): 主类先于 agent 被加载(必须重转换)+ 重转换禁止新增方法(REBASE 策略被 JVM 拒绝)。

2. 学习目标 ​

  • 会用 javap -c 读基础字节码指令(栈操作、方法调用、异常)★★★;
  • 能写一个 Java Agent 的两个入口:premain 与 agentmain ★★★;
  • 理解「字节码增强 = 字节数组进、字节数组出」★★★;
  • 知道重转换(retransform)的硬约束与典型踩坑 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

★ 你的代码「跑起来」和「你写的样子」之间,隔着一层字节码 ✓✓:

你以为 ✗实际 ✓
字符串拼接就是 StringBuilderJDK 9+ 是 invokedynamic 的 makeConcatWithConstants ✓
Integer i = 1 没有调用编译器插了 Integer.valueOf(缓存 -128~127)✓
性能工具是「黑魔法」都是 agent + 字节码增强(JaCoCo 探针、Arthas、APM)✓✓

★ 会读字节码 = 排查 NoSuchMethodError、反编译对不上、AOP 不生效时不再抓瞎 ✓✓✓。

5. 核心概念 ​

5.1 javap:最朴素的字节码阅读器 ★★★ ​

powershell
javap -c -p -cp <classes> cn.kb.learn.deep.jvm.BytecodeSample

★ 四段实测输出「看什么」 ✓✓✓:

text
public int add(int, int);                     ← 方法签名
    0: iload_1        ← 把第 1 个参数压栈(实例方法:0 号是 this)
    1: iload_2        ← 把第 2 个参数压栈
    2: iadd           ← 弹出两个、相加、压回
    3: ireturn        ← 返回 int

public java.lang.String concat(String, int);
    2: invokedynamic  … makeConcatWithConstants   ← 字符串拼接的「现代实现」✓

public java.lang.Integer boxed(int);
    1: invokestatic  … Integer.valueOf            ← 自动装箱的真相 ✓

public int guard(int);
    1: ifge          14     ← 条件跳转(>= 0 就跳到 14)
    4: new           … IllegalArgumentException
   13: athrow                ← 抛出异常
   14: iload_1 / 15: iconst_2 / 16: imul / 17: ireturn   ← value * 2

★★ 三条常用指令 ✓✓:iload/istore(读写局部变量)、iadd/imul(运算)、 invokestatic/invokespecial/invokevirtual/invokedynamic(调用)、athrow(抛异常)。

5.2 Agent 的两个入口 ★★★ ​

java
public static void premain(String args, Instrumentation inst)   // -javaagent:xxx.jar 启动时挂载
public static void agentmain(String args, Instrumentation inst) // 运行时 attach 挂载(Arthas 的路)✓✓

★ 三个必备条件 ✓:

  1. jar 的 META-INF/MANIFEST.MF 写明 Premain-Class / Agent-Class(本节用 maven-jar-plugin 写入)✓;
  2. attach 场景还要 Can-Retransform-Classes: true ✓;
  3. agent 类只能来自 agent jar 本身(卸载不了,写代码要格外谨慎)⚠️。

5.3 字节码增强的本质:字节数组进、字节数组出 ★★★ ​

java
inst.addTransformer(new ClassFileTransformer() {
    public byte[] transform(ClassLoader l, String className, Class<?> beingRedefined,
                            ProtectionDomain pd, byte[] classfileBuffer) {
        // 你收到「旧字节码」,返回「新字节码」—— 就这么简单
    }
}, true);   // canRetransform=true

★★ 两种时机 ✓✓✓:

时机何时发生beingRedefined
加载时改写类被加载那一刻null
重转换(retransform)类已加载,事后改写非 null(attach 场景)

★ 实战写法(本节采用):手写 transformer + 用 ByteBuddy 做「方法内联」—— 先取 buffer 当原始类、再 Advice 内联、再返回新数组 ✓✓,全程可控可见:

text
[agent] 已改写 cn.kb.learn.deep.jvm.agent.AgentTarget:1679 → 2072 字节 ✓✓

5.4 重转换的两条硬约束(本节踩坑实录)★★★ ​

★ 坑 1:主类可能先于 agent 被加载 ✗✗

text
只 addTransformer 不重转换 → 运行 10 次调用,一次增强都没生效 ✗✗
(main 类在 premain 之后就绪之前已进入加载流程)
正解:安装 transformer 后,对已加载的目标类主动 inst.retransformClasses(loaded) ✓✓✓

★ 坑 2:重转换禁止新增方法 ✗✗✗(本节真实报错)

text
[agent] retransform 失败:java.lang.UnsupportedOperationException:
        class redefinition failed: attempted to add a method

原因是默认的 REBASE 策略会新增辅助方法来搬移原方法体;而 JVM 规定: 重定义只能改方法体,不能增删方法/字段 ✗✗。 正解:用 REDEFINE 思路(只改方法体) ✓✓✓ —— 本节最终版的手写 transformer 走的正是这条路。

★★ 这两条坑解释了一个常见困惑:为什么「动态开的功能」总比「启动参数」限制多 —— attach 场景 = 在「已经跑起来的类」上做手术 ✓✓。

5.5 谁在使用 Agent ★★ ​

工具用途
JaCoCo给类插探针统计覆盖率(T1-004、T1-018 的门禁数据来源)✓✓
Arthas / APM在线诊断与监控(《Arthas 与线上问题诊断》)✓✓
Mockito运行时生成 mock 类 ✓
热部署工具改代码不重启,靠重转换 ✓

6. 递进示例 ​

6.1 读一段字节码 ​

powershell
javap -c -p -cp java-deep/target/classes cn.kb.learn.deep.jvm.BytecodeSample

6.2 静态挂载(把 jar 做成 agent) ​

powershell
cd labs
.\mvnw.cmd -B -ntp -pl java-deep package -DskipTests
# 拿到 ByteBuddy 的 jar 路径(agent 需要它做字节码改写)
.\mvnw.cmd -B -ntp -q -pl java-deep dependency:build-classpath "-Dmdep.outputFile=target/cp.txt"
$bb = (Get-Content java-deep/target/cp.txt -Raw -Encoding UTF8).Trim()
java "-javaagent:java-deep/target/java-deep-1.0.0.jar" -cp "java-deep/target/classes;$bb" `
     cn.kb.learn.deep.jvm.agent.AgentTarget

6.3 动态 attach(不重启改行为) ​

powershell
java -cp "java-deep/target/classes;$bb" "-Dkb.agent.jar=$((Resolve-Path java-deep/target/java-deep-1.0.0.jar).Path)" `
     cn.kb.learn.deep.jvm.agent.AgentAttachLesson

★ attach 的三步 ✓✓✓: VirtualMachine.attach(pid) → loadAgent(jarPath) → detach() —— 目标进程收到 agentmain 回调,之后就是标准的字节码增强 ✓。

7. 完整可运行代码与实测 ​

代码:labs/java-deep/src/main/java/cn/kb/learn/deep/jvm/BytecodeSample.java + labs/java-deep/src/main/java/cn/kb/learn/deep/jvm/agent/{TimingAgent,TimingAdvice,AgentTarget,AgentAttachLesson}.java

实测一(javap,节选):

text
public int add(int, int);
    0: iload_1
    1: iload_2
    2: iadd
    3: ireturn

public java.lang.String concat(java.lang.String, int);
    2: invokedynamic #7,  0   // InvokeDynamic #0:makeConcatWithConstants:(…)Ljava/lang/String;

public java.lang.Integer boxed(int);
    1: invokestatic  #11      // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;

实测二(静态 -javaagent):

text
[agent] TimingAgent 已挂载(静态挂载 -javaagent)
[agent] 已改写 cn.kb.learn.deep.jvm.agent.AgentTarget:1679 → 2072 字节
AgentTarget 启动,pid=12248
    [agent] slow 耗时 123.2 ms
  第 1 次调用 slow(1) → done-1
    [agent] slow 耗时 120.5 ms
  第 2 次调用 slow(2) → done-2
…

实测三(动态 attach)—— 「裸奔 → 手术 → 生效」全过程 ✓✓✓:

text
== 1. 启动目标 JVM(此时没有 agent,前几次调用是「裸奔」的)==
AgentTarget 启动,pid=3416
  第 1 次调用 slow(1) → done-1          ← 裸奔
  第 2 次调用 slow(2) → done-2          ← 裸奔
== 2. attach 到 pid=3416 并 loadAgent ==
[agent] TimingAgent 已挂载(动态 attach)
  第 3 次调用 slow(3) → done-3
  第 4 次调用 slow(4) → done-4
[agent] 已改写 cn.kb.learn.deep.jvm.agent.AgentTarget:1679 → 2072 字节   ← 手术完成
[agent] 目标类已加载,重转换完成
== 3. agent 已加载,观察后半段输出的耗时日志 ==
    [agent] slow 耗时 121.9 ms           ← 生效
  第 5 次调用 slow(5) → done-5
    [agent] slow 耗时 120.2 ms
  第 6 次调用 slow(6) → done-6
…

★ 排障留档(本节的另一半价值) ✓✓:

text
先试默认 REBASE 策略 → [agent] retransform 失败:
    java.lang.UnsupportedOperationException: class redefinition failed: attempted to add a method
改为「只改方法体」的手写 transformer → [agent] 已改写 … 1679 → 2072 字节 ✓✓

8. Debug 观察点 ​

  1. javap -c -p 逐个方法对照源码,确认「你写的」与「编译出的」差异 ✓✓✓;
  2. 给 advice 加 @Advice.Origin("#m #d") 打印方法描述符:同一个 agent 也能服务其他方法 ✓✓;
  3. 把 -javaagent 去掉再跑 AgentTarget:确认「没有 agent 就没有耗时日志」✓✓;
  4. attach 前后各打一份 sc -d(《Arthas 与线上问题诊断》):看类的加载器没变、但字节码变了 ✓✓。

9. 常见错误 ​

#错误说明
1manifest 忘写 Premain-Class/Agent-Class ✗✗挂载时静默不生效或报错 ✓✓
2attach 后不 retransform 已加载的类 ✗✗✗主类可能早已加载(本节坑 1)✓✓
3对已加载类用「新增方法」的增强 ✗✗✗attempted to add a method(本节坑 2)✓✓
4agent 依赖没进 classpath / 没打进 jar ✗✗agent 类解析失败,挂载后毫无动静 ✓
5在 advice 里写重逻辑 / 抛异常 ✗✗会污染目标方法执行,甚至拖垮业务 ✓✓
6以为「增强过就永久生效」 ✗类卸载/重新加载会回到原始字节码 ✓

10. 动手实验 ​

  1. 跑通 javap 四段字节码对照 ✓✓✓;
  2. 静态挂载:自己加一行 advice 输出(比如打印方法名)✓✓;
  3. 动态 attach:跑通「裸奔 → 改写 → 生效」全程,并故意注释掉 retransform 那两行观察差异 ✗✗✓;
  4. 把 REDEFINE 改回 REBASE 复现 attempted to add a method ✗✗✓(亲手复现坑比读十遍强);
  5. 用 -Djdk.instrument.traceUsage 启动,观察 JDK 对 agent 使用的追踪输出 ✓。

验收方式:能独立写出「attach 到指定 pid 并给指定方法加耗时日志」的 agent,并解释每一步为什么必须存在。

扩展任务:给项目 A 写一个「慢方法告警 agent」:耗时超过阈值的调用打印一条带线程名与参数的告警 ✓✓

11. 练习题 ​

  1. javap -c 的输出里,iload_1、iadd、ireturn 分别在做什么?
  2. JDK 9+ 的字符串拼接用什么指令实现?
  3. Agent 的两个入口方法与各自的挂载方式?
  4. 重转换为什么不能新增方法?本条是怎么解决的?
  5. 「主类先于 agent 加载」会导致什么现象?怎么解决?
参考答案(要点)
  1. 载入参数 1 → 整数相加 → 返回 ✓✓★
  2. invokedynamic + makeConcatWithConstants ✓★
  3. premain(-javaagent 静态)/ agentmain(attach 动态) ✓✓★
  4. JVM 重定义只允许改方法体(schema 不变);改用只改方法体的 REDEFINE 思路 ✓✓★
  5. 增强静默不生效(类已加载、错过加载时改写);安装后对已加载类主动 retransform ✓✓✓★

12. 掌握标准 ​

  • [ ] 会读基础字节码指令 ★★★;
  • [ ] 能写 premain / agentmain 双入口 agent ★★★;
  • [ ] 理解「字节数组进、字节数组出」★★★;
  • [ ] 知道重转换两条硬约束与解法 ★★★;
  • [ ] 能说出 JaCoCo / Arthas / APM 的共同原理 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

JVM 模块到此收官(《JVM 内存结构与对象创建》 内存结构 → 《类加载机制》 类加载 → 《垃圾回收算法与收集器》 GC → 《GC 日志分析与 JVM 调优》 日志与调优 → 《Arthas 与线上问题诊断》 在线诊断 → 本节)。 下一站:《CAP 与 BASE 理论》 起的分布式与架构设计(CAP 与 BASE、分布式事务、高并发与高可用系统设计)。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引