《Java 高级编程》3.2 字节码入门:javap、常量池与 ASM/Byte Buddy

第 3 章要改写 libraryd 的字节码,第一步不是选库,而是读懂编译器到底生成了什么。本节用 javap -c -p -v 拆开 FineCalculator 的常量池、栈机器指令和 invokedynamic,再用 ASM 9.11.1 统计方法指令、用 Byte Buddy 1.18.16 生成子类,并给出两者的选择边界。

本节目标:能读懂 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 的指令按栈的变化走一遍:

  1. lload_1 把局部变量 1(overdueDays 的低位槽)压栈;long 占两个槽,lload_1 会读 1 和 2。
  2. lconst_0 压入 0L。
  3. lcmp 弹出两个 long,比较后压入 -1、0 或 1。
  4. ifgt 8 弹出比较结果,如果大于 0 跳到指令 8;否则继续执行 6。
  5. iconst_0 压入 0,ireturn 返回 int 0。
  6. 跳到 8 后,lload_1 重新压入 overdueDays,ldc2_w #9 压入 50L,lmul 弹出两个 long、压入乘积。
  7. lstore_3 把乘积存到局部变量 3(占 3 和 4 两个槽)。
  8. lload_3 压回乘积,ldc2_w #11 压入 10000L,invokestatic Math.min(JJ)J 取较小值。
  9. 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 里按类型/方法匹配加 AdviceByte 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:无侵入计时 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发