javap -v 输出的最前面几十行永远是一堆 #1 = Class #2、#2 = Utf8 java/lang/Object。多数人直接跳过,直到撞上 ClassFormatError: Unknown constant tag、NoSuchMethodError,或自己用 ASM 拼出来的类一加载就崩——才回头发现常量池是 class 文件里唯一"靠自己索引自己"的结构。
读懂它只要三件事:entry 有哪几种、每种长什么样、索引怎么互相指。
1. 常量池在类文件里的位置
class 文件没有分隔符,全靠固定顺序的字段头 + 长度前缀。constant_pool 紧跟在版本号之后,是第一个变长区。
| 偏移 | 字段 | 宽度 | 说明 |
|---|---|---|---|
| 0 | magic |
u4 | 固定 0xCAFEBABE |
| 4/6 | minor/major_version |
u2 | 52 = Java 8,61 = Java 17 |
| 8 | constant_pool_count |
u2 | entry 数 + 1 |
| 10 | constant_pool[] |
变长 | 从 index 1 开始 |
两个易错点:constant_pool_count 是加一的(index 0 留给"不引用"语义,永远非法);long/double 占两个槽位,写入一个后下一个 entry 的 index 要 +2,中间槽在 javap 里不出现——早期对齐 8 字节的妥协,至今保留。
先确认手上是哪个版本的文件:
$ javap -v -p target/classes/com/example/Demo.class | head -20
major version: 61
this_class: #3 // com/example/Demo
super_class: #4 // java/lang/Object
Constant pool:
#1 = Methodref #4.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#22 // com/example/Demo.value:I
#3 = Class #23 // com/example/Demo
#4 = Class #24 // java/lang/Object
major version: 61 对应 Java 17,按 JDK 17 的 javap 核对过该行为。想看裸字节,直接扒头 16 字节:
$ xxd -s 4 -l 6 target/classes/com/example/Demo.class
00000004: 0000 003d 0025
003d = 61(Java 17),0025 = 37 → 池里 36 个 entry。
2. 17 种 entry 的 tag 与结构
tag 是每个 entry 的首字节,解析器靠它决定后面读几个字节。tag 2 与 13、14 在正式规范里未使用(13 是早期草案里的 Unicode 常量,从未生效),所以实际只有下面这些:
| tag | entry | 结构 | 用在哪 |
|---|---|---|---|
| 1 | CONSTANT_Utf8_info |
length u2 + bytes |
所有名字和描述符的最终落点 |
| 3 | CONSTANT_Integer_info |
4 bytes |
int 字面量、boolean/char 常量 |
| 4 | CONSTANT_Float_info |
4 bytes |
float 字面量 |
| 5 | CONSTANT_Long_info |
8 bytes,占 2 槽 |
long 字面量 |
| 6 | CONSTANT_Double_info |
8 bytes,占 2 槽 |
double 字面量 |
| 7 | CONSTANT_Class_info |
name_index u2 → Utf8 |
this_class、super_class、类型引用 |
| 8 | CONSTANT_String_info |
string_index u2 → Utf8 |
字符串字面量(ldc 的对象) |
| 9 | CONSTANT_Fieldref_info |
class_index u2 + name_and_type_index u2 |
getfield/putstatic 目标 |
| 10 | CONSTANT_Methodref_info |
同上 | invokevirtual/invokestatic/invokespecial 目标 |
| 11 | CONSTANT_InterfaceMethodref_info |
同上 | invokeinterface 目标 |
| 12 | CONSTANT_NameAndType_info |
name_index u2 + descriptor_index u2 |
名字与描述符的组合 |
| 15 | CONSTANT_MethodHandle_info |
reference_kind u1 + reference_index u2 |
MethodHandle 常量 |
| 16 | CONSTANT_MethodType_info |
descriptor_index u2 → Utf8 |
方法类型常量 |
| 17 | CONSTANT_Dynamic_info |
bootstrap_method_attr_index u2 + name_and_type_index u2 |
动态常量(JEP 309) |
| 18 | CONSTANT_InvokeDynamic_info |
同上两个索引 | invokedynamic 指令的调用点 |
| 19 | CONSTANT_Module_info |
name_index u2 |
模块描述符(JEP 261) |
| 20 | CONSTANT_Package_info |
name_index u2 |
模块描述符 |
tag 9/10/11 结构完全一样,区别只在"被哪条指令使用"。版本 ≥ 52 后,invokevirtual 调接口方法时目标落在 InterfaceMethodref 上,旧解析器会直接拒绝。
CONSTANT_MethodHandle_info 的 reference_kind 取值 1–9,含义固定:
| kind | 语义 | 允许指向的 entry |
|---|---|---|
| 1–4 | getField/getStatic/putField/putStatic |
Fieldref |
| 5 | invokeVirtual |
Methodref |
| 6–7 | invokeStatic/invokeSpecial |
Methodref 或 InterfaceMethodref |
| 8 | newInvokeSpecial |
Methodref,且必须是 <init> |
| 9 | invokeInterface |
InterfaceMethodref |
λ 表达式、字符串拼接(StringConcatFactory)最终都是"一个 MethodHandle + 一组参数常量"塞进 BootstrapMethods 属性,池里只留一个 4 字节索引。
3. 索引怎么互相指:一层套一层的三元组
一个 Methodref 不存"类名.方法名(参数)返回"这种字符串,它存两个索引:class_index 指向 Class,name_and_type_index 指向 NameAndType;NameAndType 再各指一个 Utf8。四层跳转才拿到真正的方法名。
$ javap -v target/classes/com/example/Demo.class | grep -A2 NameAndType
#21 = NameAndType #7:#8 // "<init>":()V
#22 = NameAndType #5:#6 // value:I
#5 = Utf8 value
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
按这条链读 #1:
Methodref #1 ──class_index──▶ #4 Class ──name_index──▶ #24 Utf8 "java/lang/Object"
└─name_and_type─▶ #21 NameAndType ─┬─name_index───────▶ #7 Utf8 "<init>"
└─descriptor_index─▶ #8 Utf8 "()V"
| 引用方 | 字段 | 指向的 entry 类型 | 不匹配时的报错 |
|---|---|---|---|
this_class |
u2 |
Class | ClassFormatError |
Fieldref.class_index |
u2 |
Class | ClassFormatError: Invalid constant pool index |
Fieldref.name_and_type_index |
u2 |
NameAndType | 同上 |
MethodHandle.reference_index |
u2 |
由 kind 决定 | ClassFormatError: Invalid constant pool reference |
InvokeDynamic.bootstrap_method_attr_index |
u2 |
BootstrapMethods 数组下标 |
BootstrapMethodError |
ldc 操作数 |
u1(ldc_w 为 u2) |
可加载常量 | ClassFormatError: Invalid constant type |
ldc 的操作数只有 1 字节,只能索引 0–255;超过就必须用 ldc_w。常量池破 255 项后"同一个字面量编译方式变了",原因就是编译器自动切换了指令宽度。
4. 用 ASM 亲手造一个 invokedynamic
理解了结构,最直接的验证方式是让字节码生成器替你写一遍,再用 javap 对照。下面这段用 ASM 的 ClassWriter 生成一个最小类,把 Runnable 当 MethodType 常量塞进池里:
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
cw.visit(V17, ACC_PUBLIC, "demo/Gen", null, "java/lang/Object", null);
MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "run",
"(Ljava/lang/Runnable;)V", null, null);
mv.visitCode();
// 把 MethodType 常量压栈:类型由 descriptor 决定,池里生成 CONSTANT_MethodType
mv.visitLdcInsn(Type.getMethodType("()V"));
mv.visitInsn(POP);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();
cw.visitEnd();
Files.write(Path.of("demo/Gen.class"), cw.toByteArray());
visitLdcInsn(Type.getMethodType(...)) 会在常量池里落一个 CONSTANT_MethodType_info,它的 descriptor_index 又指向一个 Utf8 "()V"。用 javap 验证:
$ javap -v demo/Gen.class | grep -E "MethodType|Utf8.*\(\)V|ldc"
#5 = MethodType #6 // ()V
#6 = Utf8 ()V
4: ldc #5 // MethodType ()V
用 CONSTANT_Dynamic_info(visitLdcInsn + ConstantDynamic)时 javap 会多打印一段 BootstrapMethods:,因为 tag 17 必须先有可解析的引导方法,否则加载期就抛 BootstrapMethodError——常量池里唯一"结构合法但语义必须外部兑现"的 entry。
三个上限都由 u2 决定:
| 限制 | 数值 | 触发后果 |
|---|---|---|
| 单个 Utf8 长度 | 65535 字节(modified UTF-8) | ClassFormatError: UTF8 string too long |
| 常量池 entry 数 | 65535(含占位的第 0 项) | ClassFormatError: Too many constants |
| 方法描述符参数槽位 | 255(long/double 算 2) |
编译期即报错,落不到 class 文件 |
5. 排错:六种典型异常与成因
| 现象 | 真实原因 | 处理 |
|---|---|---|
Unknown constant tag 0 |
文件被截断,或手工拼接时漏了 entry | 数一遍 constant_pool_count 与实际 entry 数是否吻合 |
Invalid constant pool index 37 |
u2 索引越界,常见于算错槽位(忘了 long 占 2 槽) | javap -v 打印全池,或 ASM CheckClassAdapter 校验 |
NoSuchMethodError 但源码里方法存在 |
Methodref 在运行期指向另一版本 jar | javap -c 看真实调用点,比对运行时签名 |
BootstrapMethodError |
bootstrap_method_attr_index 越界或属性缺失 |
确认版本 ≥ 51,ASM 参数完整 |
IncompatibleClassChangeError |
kind 5 的调用点指向 InterfaceMethodref(或反之) |
别手改 tag,用匹配 JDK 的编译器重生成 |
UnsupportedClassVersionError |
major_version 高于运行 JDK |
用 --release 而非 -source/-target |
最后一条常被混淆:-source 8 -target 8 只改版本号,仍可能引用高版本 API;--release 8 会连 ct.sym 一起换掉。class 文件里没有任何信息能说明"这个 Utf8 是不是高版本 API",这类错误只会在执行到那行 invoke 时暴露。
6. 边界:什么时候不必读常量池
| 场景 | 更合适的入口 | 理由 |
|---|---|---|
| 只想知道方法调了谁 | javap -c(不带 -v) |
指令流足够,池是噪音 |
| 想改字节码 | ASM Tree API |
直接操作池索引容易算错槽位 |
追 invokedynamic 到底调了什么 |
引导方法的 Java 实现 | 池里只有索引 |
| 排查 jar 冲突 | jdeps / mvn dependency:tree |
池看不出"哪个 jar 赢" |
一句话:常量池是结构层知识,解释"为什么能加载/为什么拒绝加载",不解释业务语义。
7. 自测
constant_pool_count读到 37,池里实际有多少个 entry?为什么是这个关系?- 为什么一个
CONSTANT_Long_info之后的下一个 index 会跳过一位? Methodref到真正的方法名要经过几次索引跳转?分别指向哪些 entry?ldc和ldc_w的分界点在常量池的哪个数值上?CONSTANT_Dynamic_info与CONSTANT_InvokeDynamic_info结构相同,语义差别在哪?- 遇到
NoSuchMethodError时,为什么先看javap -c的调用点而不是源码?