本节目标:能对着
javap -v -p的输出说出每一段的含义,知道 record 的方法不是手写的。
适用版本:JDK 25(Temurin 25.0.5);class 文件 major version 69。
承接的问题
1.3 的工具能告诉你「类加载了多少」,但不能告诉你「类文件里到底有什么」。libraryd 的插件 jar 在加载时只做版本和格式校验;一旦出现 UnsupportedClassVersionError、NoSuchMethodError 或「record 的 equals 为什么和我写的不一样」,就必须回到 class 文件本身。
class 文件不是二进制黑盒。它有固定的十段结构:
magic -> minor_version -> major_version -> constant_pool_count -> constant_pool
-> access_flags -> this_class -> super_class -> interfaces
-> fields -> methods -> attributes
这一节用两个 libraryd 类型做样本:Book 是 record,能同时看到字段、规范构造器、访问器和三个 invokedynamic 方法;FinePolicy 是带静态常量的 record,能补上 <clinit> 与 Record 属性。所有输出都来自本机编译产物。
文件头:cafe babe、版本 69 与访问标志
javap 不打印 magic,先用 xxd 看前 16 个字节:
xxd -l 16 out/com/plume/library/domain/Book.class
本机输出:
00000000: cafe babe 0000 0045 0041 0a00 0200 0307 .......E.A......
逐段读:
cafe babe是 magic,JVM 用它快速判断「这是不是 class 文件」。0000是 minor version,0045是 major version。0x45十进制是 69,对应 Java 25(Java 21 是 65,Java 17 是 61)。0041是常量池计数,十进制 65,表示常量池索引从 1 到 64,索引 0 保留不用。- 后面的
0a是第一个常量池项的 tag(CONSTANT_Methodref),0002 0003是它引用的两个索引。
javap -v -p 给出同一份信息的可读版本:
javap -v -p -classpath out com.plume.library.domain.Book
本机输出(头部):
Classfile /private/tmp/vol4-a/out/com/plume/library/domain/Book.class
Last modified 2026年10月28日; size 1517 bytes
SHA-256 checksum 4499f9612b80a0cdde001de2208e4bdb11487567c3af734cb205bc85bafae5ce
Compiled from "Book.java"
public final class com.plume.library.domain.Book extends java.lang.Record
minor version: 0
major version: 69
flags: (0x0031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER
this_class: #8 // com/plume/library/domain/Book
super_class: #2 // java/lang/Record
interfaces: 0, fields: 4, methods: 8, attributes: 4
0x0031 拆开是 ACC_PUBLIC(0x0001) | ACC_FINAL(0x0010) | ACC_SUPER(0x0020)。ACC_FINAL 说明 record 不能被继承;ACC_SUPER 影响 invokespecial 的查找语义,现代编译器都会置位。extends java.lang.Record 不是源码里写的,是编译器加的。
fields: 4, methods: 8, attributes: 4 是后面三张表的规模。Book 源码只有一行,编译器生成了 4 个字段、8 个方法、4 个属性。
常量池:符号表、描述符与两个槽的常量
常量池是 class 文件的符号表。javap -v 逐项列出,Book 的前半段:
Constant pool:
#1 = Methodref #2.#3 // java/lang/Record."<init>":()V
#2 = Class #4 // java/lang/Record
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Record
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // com/plume/library/domain/Book.isbn:Ljava/lang/String;
#8 = Class #10 // com/plume/library/domain/Book
#9 = NameAndType #11:#12 // isbn:Ljava/lang/String;
#10 = Utf8 com/plume/library/domain/Book
#11 = Utf8 isbn
#12 = Utf8 Ljava/lang/String;
四种最常见的项:
Utf8存字符串和描述符,Ljava/lang/String;是字段类型,()V是无参无返回值的方法描述符。Class指向一个Utf8,表示类或接口名。NameAndType把名字和描述符组合起来。Fieldref/Methodref再由Class加NameAndType组成,分别表示字段和方法。
描述符是字节码里的类型语言:I 是 int,J 是 long,Z 是 boolean,[ 是数组,L...; 是引用类型。Book 的规范构造器描述符是:
(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V
四个参数依次是三个 String 和一个 int,返回 void。
long 和 double 常量占两个常量池槽位。用一个最小类验证:
public final class ConstantLab {
static final long LIMIT_CENTS = 10_000L;
static final double RATE = 0.5;
public static void main(String[] args) {
long limit = LIMIT_CENTS + args.length;
double rate = RATE * args.length;
System.out.println(limit + " " + rate);
}
}
本机 javap -v 的常量池节选:
#9 = Long 10000l
#11 = Double 0.5d
#10 和 #12 是空的。这不是 javap 漏打,而是 JVMS 规定 8 字节常量占两个 u2 槽位,后一个槽位不可用。手写字节码或解析 class 文件时,索引必须跳过这两个位置。
字段、方法与 Code 属性
Book 的四个字段都是 private final:
private final java.lang.String isbn;
descriptor: Ljava/lang/String;
flags: (0x0012) ACC_PRIVATE, ACC_FINAL
0x0012 = ACC_PRIVATE(0x0002) | ACC_FINAL(0x0010)。record 的字段由编译器生成,源码里只写组件名。
规范构造器的方法表与字节码:
public com.plume.library.domain.Book(java.lang.String, java.lang.String, java.lang.String, int);
descriptor: (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=5, args_size=5
0: aload_0
1: invokespecial #1 // Method java/lang/Record."<init>":()V
4: aload_0
5: aload_1
6: putfield #7 // Field isbn:Ljava/lang/String;
9: aload_0
10: aload_2
11: putfield #13 // Field title:Ljava/lang/String;
14: aload_0
15: aload_3
16: putfield #16 // Field author:Ljava/lang/String;
19: aload_0
20: iload 4
22: putfield #19 // Field year:I
25: return
LineNumberTable:
line 3: 0
MethodParameters:
Name Flags
isbn
title
author
year
stack=2 是操作数栈最大深度,locals=5 是局部变量槽数,args_size=5 是 this 加四个参数。构造函数先 aload_0 压 this,invokespecial 调 Record.<init>,然后对每个字段执行 aload_0、加载参数、putfield。
LineNumberTable 把字节码偏移映射回源码行;没有它,异常栈里只有 <Unknown Line>。MethodParameters 保存参数名,调试和反射用得上。普通类默认不生成这个属性,要加 -parameters;record 的规范构造器由编译器强制保留参数名。
Record 的三个方法:invokedynamic 与 ObjectMethods
Book 的 toString、hashCode、equals 都是三到四条指令:
public final java.lang.String toString();
Code:
0: aload_0
1: invokedynamic #23, 0 // InvokeDynamic #0:toString:(Lcom/plume/library/domain/Book;)Ljava/lang/String;
6: areturn
public final int hashCode();
Code:
0: aload_0
1: invokedynamic #27, 0 // InvokeDynamic #0:hashCode:(Lcom/plume/library/domain/Book;)I
6: ireturn
public final boolean equals(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: invokedynamic #31, 0 // InvokeDynamic #0:equals:(Lcom/plume/library/domain/Book;Ljava/lang/Object;)Z
7: ireturn
三个方法都走 invokedynamic,引导方法在 BootstrapMethods 属性里:
BootstrapMethods:
0: #52 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
Method arguments:
#8 com/plume/library/domain/Book
#46 isbn;title;author;year
#48 REF_getField com/plume/library/domain/Book.isbn:Ljava/lang/String;
#49 REF_getField com/plume/library/domain/Book.title:Ljava/lang/String;
#50 REF_getField com/plume/library/domain/Book.author:Ljava/lang/String;
#51 REF_getField com/plume/library/domain/Book.year:I
ObjectMethods.bootstrap 接收类、组件名列表和四个字段的 MethodHandle,在第一次调用时生成具体的 toString/equals/hashCode 实现,后续调用直接走链接后的 CallSite。这就是为什么 record 的 equals 不需要你在源码里写,也不会为每个 record 生成一份巨大的字节码。
Record 属性单独列出组件:
Record:
java.lang.String isbn;
descriptor: Ljava/lang/String;
java.lang.String title;
descriptor: Ljava/lang/String;
java.lang.String author;
descriptor: Ljava/lang/String;
int year;
descriptor: I
反射里的 Class.isRecord()、getRecordComponents() 读的就是这个属性,不是读字段。字段可以被混淆或改名,Record 属性保留的是组件语义。
第二个例子:FinePolicy 的静态字段与 <clinit>
FinePolicy 多了一个静态常量:
public static final com.plume.library.domain.FinePolicy DEFAULT;
descriptor: Lcom/plume/library/domain/FinePolicy;
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
0x0019 = ACC_PUBLIC | ACC_STATIC | ACC_FINAL。它不是 ConstantValue,因为 ConstantValue 只适用于基本类型和 String;对象常量必须在 <clinit> 里构造:
static {};
Code:
stack=4, locals=0, args_size=0
0: new #8 // class com/plume/library/domain/FinePolicy
3: dup
4: bipush 50
6: iconst_0
7: invokespecial #34 // Method "<init>":(II)V
10: putstatic #37 // Field DEFAULT:Lcom/plume/library/domain/FinePolicy;
13: return
new 只分配对象,dup 复制一份引用供构造器消耗,invokespecial 调 <init>(II)V,putstatic 把引用写入静态字段。50 是 centsPerDay,0 是 graceDays,和 FinePolicy.DEFAULT 的定义一致。
FinePolicy 的 fineCents 方法体留到 2.3 逐指令走;这里只确认它的描述符是 (J)J,参数和返回值都是 long。
在 libraryd 里落地
插件 jar 的兼容性检查不该等到运行时。构建阶段用 javap 打印目标 class 的版本与依赖:
javap -v -p -classpath libraryd-domain/target/classes \
com.plume.library.domain.Book | head -20
更稳的做法是在测试里直接读 class 文件头,断言主版本号:
package com.plume.library.domain;
import java.io.DataInputStream;
import java.io.InputStream;
public final class ClassFileVersionTest {
public static void main(String[] args) throws Exception {
try (InputStream in = Book.class.getResourceAsStream("Book.class");
DataInputStream data = new DataInputStream(in)) {
int magic = data.readInt();
int minor = data.readUnsignedShort();
int major = data.readUnsignedShort();
if (magic != 0xCAFEBABE) {
throw new AssertionError("bad magic: " + Integer.toHexString(magic));
}
if (major != 69) {
throw new AssertionError("expected major 69, got " + major + "." + minor);
}
System.out.println("Book.class major=" + major + " minor=" + minor);
}
}
}
本机运行输出:
Book.class major=69 minor=0
这个检查的价值在于:升级 JDK 或改 --release 时,构建会立刻告诉你产物版本变了,而不是等插件在旧 JRE 上加载失败。libraryd 的插件协议可以要求「major ≤ 当前运行时」,在 PluginClassLoader 加载前先读这 8 个字节,失败时给出明确错误,而不是让 UnsupportedClassVersionError 从深处冒出来。
常见错误与边界
错误一:只跑 javap -c。 -c 只反汇编方法体,看不到常量池、字段表、BootstrapMethods 和 Record。排查 invokedynamic 或 record 语义时必须加 -v,需要私有成员再加 -p。
错误二:以为 record 的 equals 是编译器逐字段展开的。 它是 invokedynamic 加 ObjectMethods.bootstrap,第一次调用才生成实现。用 ASM 统计指令数时,equals 只有 4 条指令,不要据此判断它「很轻」;真正的比较逻辑在引导方法生成的方法里。
错误三:把 ConstantValue 用在对象常量上。 static final FinePolicy DEFAULT = new FinePolicy(50, 0) 不会生成 ConstantValue 属性,而是在 <clinit> 里 new + putstatic。只有基本类型和 String 能直接进常量池。
错误四:解析常量池时忽略两个槽的常量。 Long、Double 占两个索引,后面的索引不可用。自己写 class 文件解析器时若按顺序递增,会在 #9 之后读到 #10 的空位并错位。
边界一:major version 只保证格式兼容,不保证 API 兼容。 JDK 25 能加载 major 65(Java 21)的类,但类里引用的方法可能在新 JDK 被移除。版本检查只能挡住「太新」,挡不住「API 没了」。
边界二:javap 是静态视角。 它展示 class 文件里写了什么,不展示 JIT 之后机器码长什么样。内联、逃逸分析、去虚拟化都在运行时发生,第 4 章再处理。
小结
- class 文件以
cafe babe开头,Java 25 的 major version 是 69(0x45)。 - 常量池是符号表,
Utf8存名字与描述符,Class/NameAndType/Fieldref/Methodref组合引用。 long/double常量占两个槽位,解析时要跳过下一个索引。- 方法表里的
Code属性包含stack、locals、args_size、指令和LineNumberTable。 - record 的
toString/equals/hashCode由invokedynamic+ObjectMethods.bootstrap生成;Record属性保存组件语义。 - 对象类型的静态常量在
<clinit>里构造,不走ConstantValue。
知道 class 文件里有什么之后,下一个问题是它什么时候、由谁放进 JVM。2.2 节从 -Xlog:class+load 和两个 URLClassLoader 开始,讲加载、链接、初始化与双亲委派。
阅读导航:上一节:1.3 观测工具全景:jcmd、JFR、GC 日志与 JMC · 下一节:2.2 类加载过程:加载、链接、初始化与双亲委派 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。