本节目标:能读懂
javap -c -p -v的关键输出,知道 ASM 和 Byte Buddy 各自适合什么任务。
适用版本:JDK 25(Temurin 25.0.5);ASM 9.11.1;Byte Buddy 1.18.16。
承接的问题
3.1 的插件加载器解决的是「类从哪里来」,但插件 jar 里的类只能按原样加载。libraryd 想在 LibraryService.borrow 外面加计时,或者在不改源码的情况下替换 FineCalculator 的罚金策略,就必须在类加载时改写字节码。
改写之前要先回答一个更基础的问题:编译器到底生成了什么? 如果不看字节码,只凭 Java 源码猜,很容易犯三类错误:
- 以为
long的乘法是一条指令,实际是lload、ldc2_w、lmul三条。 - 以为字符串拼接是
StringBuilder,实际 JDK 9 之后是invokedynamic+StringConcatFactory。 - 以为方法签名里的泛型还在字节码里,实际只剩擦除后的描述符。
这一节先用 javap 把 FineCalculator 拆开,再用 ASM 读一遍、用 Byte Buddy 生成一个子类。ASM 和 Byte Buddy 不是二选一,它们工作在不同抽象层;理解这一点,3.3 的 Agent 才知道该用哪个。
class 文件与 javap
先写一个 libraryd 的罚金计算器:
package com.plume.library.domain;
public class FineCalculator {
public static final int FINE_PER_DAY_CENTS = 50;
public int fineCents(long overdueDays) {
if (overdueDays <= 0) {
return 0;
}
long cents = overdueDays * FINE_PER_DAY_CENTS;
return (int) Math.min(cents, 10_000L);
}
public String describe(long days) {
return "overdue " + days + " days: " + fineCents(days) + " cents";
}
}
编译并查看:
javac --release 25 -d out src/com/plume/library/domain/FineCalculator.java
javap -c -p -v -classpath out com.plume.library.domain.FineCalculator
本机输出的头部:
Classfile /private/tmp/vol3-a/ch3/bytecode/out/com/plume/library/domain/FineCalculator.class
Last modified 2026年10月28日; size 1016 bytes
SHA-256 checksum d69f05a2cba8c1d9ca47a9b83fb3630f8489c3fe91a5e6eeee02cf8e1a31dff9
Compiled from "FineCalculator.java"
public class com.plume.library.domain.FineCalculator
minor version: 0
major version: 69
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // com/plume/library/domain/FineCalculator
super_class: #2 // java/lang/Object
interfaces: 0, fields: 1, methods: 3, attributes: 3
major version: 69 对应 Java 25(class 文件版本从 Java 1.1 的 45 起算,Java 25 是 44 + 25 = 69;Java 21 是 65)。这个数字决定了 JVM 能不能加载 class 文件:用 JDK 21 的 java 运行 --release 25 编译的类,会得到 UnsupportedClassVersionError。
常量池是 class 文件的符号表,javap -v 会逐项列出。节选:
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#7 = Class #8 // com/plume/library/domain/FineCalculator
#9 = Long 50l
#11 = Long 10000l
#13 = Methodref #14.#15 // java/lang/Math.min:(JJ)J
#19 = Methodref #7.#20 // com/plume/library/domain/FineCalculator.fineCents:(J)I
#23 = InvokeDynamic #0:#24 // #0:makeConcatWithConstants:(JI)Ljava/lang/String;
#30 = Integer 50
#39 = String #40 // overdue \u0001 days: \u0001 cents
#41 = MethodHandle 6:#42 // REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:...
几个观察:
Long和Double常量占两个常量池槽位,所以#9后面直接是#11,#10被占用。Methodref是「类 + 名字和类型」的组合,#13指向Math.min(JJ)J。InvokeDynamic的引导方法是StringConcatFactory.makeConcatWithConstants,不是StringBuilder。JDK 9 之后,编译器把字符串拼接交给invokedynamic,运行时由StringConcatFactory生成最优的连接策略。#39里的\u0001是占位符,对应两个动态参数(long days和int cents)。FINE_PER_DAY_CENTS是static final int,ConstantValue: int 50直接存在字段属性里;但方法里的乘法仍然通过ldc2_w #9读取50l,因为表达式被提升成了long。
javap -c 的方法体是栈机器指令:
public int fineCents(long);
descriptor: (J)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=4, locals=5, args_size=2
0: lload_1
1: lconst_0
2: lcmp
3: ifgt 8
6: iconst_0
7: ireturn
8: lload_1
9: ldc2_w #9 // long 50l
12: lmul
13: lstore_3
14: lload_3
15: ldc2_w #11 // long 10000l
18: invokestatic #13 // Method java/lang/Math.min:(JJ)J
21: l2i
22: ireturn
LineNumberTable:
line 8: 0
line 9: 6
line 11: 8
line 12: 14
stack=4 是操作数栈的最大深度,locals=5 是局部变量槽数量,args_size=2 是 this 加一个 long(long 占两个槽)。这些数字不是给人看的,是 JVM 校验 class 文件时必须匹配的约束;手写字节码时算错 maxStack 会导致 VerifyError。
指令怎么读:fineCents 的栈机器
把 fineCents 的指令按栈的变化走一遍:
lload_1把局部变量 1(overdueDays的低位槽)压栈;long占两个槽,lload_1会读 1 和 2。lconst_0压入0L。lcmp弹出两个long,比较后压入-1、0或1。ifgt 8弹出比较结果,如果大于 0 跳到指令 8;否则继续执行 6。iconst_0压入0,ireturn返回int 0。- 跳到 8 后,
lload_1重新压入overdueDays,ldc2_w #9压入50L,lmul弹出两个long、压入乘积。 lstore_3把乘积存到局部变量 3(占 3 和 4 两个槽)。lload_3压回乘积,ldc2_w #11压入10000L,invokestatic Math.min(JJ)J取较小值。l2i把long截断成int,ireturn返回。
这段字节码和源码一一对应,没有魔法。真正需要习惯的是两个地方:long/double 占两个局部变量槽,以及 ifgt 的目标是指令偏移而不是行号。LineNumberTable 把偏移映射回源码行,调试器靠它显示行号;没有它,异常栈里只有 <Unknown Line>。
describe 方法更短,但包含了 invokedynamic:
public java.lang.String describe(long);
Code:
0: lload_1
1: aload_0
2: lload_1
3: invokevirtual #19 // Method fineCents:(J)I
6: invokedynamic #23, 0 // InvokeDynamic #0:makeConcatWithConstants:(JI)Ljava/lang/String;
11: areturn
aload_0 压入 this,lload_1 压入 days,invokevirtual fineCents 消耗 this 和 days、压入 int 结果,然后 invokedynamic 用两个动态参数和常量池里的模板拼接字符串。invokedynamic 第一次执行时会链接一个 CallSite,后续调用直接走链接后的方法句柄;这就是为什么字符串拼接在循环里不会反复创建 StringBuilder 的决策对象。
ASM 9.11.1:读字节码而不是猜
如果只需要读 class 文件、统计方法或检查注解,ASM 的 ClassReader + ClassVisitor 是最直接的。下面统计 FineCalculator 的方法数和指令数:
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
public final class MethodCounter extends ClassVisitor {
private int methodCount;
private int instructionCount;
public MethodCounter() {
super(Opcodes.ASM9);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
methodCount++;
System.out.println("method=" + name + descriptor
+ " access=0x" + Integer.toHexString(access));
return new MethodVisitor(Opcodes.ASM9) {
@Override
public void visitInsn(int opcode) {
instructionCount++;
}
@Override
public void visitMethodInsn(int opcode, String owner, String name,
String descriptor, boolean isInterface) {
instructionCount++;
}
@Override
public void visitInvokeDynamicInsn(String name, String descriptor,
org.objectweb.asm.Handle handle,
Object... arguments) {
instructionCount++;
}
@Override
public void visitLdcInsn(Object value) {
instructionCount++;
}
};
}
}
实际代码还会覆盖 visitIntInsn、visitVarInsn、visitTypeInsn、visitFieldInsn、visitJumpInsn,否则 lload、ldc2_w、ifgt 这些指令不会被计数。完整版本在本机跑了:
ASM=~/.m2/repository/org/ow2/asm/asm/9.11.1/asm-9.11.1.jar
javac --release 25 -cp "$ASM" -d out $(find src -name '*.java')
java -cp "out:$ASM" lab.MethodCounter out/com/plume/library/domain/FineCalculator.class
本机输出:
class=com/plume/library/domain/FineCalculator version=69 access=0x21
method=<init>()V access=0x1
method=fineCents(J)I access=0x1
method=describe(J)Ljava/lang/String; access=0x1
methods=3
instructions=24
version=69 是 class 文件版本,access=0x21 是 ACC_PUBLIC | ACC_SUPER。ASM 的 ClassVisitor 是流式 API:ClassReader.accept 按顺序回调 visit、visitField、visitMethod,每个方法再回调指令。它不会把整个 class 文件构造成对象树,内存占用小,适合在 Agent 的 ClassFileTransformer 里逐类处理。
ASM 的代价是抽象层低:要自己维护栈深度、局部变量表、跳转标签和 StackMapTable。写一个只读访问器不需要这些;写一个会改变控制流的转换器,就必须理解 COMPUTE_FRAMES 和 COMPUTE_MAXS 的区别。COMPUTE_FRAMES 会重新计算栈映射帧,代价高但正确;COMPUTE_MAXS 只重算最大栈和局部变量,不重算帧,改控制流时不够用。
Byte Buddy 1.18.16:更高层的生成
Byte Buddy 把「生成一个类」抽象成流式 API。下面的例子生成 FineCalculator 的子类,把 fineCents 固定返回 0:
FineCalculator original = new FineCalculator();
System.out.println("original=" + original.fineCents(10));
Class<? extends FineCalculator> generated = new ByteBuddy()
.subclass(FineCalculator.class)
.name("com.plume.library.generated.FreeFineCalculator")
.method(named("fineCents"))
.intercept(FixedValue.value(0))
.make()
.load(FineCalculator.class.getClassLoader(),
ClassLoadingStrategy.Default.WRAPPER)
.getLoaded();
FineCalculator free = generated.getDeclaredConstructor().newInstance();
System.out.println("generated=" + generated.getName());
System.out.println("buddy=" + free.fineCents(10));
System.out.println("buddyDescribe=" + free.describe(10));
本机输出:
original=500
generated=com.plume.library.generated.FreeFineCalculator
buddy=0
buddyDescribe=overdue 10 days: 0 cents
subclass 生成子类,method(named("fineCents")) 选择方法,intercept(FixedValue.value(0)) 替换实现,load 把字节码定义成类。WRAPPER 策略用一个新加载器包住生成的类,避免污染应用类路径;生产代码也可以用 INJECTION 把类注入目标加载器。
buddyDescribe 的输出是 0,说明 describe 里调用的 fineCents 也走了子类的覆盖版本,而不是父类的 500。这验证了虚方法分派:describe 在父类字节码里是 invokevirtual FineCalculator.fineCents,运行时分派到子类。
Byte Buddy 的抽象层高,代价是启动时生成辅助类和类加载策略的复杂度。它适合「生成一个实现/子类」「按方法匹配加 Advice」这类任务;如果只是读常量池、统计指令,用 ASM 更轻。
两者的选择可以落成一张表:
| 任务 | 推荐 | 原因 |
|---|---|---|
| 读取 class 结构、统计方法/注解 | ASM ClassReader | 流式、无额外类生成、依赖小 |
| 修改单条指令、重写常量池 | ASM ClassVisitor/MethodVisitor | 直接控制字节码,代价是必须维护帧 |
| 生成子类、实现接口、按方法拦截 | Byte Buddy | 高层 DSL,自动处理栈帧和类加载 |
| Agent 里按类型/方法匹配加 Advice | Byte Buddy AgentBuilder | 匹配器、Advice、重转换集成度高 |
| 对启动时间敏感的极简转换 | ASM | 避免 Byte Buddy 的初始化开销 |
libraryd 的 libraryd-agent 只依赖 byte-buddy 和 byte-buddy-agent,用 AgentBuilder 做方法计时;如果以后要做「扫描所有 class 的常量池」这种只读任务,会在同一个模块里引入 ASM,而不是用 Byte Buddy 绕一圈。
在 libraryd 里落地
第一个落点是 FineCalculator 的可测试性。罚金规则是业务核心,不应该靠字节码替换来改;Byte Buddy 生成的 FreeFineCalculator 只用于实验和基准,不进入生产装配。生产代码通过 FinePolicy 接口注入不同策略:
public interface FinePolicy {
int fineCents(long overdueDays);
}
字节码工具解决的是「不能改源码」的场景:第三方库、Agent 计时、测试夹具。业务规则本身应该用接口和依赖注入表达,不要让字节码成为主要扩展点。
第二个落点是构建依赖。ASM 和 Byte Buddy 都从 Maven Central 拉取,版本固定:
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.11.1</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.18.16</version>
</dependency>
本机用 mvn dependency:get 确认这两个坐标可解析,然后用本地仓库的 jar 直接编译运行,输出就是上面的 original=500、buddy=0。版本不要写成 LATEST;字节码库对 class 文件版本敏感,升级时要重新跑一遍所有转换用例。
第三个落点是测试。字节码转换的错误往往在类加载时才暴露,单元测试要覆盖「转换后的类能加载、能调用、异常栈正确」三件事。对 FineCalculator,至少断言:
FineCalculator free = generated.getDeclaredConstructor().newInstance();
if (free.fineCents(10) != 0) {
throw new AssertionError("fineCents 未被拦截");
}
if (!free.describe(10).contains("0 cents")) {
throw new AssertionError("describe 未走子类分派");
}
不要只断言 generated.getName();类名对了不代表字节码正确。
常见错误与边界
「class 文件版本不匹配。」 用 JDK 25 编译、用 JDK 21 运行,会得到:
java.lang.UnsupportedClassVersionError: com/plume/library/domain/FineCalculator
has been compiled by a more recent version of the Java Runtime (class file version 69.0),
this version of the Java Runtime only recognizes class file versions up to 65.0
--release 25 生成 69,--release 21 生成 65。字节码库的版本也要跟得上:ASM 9.11.1 支持 Java 25 的 class 文件;旧版 ASM 会在 ClassReader 构造时抛 IllegalArgumentException: Unsupported class file major version。
「只覆盖部分 visitXxx 方法。」 统计指令时只覆盖 visitInsn,会漏掉 lload(visitVarInsn)、ldc2_w(visitLdcInsn)、invokestatic(visitMethodInsn),得到的指令数偏小。写访问器时对照 MethodVisitor 的完整回调列表,或者用 ASM 的 TraceClassVisitor 先打印一遍。
「改控制流却只用 COMPUTE_MAXS。」 ClassWriter(COMPUTE_MAXS) 只重算最大栈和局部变量,不重算 StackMapTable。插入分支或跳转后,帧信息可能和实际栈不一致,类加载时抛 VerifyError。改控制流用 COMPUTE_FRAMES,并且给 ClassWriter 一个能解析父类的 ClassLoader,否则它无法计算公共父类型。
「在 Agent 里转换已经加载的类。」 ClassFileTransformer 只对之后加载的类生效;已经加载的类需要 retransformClasses,而且受 Can-Retransform-Classes 和转换限制约束。3.3 会看到 premain 在 main 之前执行,所以 LibraryService 还没加载,正好能被转换;如果目标是 JDK 核心类,时机和模块边界都会更复杂。
「把 Byte Buddy 生成类放进应用 classpath。」 ClassLoadingStrategy.Default.WRAPPER 用一个新加载器隔离生成类;如果用 INJECTION 注入应用加载器,同名类重复定义会冲突。测试里优先 WRAPPER,需要和现有类互相引用时再考虑 INJECTION,并确保类名唯一。
小结
javap -c -p -v同时给出 class 文件头、常量池、方法指令和行号表;major version 69对应 Java 25。long/double占两个局部变量槽,lcmp产生-1/0/1,跳转目标是字节码偏移而不是行号。- JDK 9 之后字符串拼接走
invokedynamic+StringConcatFactory,不是StringBuilder。 - ASM 9.11.1 的
ClassReader/ClassVisitor适合流式读取和低层改写,代价是手动维护栈帧。 - Byte Buddy 1.18.16 的
subclass/method/intercept适合生成子类和按方法拦截,自动处理类加载与栈帧。 - 业务规则优先用接口注入,字节码工具留给第三方库、Agent 和测试夹具;依赖版本固定,不用
LATEST。
能读、能改字节码之后,最后一个问题是「什么时候改」。premain 在 main 之前执行,Instrumentation 提供注册转换器的入口;下一节把 TimingAgent 打成带 Premain-Class 的 jar,用真实的 -javaagent 参数测量 LibraryService.borrow。
阅读导航:上一节:3.1 类加载器与双亲委派:从平台加载器到插件加载器 · 下一节:3.3 Instrumentation 与 Java Agent:无侵入计时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。