2161 字
约 7 分钟
16
S05-X28 · JVM 类文件常量池:17 种 entry 与它们的交叉索引
2024-12-31
2026-11-09

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. 自测

  1. constant_pool_count 读到 37,池里实际有多少个 entry?为什么是这个关系?
  2. 为什么一个 CONSTANT_Long_info 之后的下一个 index 会跳过一位?
  3. Methodref 到真正的方法名要经过几次索引跳转?分别指向哪些 entry?
  4. ldc 和 ldc_w 的分界点在常量池的哪个数值上?
  5. CONSTANT_Dynamic_info 与 CONSTANT_InvokeDynamic_info 结构相同,语义差别在哪?
  6. 遇到 NoSuchMethodError 时,为什么先看 javap -c 的调用点而不是源码?
S05-X28 · JVM 类文件常量池:17 种 entry 与它们的交叉索引
https://www.clxhxhhr.top/posts/6021/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。