《Java 虚拟机原理与性能调优》2.1 类文件结构:常量池、方法表与属性

用 javap -v -p 和 xxd 拆开 Book 与 FinePolicy 的 class 文件:magic 与 major 69、常量池的描述符与两槽常量、字段与方法表、Code/LineNumberTable/BootstrapMethods/Record 属性,并解释 record 的 toString/equals/hashCode 为何是 invokedynamic。

本节目标:能对着 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 类加载过程:加载、链接、初始化与双亲委派 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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