JVM ClassFile 学习笔记

基于《The Java® Virtual Machine Specification(Java SE 8 Edition)》第四章 —— Tim Lindholm, Frank Yellin, Gilad Bracha, Alex Buckley (2015-02-13)

学习路径参照大作业的实现顺序,循序渐进,易于上手。

写在前面:大作业中踩过的坑

Bug 根因
UTF8Info StringBuilder.append(byte) 把 byte → 十进制数字字符串,而不是 byte → 字符
ClassInfo Object.toString() 返回内存地址,而不是业务数据

前言

每个class文件包含一个类或接口的定义
文件class由 8 位字节流组成。所有 16 位、32 位和 64 位数据分别通过读取两个、四个和八个连续的 8 位字节来构建。

所以说,这个.class文件实质上是一个2进制的文件,原文中“文件class由 8 位字节流组成。”更倾向于是一种jvm读取.class文件数据的一种方式——每次读取8位位一个byte。但这并不影响class文件本身还是一个二进制文件。而我们通过编辑器查看到的16进制的.class文件则是将二进制的.class文件每4位转为一个16进制的数,每4个数放在一起,这样形成的类似于“cafe babe”的可视化16进制文件。

多字节数据项始终以”大端序“存储,即高字节在前。
(当 JVM 要把 0xCAFEBABE 写入 .class 文件这条传送带时,它会按照人类读数字从左到右的习惯,把最高字节 CA 放在最前面(最先被读到),然后是 FE、BA,最后才是最低字节 BE。)

连续的项在 class 文件中是按顺序紧凑存储的,没有任何填充(padding)或对齐(alignment)。
(解释:在 C/C++ 等语言中,为了提高 CPU 读取内存的效率,结构体通常会进行“内存对齐”(比如不足 4 字节的会补空字节)。
但是 .class 文件为了极大地压缩体积,采用了完全紧凑的存储方式。举个例子: 如果一个 u1 后面跟着一个 u4,那这个 u4 会紧跟在第 2 个字节解析,中间绝对不会为了对齐而留空。这也意味着解析 class 文件必须严格按照字节流的顺序,错位一个字节,整个文件解析就会全部报错。)

“表(Table)” vs “数组(Array)”

规范中特意区分了这两个词,这是最容易混淆的地方:

数组(Array): 里面每个元素的大小是固定的。比如一个 u2 数组,想要找第 3 个元素,直接用 3 * 2字节 = 偏移量 6 就能定位。

表(Table): 里面每个元素的大小是可变的。例如“常量池表(Constant Pool Table)”,里面有的常量占 3 个字节,有的占 5 个字节。

关键点: 因为大小不固定,你不能通过公式(如 index * size)直接跳转到某个索引,你必须从头开始逐个解析前两项,才知道第三项在文件中的确切字节位置。

4.1. The Structure ClassFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
u4             magic;                    // 魔数
u2 minor_version; // 次版本号
u2 major_version; // 主版本号
u2 constant_pool_count; // 常量池计数器
cp_info constant_pool[constant_pool_count-1]; // 常量池表
u2 access_flags; // 访问标志
u2 this_class; // 当前类索引
u2 super_class; // 父类索引
u2 interfaces_count; // 接口计数器
u2 interfaces[interfaces_count]; // 接口表
u2 fields_count; // 字段计数器
field_info fields[fields_count]; // 字段表
u2 methods_count; // 方法计数器
method_info methods[methods_count]; // 方法表
u2 attributes_count; // 属性计数器
attribute_info attributes[attributes_count]; // 属性表

magic(魔数)

magic 项提供了用于识别 class 文件格式的魔数;它的值固定为 0xCAFEBABE。

minor_version, major_version(次版本号,主版本号)

class文件的版本为 major_version.minor_version
(文件的主版本号为 M,次版本号为 m,我们将其文件格式版本表示为 M.m。因此,class 文件格式的版本可以按字典顺序进行排序,例如:1.5 < 2.0 < 2.1。)
(这里的文件格式版本指的是“主版本号”)
JDK 1.0.2 版本中支持的 class 文件格式版本为 [45.0 , 45.3]
JDK 1.1.* 版本支持的 class 文件格式版本范围是 [45.0 , 45.65535]
JDK 1.k (k > 2)版本支持的 class 文件格式版本范围是 [45.0, 44+k.0](向下兼容,高版本的 JVM 可以运行低版本编译器编译出来的 class 文件。例)

constant_pool_count(常量池计数器)

constant_pool_count 项的值等于 constant_pool(常量池)表中的实体数加 1!!(注意!!)如果一个常量池索引大于零且小于 constant_pool_count,则该索引被认为是有效的;但需要注意 §4.4.5 节中所提到的、针对 long(长整型)和 double(双精度浮点型)类型常量的特殊例外情况。(这个特殊情况就是:如果常量池的索引 5 存放了一个 long 型常量,那么索引 6 将会被这个常量强行霸占,但索引 6 里面什么都没有(处于不可用状态)。常量池的下一个实际常量只能存放在索引 7。而不是继续放 6 .这导致了:常量池的实际项数,往往会少于 constant_pool_count - 1。)

constant_pool

用于表示各种字符串常量、类、接口名、字段名、在 ClassFile 结构体及其子结构体中 被引用的其他常量 。(总之这里就是存储各种常量,供其他部分引用的,统一存储,随处引用)
“在 ClassFile 结构体及其子结构体中被引用的其他常量。”是什么意思?:

  1. 什么是“结构体”(ClassFile)中的引用?
    在 ClassFile 结构的更下层,有很多地方需要表示“我是谁”。
    比如 this_class 项(代表当前 类名 ),它是一个 u2(16位)的数据。它里面存的不是 “MyClass” 这样的字符串,而是存了一个数字(比如 5)。
    这个 5 就是一个指针(符号引用),指向常量池里的第 5 项。第 5 项里才真正存着 “MyClass”。
  2. 什么是“子结构体”中的引用?
    ClassFile 结构体里面还包含了 fields(字段表)和 methods(方法表)这两个子结构体。它们内部也大量引用了常量池:
    字段子结构体(field_info): 如果你在 Java 里写了一行 private int age;。
    这个子结构体里需要记录字段名叫 age。它同样不会直接存 “age” 字符串,而是存一个常量池索引(比如 12),去常量池里找 “age”。
    方法子结构体(method_info): 里面包含了你写的业务代码(编译后的字节码指令)。
    如果你在方法里写了:System.out.println(“Hello World”);字符串 “Hello World” 会被提取出来,塞进常量池。方法字节码中只会包含一条指令,意思是:“去打印常量池第 X 号里面的那个字符串”。
    💡 为什么要用这种“间接引用”的设计?
    核心原因是为了 极大地节省空间(去重)。
    假设你的类里面有 10 个方法,每个方法里都调用了 System.out.println。如果每次都把 System, out, println 这些长长的名字完整写一遍,文件体积会迅速膨胀。采用这种设计后:整个 .class 文件中,”System”, “out”, “println” 这些字符串只在常量池里存一份。别的地方无论用多少次,都只需要用 2 个字节(u2)去引用这个常量的索引即可。这就是为什么规范里说,它是被 ClassFile 及其子结构体所“引用”的其他常量。

constant_pool 表的索引范围是从 1 到 constant_pool_count - 1。
文本提到:“每个实体的格式都由其第一个字节的‘标签(tag)’来指示”。为了让 JVM 能够正确解析,每个常量的 第一个字节(u1) 必定是一个数字标签。常见的标签值(Tag)包括:

Tag = 1:CONSTANT_Utf8(表示一段 UTF-8 编码的字符串)

Tag = 7:CONSTANT_Class(表示一个类或接口的符号引用)

Tag = 9:CONSTANT_Fieldref(表示一个字段的符号引用)

access_flags(访问标志)

access_flags 是一个 u2(16位)的空间,使用“位掩码(Bitmask)”设计
定义了如下常见的值(十六进制):
0x0001:ACC_PUBLIC(该类是 public 的)

0x0010:ACC_FINAL(该类是 final 的,不可被继承)

0x0020:ACC_SUPER(允许使用 invokespecial 字节码指令的新语义)

0x0200:ACC_INTERFACE(这是一个接口,而不是普通的类)

0x0400:ACC_ABSTRACT(这是一个抽象类)
等等

它是如何工作的?如果一个类既是 public 又是 abstract 的,那么 JVM 就会把这两个标志的值进行 按位或(OR)运算 :
0x0001 | 0x0400 = 0x0401
最终写在 class 文件里的 access_flags 的值就是 0x0401。
JVM 读取时,只需通过按位与(AND)运算,就能一瞬间解构出这个类所有的修饰符状态。

访问标志组合的规则:

  1. 接口是通过设置 ACC_INTERFACE 标志来区分的。如果该标志未设置,则此 class 文件定义的是一个类,而不是接口。

  2. 如果设置了 ACC_INTERFACE 标志,则 必须 同时设置 ACC_ABSTRACT 标志,并且 绝对不能 设置 ACC_FINAL、ACC_SUPER 和 ACC_ENUM 标志。

  3. 如果未设置 ACC_INTERFACE 标志,则表 4.1-A 中的其他任何标志都可以设置(ACC_ANNOTATION 除外)。然而,一个普通的类文件 绝对!!不能同时设置 ACC_FINAL 和 ACC_ABSTRACT 标志!!(这符合 Java 语言规范 JLS §8.1.1.2 的规定)。

  4. ACC_SUPER 标志指示了:如果当前类或接口中出现了 invokespecial 字节码指令,该指令应该表达两种备选语义中的哪一种。针对 JVM 指令集的编译器都应该设置 ACC_SUPER 标志。在 Java SE 8 及更高版本中,无论 class 文件中该标志的实际值是多少,也无论该 class 文件的版本如何,JVM 都会默认视其为已设置状态。

  5. ACC_SUPER 标志的存在是为了与旧版 Java 编译器编译的代码保持向后兼容。在 JDK 1.0.2 之前的版本中,编译器生成的 access_flags 中,现在代表 ACC_SUPER 的那一位没有任何分配的含义,且当时 Oracle 的 JVM 实现会直接忽略该标志(即使它被设置了)。

  6. ACC_SYNTHETIC 标志表明该类或接口是由编译器自动生成的,并没有在 Java 源代码中出现。

  7. 注解类型(annotation type)必须 设置 ACC_ANNOTATION 标志。如果设置了 ACC_ANNOTATION 标志,则 必须 同时设置 ACC_INTERFACE 标志。

  8. ACC_ENUM 标志表明该类或其父类被声明为枚举类型。

  9. access_flags 项中所有未在表 4.1-A 中分配的二进制位(Bits)均保留供未来使用。在生成的 class 文件中,这些位应当被隐式设为 0,并且 JVM 的实现应当直接忽略它们。

我们可以把这些复杂的文字规定,简化归纳为以下几条 核心冲突线:

  1. 类的矛盾:FINAL ❌ ABSTRACT
    原因: abstract(抽象类)的唯一活路就是等别人继承它去实现它;而 final 的意思是“离我远点,我断子绝孙,谁也别想继承我”。

如果这两个标志同时为 1,逻辑上就会陷入死循环(一个必须被继承才能用的类,同时又规定不能被继承)。所以 JVM 严令禁止它们共存。

  1. 接口的硬性捆绑
    如果你翻看底层的二进制,会发现 Java 里的 接口(interface)、注解(annotation) 本质上全都是特殊的接口。
    规范中规定:

只要是接口: 必须贴上 INTERFACE。因为接口不能直接 new 实例化,所以必须强制贴上 ABSTRACT。

接口不配拥有 SUPER: 接口在早期没有动态调用父类特殊方法的概念,所以不能有 ACC_SUPER。

如果是注解(@interface): 必须同时拥有 ACC_ANNOTATION + ACC_INTERFACE + ACC_ABSTRACT。

(补充一点接口的知识,之前一直没有理解)

💡 补充知识:理解接口(Interface)

总结成一句话
什么时候不用: 当你在描述 “它是什么”(What it is)时,用类。
什么时候要用: 当你在规定 “它能做什么”(What it can do),且不在乎是谁来做、怎么做时,用接口。

在开发一个复杂的业务系统(比如 Spring Boot 项目)时,你会发现经典的组合是:UserService(接口,规定有哪些用户业务) + UserServiceImpl(实现类,具体的业务逻辑)。之所以这么做,就是为了以后当业务变得极其复杂时,可以随时重构、拆分或替换 UserService 的内部实现,而保证前端控制层(Controller)的代码完全不受影响。

⚡ 核心本质:接口就是“标准插座”
在现实生活中,接口最完美的对应物就是 插座和插头。

仔细想想,中国国家电网(或者插座生产商)在墙上留一个三孔插座时,它知道你未来会插电风扇、手机充电器还是电冰箱吗?它根本不知道,也不需要知道。

电网只规定了一件事:

“管你是什么电器,只要你想从我这里拿电,你的插头就必须长成 3个固定尺寸的扁片,且左零右火上地线。”

这里的插座规范,就是 接口(Interface)。

电网(调用者): 它只负责提供电,它只认“符合3孔标准的插头”。

电器(实现者): 电风扇、电冰箱内部构造千差万别,但为了能通电,它们都实现(Implement)了这 3 个扁片的标准。

💻 映射到代码:接口是“只会提要求,不会干活”的传话筒
在 Java 里,接口(interface)就是一段只提要求、不写实现的代码。

比如,我们是一家做智能家居系统的公司,要对接各种品牌的智能灯。我们不关心每个品牌的灯内部电路是怎么设计的,我们只给所有厂家提要求。

我们定义一个接口:

1
2
3
4
5

public interface SmartLight {
void turnOn(); // 要求1:必须能开灯
void turnOff(); // 要求2:必须能关灯
}

注意: 接口里的方法没有大括号 {},也就是说,它只告诉你“要做什么”,不告诉你“怎么去做”。

现在,飞利浦和小米的工程师拿到了这个接口,他们必须按照这个要求去写具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21


// 飞利浦的工程师写:
public class PhilipsLight implements SmartLight {
public void turnOn() {
// 飞利浦独特的蓝牙唤醒开灯逻辑...
}
public void turnOff() {
// 飞利浦的关灯逻辑...
}
}

// 小米的工程师写:
public class XiaomiLight implements SmartLight {
public void turnOn() {
// 小米走 Wi-Fi 协议的开灯逻辑...
}
public void turnOff() {
// 小米的关灯逻辑...
}
}

💡 为什么要费这个劲?直接写不行吗?
这是最关键的问题:如果不用接口,直接写 PhilipsLight 和 XiaomiLight 不行吗?

不行。如果没有接口,你的智能家居控制面板(主程序)就得这么写:

如果是飞利浦的灯,就调用 philips.openBluetoothLight()

如果是小米的灯,就调用 xiaomi.connectWifiAndTurnOn()

这样做的后果是,你的控制面板会变得无比臃肿,而且每当市场上出现一个新品牌的灯,你就得把控制面板拆开,重新改一遍代码!

有了接口之后:
你的控制面板(主程序)变得极其潇洒:

1
2
3
4
5
6


// 控制面板只认“符合 SmartLight 标准的灯”
public void controlLight(SmartLight light) {
light.turnOn(); // 管你是谁,只要是 SmartLight,直接闭着眼睛调用 turnOn() 准没错!
}

今天插飞利浦,明天插小米,后天就算市场上冒出一个全新的“华为灯”,控制面板的代码一个字都不用改,因为新灯只要实现了 SmartLight 接口,就能直接“插”进你的系统里。

在传参或者声明变量时,类型一定要写成“范围最大、最抽象的那个接口(或抽象类)”

在面向对象编程中,这个高阶技巧有一个极其高大上的名字,叫做 “多态(Polymorphism)”,或者叫 “面向接口编程”

为了让你彻底看懂,我们继续用刚才的“会飞的特斯拉”来做真实的场景演示。


🛠️ 场景 1:如果你的方法只需要“飞行”这个功能

假设你现在正在编写一个 “飞机场(Airport)” 的业务系统,飞机场里有一个“允许起飞”的方法。

飞机场根本不在乎来的是一架真正的波音 747 飞机,还是我们刚才造的那辆会飞的“ CyberTesla 汽车”,它只在乎一件事情:只要你能飞就行。

那么,你的方法形参(类型)应该写:Flyable(接口)

1
2
3
4
5
6
7
8
public class Airport {
// 🚦 核心:传参的类型写成 接口【Flyable】
public void permitTakeOff(Flyable jumper) {
System.out.println("--- 塔台发出起飞指令 ---");
jumper.fly(); // 闭着眼睛调用接口里的 fly 方法,谁来都能飞
}
}

运行的时候怎么传参?

在实际调用这个方法时,你直接把具体写好的实例(小辈们)塞进去就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {
public static void main(String[] args) {
Airport airport = new Airport();

// 1. 造一辆特斯拉
CyberTesla myTesla = new CyberTesla();

// 2. 造一架真飞机(它也实现了 Flyable 接口)
Boeing747 myBoeing = new Boeing747();

// 3. 重点:把它们都作为参数传给飞机场
airport.permitTakeOff(myTesla); // 汽车飞起来了!
airport.permitTakeOff(myBoeing); // 飞机飞起来了!
}
}


场景 2:如果是声明变量(左边写什么,右边写什么)

在日常开发中,我们创建实例时,也经常面临左边写什么类型的问题。通常的黄金法则是:“左边写接口,右边写具体实现类”

1
2
3
4
// ✅ 最推荐的优雅写法
Flyable vehicle = new CyberTesla();
vehicle.fly(); // 没问题,可以飞!

🤔 思考一个问题:如果写成上面这样,我能调用 vehicle.charge()(充电)吗?
答案是:不能! 因为你用 Flyable 的视角去操控这辆车,你的眼里就只有它的飞行功能。JVM 在编译时会认为它只有 fly() 方法,看不到汽车的充电功能。

如果你今天写这段代码,唯一的目的就是让它去充电、去自动驾驶,那声明变量时就应该写它的血缘父类(抽象类):

1
2
3
4
5
6
// 🔋 只想用它作为电动车的功能
ElectricCar myCar = new CyberTesla();
myCar.charge(); // 成功充电!
myCar.autoDrive(); // 成功自动驾驶!
// myCar.fly(); // 报错!汽车视角看不到飞行翅膀

如果你全都要用,既要充电又要飞行,那才写成最具体的实现类:

1
2
3
4
CyberTesla extremeCar = new CyberTesla();
extremeCar.charge(); // 可以
extremeCar.fly(); // 可以


为什么不直接用第三种,而是费劲写接口呢?

我们来看一段在企业开发中 天天都在发生 的真实代码。

假设你要把用户数据存起来,左边写接口,右边写具体的 MySQL 实现:

1
2
3
4
// 面向接口写变量(完美写法)
UserStorage storage = new MysqlUserStorage();
storage.save(user);

三年后,公司决定把数据库换成 Redis。因为你当初传参、写变量全都是用的 UserStorage 接口类型,你现在只需要改动 唯一的一行代码

1
2
3
4
// 只改右边,左边和后面一万行代码动都不用动!
UserStorage storage = new RedisUserStorage();
storage.save(user); // 后面的代码完全不需要修改,因为它们只认 UserStorage 接口

终极总结:

  • 方法传参、声明变量时: 尽量写 接口/抽象类。这叫“留退路”,未来换实现类的时候,别的地方一行都不用改。

    new 后面的实例化时: 必须写 具体实现类。因为接口是虚的,总得有一个具体干活的类出来变成内存里的对象。


3. 历史幽灵:神秘的 ACC_SUPER 标志

文本用了足足两大段解释 ACC_SUPER。这涉及到一个著名的 Java 早期 Bug:
在最早的 Java 设计中,子类如果想用 super.foo() 调用父类被覆盖的方法,底层是用 invokespecial 这条指令。然而早期的解析逻辑有漏洞,导致在某些多层继承的复杂情况下,它找错了解析的父类方法。

为了修复这个 Bug 却又不破坏已经编译好的老程序,Java 团队在 JDK 1.0.2 中引入了 ACC_SUPER 标志:

带了这个标志: 说明是用新编译器编译的,用更安全、更准确的新逻辑去解析 super 调用。

没带这个标志: 说明是古董代码,继续用老的兼容逻辑。

到了 2026 年的今天: 规范里明确说了,“Java 8 及以上,无论你写不写,JVM 强制认为这个标志就是 1”。这意味着这个历史包袱终于在现代 Java 中被强制画上了句号。

4. 什么是 ACC_SYNTHETIC(合成类)?

我们在写 Java 源码时,有时候为了方便会写内部类(Inner Class)、或者在匿名内部类里访问外部类的私有变量。
为了让这些高级语法能在底层的 JVM 上跑通,Java 编译器在后台会偷偷地帮你额外生成一些 .class 文件。这些文件在你的 .java 源码里是看不见的。
为了标记“这是编译器自己偷偷搞的,不是程序员写的”,编译器就会给这种文件打上 ACC_SYNTHETIC 标志。这也是为什么很多安全分析或反射框架会专门去过滤这个标志的原因。

  1. 关于注解的“捆绑约束”
    规范大意: 是注解(ACC_ANNOTATION)就必须是接口(ACC_INTERFACE)。

人话翻译: 这叫 “底层复用”。

本质揭秘: Java 在 JDK 1.5 引入了 注解(Annotation)(比如你在代码里写的 @Override 或 @Test)。

JVM 团队在设计注解时,面临两个选择:要么从零开始在虚拟机底层设计一套全新的数据结构去支持注解,要么“借鸡生蛋”。他们选择了后者——在底层,注解其实就是一个特殊的接口!

当你在 Java 里写:

1
public @interface MyAnnotation { ... }
编译器在编译它时,会把它翻译成:
1
public interface MyAnnotation extends java.lang.annotation.Annotation { ... }

所以规范里说,只要你打上了 ACC_ANNOTATION(说明它是注解),你必须同时打上 ACC_INTERFACE(因为它的底层骨架就是个接口)。
8. 关于 ACC_ENUM 的“身份认证”
规范大意: 标记自己或父类是个枚举。

人话翻译: 告诉 JVM,“我是个限购的字典,别让人随便 new 我”。

本质揭秘: 同注解一样,枚举(Enum)在底层也只是一个普通的类,它默认继承了 java.lang.Enum。

打上 ACC_ENUM 标志是为了做严格的运行时保护。JVM 在看到这个标志后,会启动特殊保护机制:

绝对禁止反射实例化: 任何人如果尝试用反射去调用枚举的构造函数(Constructor.newInstance()),JVM 一看到这个类带了 ACC_ENUM 标志,会直接甩出一个 IllegalArgumentException 异常。这就从底层保证了枚举单例的绝对安全。

  1. 关于未分配二进制位的“未来留白”
    规范大意: 暂时没用的位填 0,JVM 读到了就假装看不见(忽略)。

人话翻译: 这叫 “留退路”、“向前兼容设计”。

为什么要这么规定? access_flags 是一个 u2(16位)的空间。目前表 4.1-A 只用了其中的不到 10 个位,还剩好几个位是空着的。

如果现在不立规矩会怎样? 假设某个野路子编译器在生成 class 文件时,图省事把空着的位随便填了 1。如果现在的 JVM 不管它,程序能跑。

但到了几年后,Java 官方发布了新版本,决定启用这空着的一位作为新功能(比如代表某个未来的新特性)。

这时候,当年那个野路子编译器编译出来的老文件,在新 JVM 上跑就会被误认为“启用了新功能”,程序直接崩溃。

所以规矩立在当下:

对编译器提要求: 没定义的位,你必须老老实实写 0。

对 JVM 提要求: 哪怕你在读老文件时发现里面有杂音(不是0),你也必须当成 0 直接忽略,不准报错。
这样,未来 Java 官方随时可以启用这些空位,而不会伤害任何历史遗留的老系统。

归纳理解 一个.class文件的结构体是如何引用常量池表中的data的

// 这是一个连续的字节流文件:User.class

[ 0xCAFEBABE ] <– 魔数 (magic)
[ 0x0000 0x003D ] <– 版本号 (61.0, 即 JDK 17)

// ===================【常量池表(这里只存文本和符号)】===================
// 常量池就像一张由编号的表格,里面只存“零件”
[ 索引 #1 ] TAG=1 (Utf8字符串) -> 值: “User” (这只是4个英文字母)
[ 索引 #2 ] TAG=7 (Class类信息) -> 指向索引 #1 (“User”) (这代表一个类名符号)
[ 索引 #3 ] TAG=1 (Utf8字符串) -> 值: “java/lang/Object” (这只是16个英文字母)
[ 索引 #4 ] TAG=7 (Class类信息) -> 指向索引 #3 (“java/…”) (这代表父类名符号)
// =======================================================================

[ 0x0001 ] <– 访问标志 (access_flags, 代表 public)

[ 0x0002 ] <– 这就是【this_class】!
// 它就存站在这里,占2个字节,它的值是数字 2。
// 意思是:“我当前类的名字,请去常量池找 2 号选手”。

[ 0x0004 ] <– 这就是【super_class】!
// 它的值是数字 4。
// 意思是:“我父类的名字,请去常量池找 4 号选手”。
(可对照的classFile structure:

ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

JVM 是如何顺藤摸瓜的?
当虚拟机(JVM)把这个 User.class 加载到内存里时,它是这样通过 this_class 找到类名的:1.读取固定座位:JVM 解析到 ClassFile 结构体的中间,读到了 this_class 的专属座位,发现里面填写的数字是 2。2.查询常量池类型:JVM 跑到【常量池表】的 第 2 号 位置,看了一眼这里的标签(Tag)。标签说:“报告老大,我是 CONSTANT_Class_info(类符号信息),我的文本在 #1 号 位置”。3.提取最终字符串:JVM 再次跳转到【常量池表】的 第 1 号 位置,在这里它读到了纯文本字符串:”User”。4.完成身份认证:JVM 拍板:OK!这个 class 文件的名字叫 User。

神秘的 super_class = 0:Java 的“始祖之神”

规范中写道:“如果 super_class 的值是 0,那么这个文件必须代表 java.lang.Object”。
还记得我们最开始提到的常量池 0 号索引留空的规则吗?这就是它的终极用武之地!

原文:interfaces_count(接口计数器)interfaces_count 项的值给出了当前类或接口类型的直接父接口(direct superinterfaces)的数量。interfaces[](接口表)interfaces 数组中的每个值都必须是 constant_pool(常量池)表中的一个有效索引。对于每一个 interfaces[i](其中 $0 \le i < \text{interfaces_count}$),其对应的常量池实体必须是一个 CONSTANT_Class_info 结构体,用以表示当前类或接口类型的直接父接口。这些接口在数组中的顺序,必须严格对应它们在源代码中从左到右出现的顺序。fields_count(字段计数器)fields_count 项的值给出了 fields 表中 field_info 结构体的数量。这些 field_info 结构体表示了当前类或接口类型所声明的所有字段,包括类变量(静态变量)和实例变量(成员变量)。fields[](字段表)fields 表中的每个值都必须是一个 field_info 结构体(详见 §4.5 节),用以提供对当前类或接口中某个字段的完整描述。fields 表仅包含由当前类或接口显式声明的字段,它绝不包含那些从父类或父接口继承而来的字段。

有关于字段表(Fields)只存this_class的field而不存super_class的field

只认亲生,不认继承,这是读懂 .class 文件极其重要的一点:fields[] 里面只存当前类自己手写的变量,哪怕你继承了一个拥有 100 个属性的超大父类,当前类的 fields_count 依然可以是 0。为了让你彻底明白,我们来看一个代码对比:Java// 父类
public class Father {
public int money = 1000000; // 很有钱
}

// 子类
public class Son extends Father {
public int age = 18; // 自己只定义了一个年龄
}
当你去编译 Son.java 并查看 Son.class 文件的二进制结构时:fields_count 的值是 1。fields[] 里面有且仅有一个 field_info 结构,里面记录着 age。那父类的 money 去哪了? 抱歉,money 只存在 Father.class 里。💡 那么问题来了:既然子类文件里没有 money,那我们在代码里写 son.money 时,JVM 是怎么找到这个变量的?这就是 JVM 动态链接的魅力所在。1.当前类查找:JVM 在运行到 son.money 时,首先打开 Son.class 的 fields[] 表,翻了一遍,发现里面只有 age,没有 money。2.顺藤摸瓜找父类:JVM 顺着 Son.class 里的 super_class 指针,一回头找到了它的父类 Father.class。3.父类表查找:JVM 打开 Father.class 的 fields[] 表,在里面成功翻到了 money 字段。4.定位并读取:JVM 成功定位到数据,完成读取!这种“只存自己声明的”设计,再次体现了 JVM 规范为了节省磁盘和内存空间所做的极致努力——绝不存储任何重复的冗余信息。

原文:译文(Translation)methods_count(方法计数器)methods_count 项的值给出了 methods 表中 method_info 结构体的数量。methods[](方法表)methods 表中的每个值都必须是一个 method_info 结构体(详见 §4.6 节),用以提供对当前类或接口中某个方法的完整描述。如果在某个 method_info 结构体的 access_flags(访问标志)中,既没有设置 ACC_NATIVE(本地方法)标志,也没有设置 ACC_ABSTRACT(抽象方法)标志,那么实现该方法的 Java 虚拟机指令(字节码)也将一并在此处提供。这些 method_info 结构体表示了当前类或接口类型所声明的所有方法,包括:实例方法(普通成员方法)、类方法(静态方法)、实例初始化方法(即 构造函数) 以及任何 类或接口的初始化方法(即 静态代码块)。与字段表类似,methods 表中 绝不包含 那些从父类或父接口继承而来的方法。
原文:attributes_count(属性计数器)attributes_count 项的值给出了当前类 attributes 表中属性的数量。attributes[](属性表)attributes 表中的每个值都必须是一个 attribute_info 结构体(详见 §4.7 节)。本规范中所定义的、允许出现在 ClassFile 结构体的 attributes 表中的属性都列在了《表 4.7-C》中。关于这些预定义属性的具体规则在 §4.7 节中给出。关于非预定义属性(即自定义属性)的规则在 §4.7.1 节中给出。

举一个例子:
public class Demo {
public void hello() {
int a = 10;
}
}
.class:
{
// … 前面是魔数、版本号、类名指针、父类指针等,这里省略 …

public Demo(); // <– 【方法表中的第 1 个方法】:编译器自动生成的默认无参构造函数
descriptor: ()V // 方法描述符:无参,返回值是 void (V)
flags: (0x0001) ACC_PUBLIC // 方法的访问标志:public
Code: // 🎁 核心:这是一个【属性】!属于方法表内部的属性
stack=1, locals=2, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object.”“:()V
4: return
LineNumberTable: // 🎁 嵌套属性:行号表属性,挂在 Code 属性下面
line 1: 0

public void hello(); // <– 【方法表中的第 2 个方法】:我们手写的 hello 方法
descriptor: ()V // 方法描述符:无参,返回 void
flags: (0x0001) ACC_PUBLIC // 方法访问标志:public

// ===================【以下开始是该方法的属性表 (Attributes) 】===================
Code:                         // 🎁 【属性 1】:Code 属性,存放真正的字节码指令
  stack=1, locals=2, args_size=1 // 栈深度 1,局部变量表 2 格,参数 1 个 (就是隐藏的 this)
  
  // 真实运行的 JVM 指令流:
  0: bipush        10         // 将单字节常量 10 推入操作数栈
  2: istore_1                 // 将栈顶的 10 存入局部变量表的第 1 号槽位 (即变量 a)
  3: return                   // 方法返回
  //字节码指令长什么样?在 Code 属性里,真正让 CPU 运行的指令长这样:
    0: bipush 10    
    2: istore_1
    3: return
    如果用十六进制编辑器去看这个 .class 文件的原始二进制,这三行代码对应的就是四个字节:
    10 0A 3C B1。
    10 代表 bipush 指令
    0A 代表十六进制的 10
    3C 代表 istore_1 指令
    B1 代表 return 指令
  // -----------------【注意!Code 属性自己也有一个属性表!】-----------------
  LineNumberTable:            // 🎁 【嵌套属性 1.1】:行号映射表(源码行号 vs 字节码偏移量)
    line 3: 0                 // 源码中的第 3 行 (int a = 10;) 对应字节码第 0 步
    line 4: 3                 // 源码中的第 4 行 (方法的大括号结束) 对应字节码第 3 步
    
  LocalVariableTable:         // 🎁 【嵌套属性 1.2】:局部变量表属性
    Start  Length  Slot  Name   Signature
        0       4     0  this   LDemo;   // 0号槽位留给了隐藏的当前对象指针 this
        3       1     1     a   I        // 1号槽位留给了我们的变量 a (类型是整型 I)
// ==============================================================================

}
SourceFile: “Demo.class” // 🎁 【全类属性】:这是挂在 ClassFile 结构体最末尾的属性
// 说明这个类是从哪个源文件编译出来的

注意:arg_size 始终为 1,尽管我们并没有传入参数。

只要不是 static 静态方法,Java 编译器在编译任何一个普通的成员方法时,都会在参数列表的第一位偷偷塞入一个当前对象的引用(this)。这就是为什么你在普通方法内部随时随地都可以使用 this.xxx 的底层原因。

再举一个 16 进制的字节码示例:

示例:public class Demo {}

// =======================================================================
// Demo.class 真实 16 进制流:方法表与类属性表的“逐行摊平拆解”
// =======================================================================

[ 0x0001 ] <– 方法计数器 (methods_count, 占 2 字节)
// 值为 1。意思是:“这个类里有 1 个方法”(即编译器自动生成的构造函数)

// ========================【方法表 (methods[0]) 开始】========================
// 接下来是这唯一一个方法的 method_info 结构体

[ 0x0001 ] <– 方法的访问标志 (access_flags, 占 2 字节)
// 值为 0x0001(代表 ACC_PUBLIC,即这是一个 public 方法)

[ 0x0005 ] <– 方法名索引 (name_index, 占 2 字节)
// 指向常量池 5 号选手。在完整的常量池里,5号选手的文本正是 “

[ 0x0006 ] <– 方法描述符索引 (descriptor_index, 占 2 字节)
// 指向常量池 6 号选手。6号选手的文本是 “()V”,代表无参、返回值为 void

[ 0x0001 ] <– 方法内部的属性计数器 (attributes_count, 占 2 字节)
// 值为 1。意思是:“这个方法自带了 1 个属性包裹”

// —————–【方法内部的属性表 (attributes[0]) 开始】—————–
// 这个属性包裹就是用来存放真正执行代码的 Code 属性

[ 0x0007 ]                  <-- 属性名索引 (attribute_name_index, 占 2 字节)
                            // 指向常量池 7 号选手。7号选手的文本是 "Code"
                            // JVM 看到这里恍然大悟:“噢!原来这个属性是用来装字节码的!”

[ 0x00000011 ]              <-- 属性长度 (attribute_length, 占 4 字节)
                            // 十六进制 11 转十进制是 17。
                            // 意思是:“我后面还跟着 17 个字节的数据,全属于这个 Code 属性”。

[ 0x0001 ]                  <-- 操作数栈最大深度 (max_stack, 占 2 字节)
                            // 值为 1。

[ 0x0001 ]                  <-- 局部变量表最大槽数 (max_locals, 占 2 字节)
                            // 值为 1(用来存放隐藏的参数 this)。

[ 0x00000005 ]              <-- 字节码长度 (code_length, 占 4 字节)
                            // 值为 5。意思是:“接下来的 5 个字节是真正的 JVM 机器指令!”

[ 0x2A 0xB7 0x00 0x03 0xB1 ] <-- 真正的 JVM 指令流 (code[5], 占 5 字节)
                            // 0x2A : aload_0 (把 this 加载到栈顶)
                            // 0xB7 : invokespecial (调用特殊方法)
                            // 0x00 0x03 : 指向常量池 3 号选手 (即父类 Object 的构造函数)
                            // 0xB1 : return (方法返回)

[ 0x0000 ]                  <-- 异常表长度 (exception_table_length, 占 2 字节)
                            // 值为 0。意思是:“这个方法没有写 try-catch,无异常捕获”。

[ 0x0000 ]                  <-- Code 属性自己的属性计数器 (attributes_count, 占 2 字节)
                            // 值为 0。这里为了最简编译,去掉了行号表等调试属性。
                            

// —————–【方法内部的属性表结束】—————–
// ========================【方法表 (methods[0]) 结束】========================

[ 0x0001 ] <– 整个类级别的属性计数器 (attributes_count, 占 2 字节)
// 值为 1。意思是:“整个类文件的最末尾,还挂着 1 个全局属性”。

// ========================【类级别的属性表 (attributes[0]) 开始】========================
// 这也是整个 .class 文件的最后结尾

[ 0x0008 ] <– 属性名索引 (attribute_name_index, 占 2 字节)
// 指向常量池 8 号选手。8号选手的文本是 “SourceFile”

[ 0x00000002 ] <– 属性长度 (attribute_length, 占 4 字节)
// 值为 2。意思是:“我后面还跟着 2 个字节的数据”。

[ 0x0009 ] <– 源码文件名索引 (sourcefile_index, 占 2 字节)
// 指向常量池 9 号选手。9号选手的文本是 “Demo.java”
// 作用:告诉 JVM,这个文件最初是从 “Demo.java” 编译来的。

// ========================【类级别的属性表 结束】========================
// EOF (文件到此全部结束)

常量池(Constant Pool)详解

原文对照翻译 (Translation)

段落 1

原文:Java Virtual Machine instructions do not rely on the run-time layout of classes, interfaces, class instances, or arrays. Instead, instructions refer to symbolic information in the constant_pool table.

翻译:Java 虚拟机指令并不依赖于类、接口、类实例(对象)或数组的运行时内存布局(Run-time Layout)。相反,JVM 指令是通过指向 constant_pool(常量池)表中的符号信息(Symbolic Information)来发起引用的。

段落 2

原文:All constant_pool table entries have the following general format:

1
2
3
4
5
cp_info {
u1 tag;
u1 info[];
}

翻译:所有常量池表的实体项都遵循以下通用格式:

1
2
3
4
cp_info {
u1 tag; // 1字节的类型标签
u1 info[]; // 变长的具体信息数组
}

段落 3

原文:Each item in the constant_pool table must begin with a 1-byte tag indicating the kind of cp_info entry. The contents of the info array vary with the value of tag. The valid tags and their values are listed in Table 4.4-A. Each tag byte must be followed by two or more bytes giving information about the specific constant. The format of the additional information varies with the tag value.

翻译:常量池表中的每一个具体项都必须以一个 1 字节的标签(tag)开头**,用以指示该 cp_info 实体的具体种类。info 数组的内容则根据 tag 的值而有所不同。《表 4.4-A》列出了所有合法的标签及其对应的数值。每一个标签字节的后面,必须紧跟两个或更多字节的数据来提供该特定常量的具体信息。附加信息的具体格式由标签的值决定。


核心规范深度解释

这段规范是理解 JVM 如何在内存中“穿针引线”的灵魂。我们将其核心设计拆解为以下两个关键维度:

1. 深度理解:什么是“不依赖运行时布局,只认符号引用”?

这是 Java 与 C/C++ 等传统语言最本质的区别。

  • 在 C/C++ 世界里(硬编码物理地址):
    当程序调用一个方法时,编译出的机器码直接写死内存指针(如:call 0x0012FA30)。如果被调用的方法在内存里的位置变了,程序就会立刻崩溃,因此必须重新编译。
  • 在 Java 世界里(软解耦符号连接):
    Java 编译出的机器码指令(字节码)极其傲娇,它们根本不认内存地址。任何涉及方法调用、字段访问的指令,后面跟着的全部都是常量池里的索引号

💡 举个真实的运行栗子:

假设你的字节码里有一条指令要调用某个方法,它在底层展现出来就是:

invokevirtual #10

这条指令的意思是:“我要调用一个方法,具体是哪个方法?我不知道。请去常量池表的第 10 号床位查看‘说明书’。”

JVM 在运行到这一步时,会顺着 #10 展开三步查找:

  1. 去常量池 #10 看到这是一个 CONSTANT_Methodref(方法引用符号)。
  2. 该符号指出了两个文本:“类名叫 com/Demo,方法名叫 hello”。
  3. JVM 此时才在内存里动态去查找 com/Demo 类的 hello 方法在哪,并把它临时绑定起来。

这就是符号引用(Symbolic Reference)。它让 .class 文件彻底脱离了具体物理内存的束缚,做到了“一次编写,到处运行”。


2. 玩转 ByteBuffer:如何利用 cp_info 的通用的包头格式?

规范给出的 cp_info 结构,是一个标准的**“TLV (Tag-Length-Value)”**协议包头。

当你在代码里使用 ByteBuffer buffer 去剥离常量池时,这个 1 字节的 tag 就是你的“分流器”。规范强调:“每一个标签字节后面必须紧跟两个或更多字节”

我们在上一节写到的 for 循环里,在拿到不同的 tag 后,buffer 内部指针的吞吐步长是完全被规范定死的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 用你最喜欢的“单行摊平”格式,看看 buffer 是如何被规范指挥的:

byte tag = buffer.get(); // 📥 永远先吸 1 个字节的 tag

// 根据规范的【表 4.4-A】,开始对号入座:

[ 如果 tag == 7 ] (CONSTANT_Class)
// 规范规定:Class 后面紧跟 2 字节的 name_index
short nameIndex = buffer.getShort(); // 📥 指针极其自信地向后移动 2 字节

[ 如果 tag == 9 ] (CONSTANT_Fieldref 字段引用)
// 规范规定:Fieldref 后面必须跟 4 字节(2字节类索引 + 2字节名字与类型索引)
short classIndex = buffer.getShort(); // 📥 移动 2 字节
short nameAndTypeIndex = buffer.getShort(); // 📥 再移动 2 字节

[ 如果 tag == 1 ] (CONSTANT_Utf8 纯文本)
// 规范规定:Utf8 后面先跟 2 字节代表长度,再跟具体字节
int length = buffer.getShort() & 0xFFFF; // 📥 先读出长度(假设是 6 字节)
byte[] bytes = new byte[length];
buffer.get(bytes); // 📥 指针精准地向前横扫 6 字节,把文本吃掉

总结:
常量池表之所以叫“零件表”,是因为它由这 10 多种由 tag 领衔的变长 cp_info 结构体排排坐组成的。你的 ByteBuffer 解析器只要识别了开头的 tag,就能完美算准下一步该让传送带向后推进几个字节,绝不会发生数据错位!

这一部分是整个常量池的真正核心,专门用来给类、字段(属性)、类方法、接口方法这四大金刚制作“说明书”。

4.4.1. CONSTANT_Class_info 结构

1. 原文对照翻译 (Class)

CONSTANT_Class_info 结构体用于表示一个类或一个接口:

1
2
3
4
5
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}

该结构体的具体项如下:

  • tagtag 项的值为 CONSTANT_Class (7)。
  • name_indexname_index 项的值必须是 constant_pool(常量池)表中的一个有效索引。该索引处的常量池实体必须是一个 CONSTANT_Utf8_info 结构体(详见 §4.4.7 节),用以表示一个有效的、采用内部形式(详见 §4.2.1 节)编码的二进制类或接口名称。

由于数组也是对象,因此字节码指令 anewarray(创建引用类型数组)和 multianewarray(创建多维数组)——但注意不包括 new 指令——可以通过常量池中的 CONSTANT_Class_info 结构体来引用数组“类”。对于这类数组类,其类名就是该数组类型的描述符(详见 §4.3.2 节)。

  • 例如,表示二维整型数组类型 int[][] 的类名是 [[I;而表示线程数组类型 Thread[] 的类名是 [Ljava/lang/Thread;
  • 一个数组类型描述符只有在它表示 255 维或更少维度时才是合法的。

2. 深度硬核解释 (Class)

这一段透露了两个绝大多数 Java 程序员都不知道的底层冷知识:

💡 冷知识一:new 出来的绝对不能是数组

规范明确指出:数组类也可以用 CONSTANT_Class_info 表达,但 new 指令绝对不能去引用它。
你在 Java 代码里写 new int[10],编译成字节码后,底层的指令根本不是 new,而是 newarray

  • new 指令在 JVM 里是专门用来给普通的、非数组的类实例在堆中开辟空间的。
  • 创建数组在 JVM 层面有独立的高级指令(newarray, anewarray, multianewarray)。

💡 冷知识二:Java 的套娃天花板——255 维数组

规范里写死了限制:数组最多只能有 255 维。也就是说,你写 int[][][...][][] 最多只能套 255 层方括号。虽然平时没人会写出这种疯狂的代码,但在手写编译器或缓冲区解析时,这是一个必须卡死的边界条件。

🛠️ 单行摊平格式:看 ByteBuffer 如何读取 CONSTANT_Class_info

1
2
3
4
5
6
7
// 当 buffer 读到一个 tag == 7 的零件:
[ 0x07 ] <-- 标签 (tag, 占 1 字节)
// 代表这是 CONSTANT_Class_info

[ 0x0002 ] <-- 类名文本索引 (name_index, 占 2 字节)
// 它的值是一个数字(比如 2),指向常量池 #2 项(必须是 Utf8 纯文本)


4.4.2. Fieldref、Methodref 和 InterfaceMethodref 结构

【#10 号零件:方法大组合 (Methodref)】

├── 👈 左手引用碎片 A -> [ #3 Class ] ───► 再次引用 -> [ #1 Utf8 纯文本: “java/lang/Thread” ]
│ (户口在哪个类)

└── 👉 右手引用碎片 B -> [ #21 NameAndType ]

├── 引用更小的碎片 B1 -> [ #14 Utf8 纯文本: “sleep” ]
│ (它叫什么名字)

└── 引用更小的碎片 B2 -> [ #15 Utf8 纯文本: “(J)V” ]
(它长什么样:接收长整型,返回void)

1. 原文对照翻译 (Refs)

字段(Fields)、方法(Methods)和接口方法(Interface Methods)由相似的结构体表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CONSTANT_Fieldref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}

CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}

CONSTANT_InterfaceMethodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}

这些结构体的具体项如下:

  • tag

  • CONSTANT_Fieldref_info 结构体的 tag 项的值为 CONSTANT_Fieldref (9)。

  • CONSTANT_Methodref_info 结构体的 tag 项的值为 CONSTANT_Methodref (10)。

  • CONSTANT_InterfaceMethodref_info 结构体的 tag 项的值为 CONSTANT_InterfaceMethodref (11)。

  • class_index:其值必须是 constant_pool 表中的一个有效索引。该索引处的常量池实体必须是一个 CONSTANT_Class_info 结构体(详见 §4.4.1 节),用以表示一个将该字段或方法作为其成员的类或接口类型。

  • CONSTANT_Methodref_infoclass_index 必须是一个类类型,不能是接口类型。

  • CONSTANT_InterfaceMethodref_infoclass_index 必须是一个接口类型

  • CONSTANT_Fieldref_infoclass_index 既可以是类类型,也可以是接口类型。

  • name_and_type_index:其值必须是 constant_pool 表中的一个有效索引。该索引处的常量池实体必须是一个 CONSTANT_NameAndType_info 结构体(详见 §4.4.6 节)。这个常量池实体用以指示该字段或方法的名称(Name)描述符(Descriptor)

  • CONSTANT_Fieldref_info 中,该描述符必须是一个字段描述符(详见 §4.3.2 节)。否则,该描述符必须是一个方法描述符(详见 §4.3.3 节)。

  • 如果 CONSTANT_Methodref_info 结构体中方法的名字以 < ('\u003c') 开头,那么该名字必须是特殊名称 <init>,用以表示实例初始化方法(详见 §2.9 节)。此类方法的返回值类型必须为 void


2. 深度硬核解释 (Refs)

这三个结构体是 Java 符号引用(解耦)的最高体现。它们被称为“引用(Ref)”,因为它们自己不生产内容,完全是拉拢别人来组合。

不管你要调用一个方法,还是去读写一个变量,JVM 规定你必须把三个要素讲清楚:

  1. 它在哪(哪个类里)? $\rightarrow$ 由 class_index 指向 Class_info 来回答。
  2. 它叫什么名字? $\rightarrow$ 由 name_and_type_index 的前半部分回答。
  3. 它长什么样(什么类型/什么参数返回值)? $\rightarrow$ 由 name_and_type_index 的后半部分回答。

💡 绝妙的细节划分:Methodref vs InterfaceMethodref

规范极其严厉地把“类的方法(Tag=10)”和“接口的方法(Tag=11)”分成了两个不同的结构体。

  • 如果你去调用 Thread.sleep(),编译器会生成一个 CONSTANT_Methodref_info
  • 如果你去调用 List.add(),编译器会生成一个 CONSTANT_InterfaceMethodref_info

为什么要分得这么细? 因为在 JVM 内部,调用普通类的方法(用 invokevirtual 指令)和调用接口的方法(用 invokeinterface 指令)的方法查找算法是完全不同的。普通的类有连续的虚方法表(vtable),查找极快;而接口允许多实现,在底层需要通过更复杂的接口表(itable)去动态搜索。为了让 JVM 在一读到常量池时就做好思想准备,规范在 tag 上就直接分流了。

💡 构造函数的强制约束

最后一句说:如果方法名以 < 开头,它必须是 <init>(构造函数),且返回值必须为 void。这在底层彻底堵死了任何人企图通过魔改字节码,让构造函数返回其他非空对象的破坏行为。

🛠️ 单行摊平格式:看 ByteBuffer 如何读取方法引用

1
2
3
4
5
6
7
8
9
10
// 当 buffer 读到一个 tag == 10 的普通类方法引用零件:
[ 0x0A ] <-- 标签 (tag, 占 1 字节)
// 代表这是 CONSTANT_Methodref_info

[ 0x0003 ] <-- 类索引 (class_index, 占 2 字节)
// 指向常量池 #3 项(必须是一个 CONSTANT_Class_info,代表哪个类)

[ 0x0015 ] <-- 名字与类型索引 (name_and_type_index, 占 2 字节)
// 指向常量池 #21 项(代表这个方法叫什么,长什么样)

至此,常量池里最核心的几个“套娃指针零件”的内部构造,已经在你的解析引擎(ByteBuffer)面前暴露无遗了!

“既然我们已经身在 User.class 这个文件里了,这里面调用的方法和字段,难道不都是当前类自己的吗?为什么还要在每个方法和字段引用里存一个 class_index 指向它属于哪个类?”

这其实是因为我们产生了一个错觉:以为 class 文件里只会引用自己写的方法和字段。

但实际上,只要你写一行稍微实用一点的代码,你的 class 文件里就会充斥着大量外部类的方法和字段。class_index 就是为了解决“谁的”这个问题而存在的。

我们用两个最经典的场景,看完你就会恍然大悟:

场景一:调用别的类(比如 Java 自带的类)
假设你在自己的类里写了这么一行最普通的打印代码:

1
System.out.println("hello");

编译之后,你的 class 文件的方法表(Methods)里,绝对没有 println 这个方法,对吧?这个方法是 java.io.PrintStream 那个类里的。

那 JVM 怎么知道去哪调用它呢?这就全靠 CONSTANT_Methodref_info 里的 class_index 了:

C

// 常量池里的 println 方法引用明细:
[ 索引 #4 ] TAG=10 (Methodref 方法引用)
├── class_index: 指向 “java/io/PrintStream” <–【看这里!】
└── name_and_type_index: 指向 “println” + “(Ljava/lang/String;)V”
如果没有 class_index,JVM 读到 invokevirtual #4 时,就只知道你想调用一个叫 println 的方法,但它会像无头苍蝇一样:“天哪!全天下叫 println 的方法多了去了,这到底是哪家类里的 println 啊?!”

有了 class_index,JVM 一眼就锁定了:噢,去 PrintStream 类里找!

场景二:哪怕调用自己类的方法,也是为了处理“继承”
“好吧,调用外面的类我懂了。但如果我调用自己类里手写的方法,为什么也要有 class_index 呢?”

因为 Java 有继承机制。

假设你写了下面这样的父子类:

1
2
3
4
5
6
7
8
9
10
11


class Father {
public void run() {}
}

class Son extends Father {
public void test() {
run(); // 子类调用了父类的方法
}
}

当你编译子类 Son.java 时:

Son.class 自己的方法表里,只有 test(),并没有 run()(因为只存亲生,不存继承)。

在 test() 方法的字节码指令里,会有一条:invokevirtual #5(调用 run 方法)。

此时,这个 #5 常量池项,就是一个 Methodref。它的 class_index 指向谁?
它会指向 Father 类!

JVM 执行到这里,一看到 class_index 写的是 Father,就知道应该去父类的地图里搜寻 run 方法的字节码。

💡 终极总结
在二进制的世界里,CONSTANT_Fieldref_info 和 CONSTANT_Methodref_info 的本质是 “精确到点的全网通缉令”。

如果一个引用只包含“名字”和“长相”,那它只是个半成品。在 Java 这种允许跨文件、跨 Jar 包动态链接的语言里,你必须通过 class_index 把它的 “户口所在地(属于哪个类)” 永久地锁死,JVM 才能在运行时精准地跨越内存去抓取它。

常量池图解:
【 字节码指令流 】
getstatic #3 ───┐ (获取静态字段)
ldc #4 ───┼─┐ (加载常量)
invokevirtual #5 ───┼─┼─┐ (调用成员方法)
│ │ │
========================│═│═│==========================================
【 常量池表 (constant_pool) 】 │ │ │
│ │ │
[ #1 ] CONSTANT_Utf8 <┼─┼─┼─── 值: “java/lang/System” (类名纯文本)
│ │ │
[ #2 ] CONSTANT_Class <┼─┼─┼─── name_index: 指向 #1 (“java/lang/System”)
│ │ │
┌───────────────────────┘ │ │
▼ │ │
[ #3 ] CONSTANT_Fieldref │ │ (字段通缉令)
├── class_index ──┴─┼─── 指向 #2 (类: java/lang/System)
└── name_and_type_index ───┐ 指向 #6 (名字: out, 类型: Ljava/io/PrintStream;)
│ │ │
[ #4 ] CONSTANT_String ◄───┘ │ │ ├── name_index ──► [ #10 ] Utf8: “out”
└── string_index ─────┼──┐ │ └── descriptor ──► [ #11 ] Utf8: “Ljava/io/PrintStream;”
│ │ │
┌─────────────────────────────┘ │ │
▼ │ │
[ #5 ] CONSTANT_Methodref │ │ (方法通缉令)
├── class_index ─────────┼─┼─── 指向 #7 (类: java/io/PrintStream) ──► 指向 #8 (Utf8: “PrintStream”)
└── name_and_type_index ─┼─┼─── 指向 #9 (名字: println, 类型: (Ljava/lang/String;)V)
│ │ │
[ #12 ] CONSTANT_Utf8 ◄──────────┘ │ ├── name_index ──► [ #13 ] Utf8: “println”
值: “hello” │ └── descriptor ──► [ #14 ] Utf8: “()V”

[ #6 ] CONSTANT_NameAndType <──────┘
[ #9 ] CONSTANT_NameAndType <──────────────────────────────────────────┘
=======================================================================

来了!我们继续用精准原文对照翻译配合硬核原理解析单行摊平格式,把常量池里专门存放“基本数据类型”的几大结构彻底拆解。

这次登场的是:字符串对象(String)整型(Integer)单精度浮点型(Float)


4.4.3. CONSTANT_String_info 结构

1. 原文对照翻译 (String)

CONSTANT_String_info 结构体用于表示 String 类型的常量对象:

1
2
3
4
5
CONSTANT_String_info {
u1 tag;
u2 string_index;
}

该结构体的具体项如下:

  • tagCONSTANT_String_info 结构体的 tag 项的值为 CONSTANT_String (8)。
  • string_indexstring_index 项的值必须是 constant_pool(常量池)表中的一个有效索引。该索引处的常量池实体必须是一个 CONSTANT_Utf8_info 结构体(详见 §4.4.7 节),用以表示即将用来初始化该 String 对象的 Unicode 码点序列(即字符串文本)。

2. 深度硬核解释 (String)

💡 灵魂拷问:为什么有了 CONSTANT_Utf8,还要多此一举搞个 CONSTANT_String

你刚才已经领悟到了常量池在“不停地引用碎片”。这里就是一个最完美的自证!

  • CONSTANT_Utf8(Tag=1):它存放的是纯粹的“二进制文本流”。它没有任何 Java 类型概念,只是一堆死字母或汉字。类名可以用它,方法名可以用它,字段名也可以用它。
  • CONSTANT_String(Tag=8):它是真正的 “Java 字符串对象说明书”

当你在代码里写:String 名字 = "hello";
JVM 在底层会生成一个 CONSTANT_String(Tag=8)的零件。这个零件自己不存任何字面量,它的 string_index 指向了存有 "hello"CONSTANT_Utf8 零件。

底层真相: JVM 只要在常量池里看到 CONSTANT_String,就会在运行时去**字符串常量池(String Table)**里寻找或创建一个真正的 java.lang.String 对象实例。它是纯文本向 Java 语言对象的第一次华丽蜕变。

🛠️ 单行摊平格式

1
2
3
4
[ 0x08 ]                       <-- 标签 (tag, 占 1 字节) 代表 CONSTANT_String
[ 0x000E ] <-- 字符串文本指针 (string_index, 占 2 字节)
// 指向常量池 #14 项的 Utf8 纯文本(比如 "hello")


4.4.4. CONSTANT_Integer_info 和 CONSTANT_Float_info 结构

1. 原文对照翻译 (Integer & Float)

CONSTANT_Integer_infoCONSTANT_Float_info 结构体用于表示 4 字节的数值型(intfloat)常量:

1
2
3
4
5
6
7
8
9
10
CONSTANT_Integer_info {
u1 tag;
u4 bytes;
}

CONSTANT_Float_info {
u1 tag;
u4 bytes;
}

这些结构体的具体项如下:

  • tag

  • CONSTANT_Integer_info 结构体的 tag 项的值为 CONSTANT_Class (3)。

  • CONSTANT_Float_info 结构体的 tag 项的值为 CONSTANT_Float (4)。

  • bytes

  • CONSTANT_Integer_info 结构体的 bytes 项表示该 int 常量的值。该值的字节按照 大端序(Big-Endian,高位字节在前) 的顺序存储。

  • CONSTANT_Float_info 结构体的 bytes 项表示该 float 常量在 IEEE 754 单精度浮点数格式(详见 §2.3.2 节)下的值。该单精度格式表示的字节同样按照 大端序 的顺序存储。

CONSTANT_Float_info 结构体所表示的具体数值由以下规则决定。该值的各个字节首先被转换成一个 int 类型的常量 bits。然后:

  • 如果 bits 的值是 0x7f800000,则该 float 值代表正无穷大(Positive Infinity)
  • 如果 bits 的值是 0xff800000,则该 float 值代表负无穷大(Negative Infinity)
  • 如果 bits 的值在 0x7f8000010x7fffffff 之间,或者在 0xff8000010xffffffff 之间,则该 float 值代表 NaN(Not a Number,非数)
  • 在所有其他情况下,设 $s$、$e$ 和 $m$ 是可以通过 bits 计算出来的三个值:
1
2
3
4
5
6
int s = ((bits >> 31) == 0) ? 1 : -1;
int e = ((bits >> 23) & 0xff);
int m = (e == 0) ?
(bits & 0x7fffff) << 1 :
(bits & 0x7fffff) | 0x800000;

那么,该 `float` 的值等于数学表达式的结果:$$s \cdot m \cdot 2^{e-150}$$

2. 深度硬核解释 (Integer & Float)

这一段规范揭示了 JVM 是如何极其抠门且极度标准地利用 4 个字节(32位) 来强行塞下整数和浮点数的。

💡 整数的“大端序”是什么鬼?

规范里强调了 Big-endian。比如你在 Java 里写了一个十六进制的大整数 0x12345678
在 class 文件里,它存放的顺序绝对不会乱,就是雷打不动的 12 34 56 78(高位在左,低位在右,这非常符合人类的阅读习惯)。
当你的 ByteBuffer buffer 执行 buffer.getInt() 时,它能一口气把这 4 个字节直接读成 Java 的 int

💡 浮点数黑魔法:为什么会有这么复杂的数学公式?

规范后面给出了一堆位移(>>&)和复杂的数学公式,这其实就是在底层完整实现了 IEEE 754 国际浮点数标准
一个 32 位的 float 内存被拆成了三部分:

  1. 最高第 31 位: 符号位(即公式里的 s,0 代表正数,1 代表负数)。
  2. 第 30 到 23 位(共8位): 指数位(即公式里的 e)。
  3. 第 22 到 0 位(共23位): 尾数位(即公式里的 m)。

规范为什么要长篇大论写出无穷大、NaN 和这个公式?
因为它在严厉地警告所有手写 JVM 或解析器的极客:“不要试图用你自己的方案去猜测 4 个字节怎么变成小数!必须老老实实按照我给出的位移公式去解码,否则你解析出来的浮点数就会严重失真!”

注:公式里的 $2^{e-150}$ 看起来奇怪(标准里一般是 $e-127$),是因为规范在计算 m 时,把 23 位的尾数当做整数来算了。为了把这个放大的 $2^{23}$ 缩回来,$e - 127 - 23$ 正好等于 $e - 150$,数学逻辑极其严密!

🛠️ 单行摊平格式:看 ByteBuffer 如何读取整数 100

1
2
3
4
[ 0x03 ]                       <-- 标签 (tag, 占 1 字节) 代表 CONSTANT_Integer
[ 0x00000064 ] <-- 具体数值 (bytes, 占 4 字节)
// 16进制的 64 转十进制正好是 100

你瞧,对于数值类型,常量池就不再去引用别的碎片了,而是把具体的数值直接死死硬编码在了这 4 个字节的 bytes 肚子里,因为 4 字节的数字本身就已经小到了极致,没必要再拆了。

来了!我们继续保持精准原文对照翻译配合底层核心解释单行摊平格式,来啃掉常量池里最特殊的 8 字节大件(Long & Double),以及我们之前提到过无数次的格式名片——NameAndType

在这段规范里,你会读到 JVM 设计史上最坦诚、最罕见的一句“官方自我吐槽”。


4.4.5. CONSTANT_Long_info 和 CONSTANT_Double_info 结构

1. 原文对照翻译 (Long & Double)

CONSTANT_Long_infoCONSTANT_Double_info 结构体用于表示 8 字节的数值型(longdouble)常量:

1
2
3
4
5
6
7
8
9
10
11
12
CONSTANT_Long_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}

CONSTANT_Double_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}

所有 8 字节的常量在 class 文件的 constant_pool(常量池)表中都会占用两个槽位(Entries)。如果一个 CONSTANT_Long_infoCONSTANT_Double_info 结构体位于常量池的索引 $n$ 处,那么常量池中下一个可用的项将位于索引 $n+2$ 处。常量池索引 $n+1$ 必须有效,但被认为是不可用的

💡 【官方吐槽】 In retrospect, making 8-byte constants take two constant pool entries was a poor choice.
回想起来,让 8 字节的常量占用两个常量池槽位真是一个糟糕的决定。

这些结构体的具体项如下:

  • tag

  • CONSTANT_Long_info 结构体的 tag 项的值为 CONSTANT_Long (5)。

  • CONSTANT_Double_info 结构体的 tag 项的值为 CONSTANT_Double (6)。

  • high_bytes, low_bytes

  • CONSTANT_Long_info 结构体中无符号的 high_bytes(高 4 字节)和 low_bytes(低 4 字节)共同表示该 long 型常量的值:

$$((long)\ high_bytes \ll 32) + low_bytes$$

其中 high_byteslow_bytes 的每个字节都按照大端序(高位字节在前)的顺序存储。

  • CONSTANT_Double_info 结构体中的 high_byteslow_bytes 共同表示 IEEE 754 双精度浮点数格式(详见 §2.3.2 节)下的 double 值。每个项的字节同样按照大端序存储。

CONSTANT_Double_info 结构体所表示的具体数值由以下规则决定。high_byteslow_bytes 首先被转换成一个 long 类型的常量 bits,它等于:

$$((long)\ high_bytes \ll 32) + low_bytes$$

然后:

  • 如果 bits0x7ff0000000000000L,则该 double 值代表正无穷大
  • 如果 bits0xfff0000000000000L,则该 double 值代表负无穷大
  • 如果 bits0x7ff0000000000001L0x7fffffffffffffffL 之间,或者在 0xff0000000000001L0xffffffffffffffffL 之间,则该 double 值代表 NaN(非数)
  • 在所有其他情况下,设 $s$、$e$ 和 $m$ 是可以通过 bits 计算出来的三个值:
1
2
3
4
5
6
int s = ((bits >> 63) == 0) ? 1 : -1;
int e = (int)((bits >> 52) & 0x7ffL);
long m = (e == 0) ?
(bits & 0xfffffffffffffL) << 1 :
(bits & 0xfffffffffffffL) | 0x10000000000000L;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    那么,该浮点数的值等于数学表达式的 `double` 结果:$$s \cdot m \cdot 2^{e-1075}$$

---

### 2. 深度硬核解释 (Long & Double)

这一段规范里藏着 JVM 发展史上最大的一个“屎山设计”和它的官方吐槽。

#### 💡 为什么官方要自我吐槽“糟糕的决定”?
在 32 位计算机横行的年代,JVM 认为所有基础常量都应该塞进一个 32 位的格子(槽位/Entry)里。但是 `Long` 和 `Double` 是 64 位的,这可怎么办?
当时的架构师拍脑袋想了个招:**强行让它们占两个格子!**

如果你在代码里写了一个 `long a = 123L;`,它被塞进了常量池的第 **5** 号槽位。
* 5 号槽位会放下这个 `Long` 的前半部分和后半部分。
* 但是!接下来的 **6** 号槽位直接被强行废弃,什么也不准存。
* 下一个零件只能存进 **7** 号槽位($n+2$)。

> **手写解析器(ByteBuffer)的惊天大坑:**
> 当你写 `for (int i = 1; i < constantPoolCount; i++)` 去解析常量池时,如果你偶遇了 `Long` 或 `Double`,**你必须在循环末尾手动让 `i++` 再多跳一次!** 否则你的指针计数就会和官方的 `constantPoolCount` 完全对不上,直接崩掉。官方自己后来也觉得这个占 2 个格子的设计对解析器极度不友好,但为了向后兼容,这个设计一直保留到了今天。

#### 🛠️ 单行摊平格式:看 `ByteBuffer` 如何拼接 64 位长整型
```c
[ 0x05 ] <-- 标签 (tag, 占 1 字节) 代表 CONSTANT_Long
[ 0x00000001 ] <-- 高 4 字节 (high_bytes)
[ 0x00000002 ] <-- 低 4 字节 (low_bytes)
// 解析器合成:(0x00000001 << 32) + 0x00000002
// 得到真实的 64 位 Long 值
// ⚠️ 注意:此时解析器的循环索引 i 必须额外自增 1,把下一个空槽位跳过去!


4.4.6. CONSTANT_NameAndType_info 结构

1. 原文对照翻译 (NameAndType)

CONSTANT_NameAndType_info 结构体用于表示一个字段或方法,但并不指明它属于哪一个类或接口类型

1
2
3
4
5
6
CONSTANT_NameAndType_info {
u1 tag;
u2 name_index;
u2 descriptor_index;
}

该结构体的具体项如下:

  • tagCONSTANT_NameAndType_info 结构体的 tag 项的值为 CONSTANT_NameAndType (12)。
  • name_indexname_index 项的值必须是 constant_pool 表中的一个有效索引。该索引处的常量池实体必须是一个 CONSTANT_Utf8_info 结构体(详见 §4.4.7 节),用以表示特殊的无参构造函数名 <init>(详见 §2.9 节),或者表示一个有效的、用于指代字段或方法的非限定名(Unqualified Name,即普通的变量名或方法名)(详见 §4.2.2 节)。
  • descriptor_indexdescriptor_index 项的值必须是 constant_pool 表中的一个有效索引。该索引处的常量池实体必须是一个 CONSTANT_Utf8_info 结构体(详见 §4.4.7 节),用以表示一个有效的字段描述符方法描述符(详见 §4.3.2 和 §4.3.3 节)。
  • CONSTANT_Fieldref_info 中,该描述符必须是一个字段描述符。否则,该描述符必须是一个方法描述符

2. 深度硬核解释 (NameAndType)

我们之前画的通缉令关系图里,NameAndType 就是那张惊艳的“格式名片”。它回答了关于一个方法的两个灵魂拷问:

  1. name_index $\rightarrow$ 它的名字叫什么?(比如叫 "sleep"
  2. descriptor_index $\rightarrow$ 它长什么样?(比如 "()V" 代表无参返回 void)

💡 为什么规范说它“不指明属于哪一个类”?

因为 NameAndType 的唯一职责是定义规格,而不是定义所属权

举个绝妙的例子:你在写代码时,不管是 Thread 类、Dog 类、还是 Game 类,它们里面可能都刚好有一个一模一样的方法:public void run()

  • 由于这三个类都有 run() 方法,它们在常量池里定义具体的 Methodref 时,需要 3 个不同的 class_index 指向各自的类。
  • 但是!由于它们的名字都叫 "run",类型都叫 "()V"它们可以共同复用常量池里的同一个 NameAndType(名片零件)!

这种把“名字类型”和“所属类”彻底解耦的拆碎设计,再次把 Java 字节码的复用性拔高到了极致。

🛠️ 单行摊平格式:看 ByteBuffer 提取名片

1
2
3
4
[ 0x0C ]                       <-- 标签 (tag, 占 1 字节) 0x0C 即十进制 12, 代表 NameAndType
[ 0x000A ] <-- 名字索引 (name_index, 占 2 字节) 指向 Utf8 文本 "run"
[ 0x000B ] <-- 描述符索引 (descriptor_index, 占 2 字节) 指向 Utf8 文本 "()V"

常量池的所有高层零件,到这里就全部集结完毕了。下一节,我们就要迎来这一切引用的终点站、也是常量池里最重要的底层文本容器——CONSTANT_Utf8_info

最为重要的 CONSTANT_Utf8_info

终于来到了常量池的终点站,也是整个 JVM 核心拼图里最基础、最重磅的结构——CONSTANT_Utf8_info

常量池里几乎所有的其他结构(无论是类名、方法名、字段描述符,还是字符串对象),说到底,最底层都在指望着它来兜底存储真正的文本。

这段规范硬核到了极点,因为它详细解密了 Java 独有的“变种 UTF-8”(Modified UTF-8)二进制编码规则。


4.4.7. CONSTANT_Utf8_info 结构

1. 原文对照翻译 (Utf8)

CONSTANT_Utf8_info 结构体用于表示常数字符串值:

1
2
3
4
5
6
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}

该结构体的具体项如下:

  • tagCONSTANT_Utf8_info 结构体的 tag 项的值为 CONSTANT_Utf8 (1)。
  • lengthlength 项的值给出了 bytes 数组中的字节数(⚠️ 注意:不是最终生成的字符串的字符长度)。
  • bytes[]bytes 数组包含字符串的字节数据。
  • 任何字节的值都不能(byte)0
  • 任何字节的值都不能(byte)0xf0(byte)0xff 的范围内。
    (规范写 bytes[length] 的真正意图是告诉你:
    “在这个 class 文件里,读完 2 字节的 length 之后,紧接着在文件后面,刚好连续躺着 length 个字节的数据。这堆连续的字节,我们称之为 bytes 数组。”)

字符串内容采用 变种 UTF-8(Modified UTF-8) 进行编码。变种 UTF-8 字符串经过特殊编码,使得仅包含非空(non-null)ASCII 字符的码点序列可以仅用每个码点 1 个字节来表示,但同时也能表示 Unicode 码点空间中的所有码点。变种 UTF-8 字符串不是以 null 结尾的(not null-terminated)。其具体编码规则如下:

规则 A:\u0001\u007F 范围内的码点由单个字节表示:

  • 字节格式: 0 + bits 6-0(最高位永远是 0,低 7 位存数据)
  • 该字节中的 7 位数据即为所表示的码点值。

规则 B:空字符(\u0000)以及 \u0080\u07FF 范围内的码点由一对字节 $x$ 和 $y$ 表示:

  • x 字节: 110 + bits 10-6
  • y 字节: 10 + bits 5-0
  • 这两个字节所代表的码点值计算公式为:

$$((x\ &\ 0x1f) \ll 6) + (y\ &\ 0x3f)$$

规则 C:\u0800\uFFFF 范围内的码点(包含几乎所有常用汉字)由 3 个字节 $x$、$y$ 和 $z$ 表示:

  • x 字节: 1110 + bits 15-12
  • y 字节: 10 + bits 11-6
  • z 字节: 10 + bits 5-0
  • 这三个字节所代表的码点值计算公式为:

$$((x\ &\ 0xf) \ll 12) + ((y\ &\ 0x3f) \ll 6) + (z\ &\ 0x3f)$$

规则 D:超过 U+FFFF 的字符(即所谓的增补字符,如部分 Emoji 或冷僻字)的处理:

通过对它们在 UTF-16 表示法下的两个代理代码单元(Surrogate Code Units)分别进行独立编码。每个代理代码单元由 3 个字节表示。这意味着增补字符最终由 6 个字节 $u$、$v$、$w$、$x$、$y$ 和 $z$ 表示:

  • u 字节: 11101101
  • v 字节: 1010 + (bits 20-16)-1
  • w 字节: 10 + bits 15-10
  • x 字节: 11101101
  • y 字节: 1011 + bits 9-6
  • z 字节: 10 + bits 5-0
  • 这 6 个字节所代表的码点值计算公式为:

$$0x10000 + ((v\ &\ 0x0f) \ll 16) + ((w\ &\ 0x3f) \ll 10) + ((y\ &\ 0x0f) \ll 6) + (z\ &\ 0x3f)$$

多字节字符的各个字节在 class 文件中均按照大端序(高位字节在前)的顺序存储。

⚠️ 【核心差异总结】

本格式与“标准” UTF-8 格式存在两处区别

  1. 空字符 (char)0 采用 2 字节格式编码,而不是标准 UTF-8 的 1 字节格式。这样做的目的是为了确保变种 UTF-8 字符串中永远不会内嵌值为 0 的字节(Embedded Nulls)
  2. 只使用了标准 UTF-8 的 1 字节、2 字节和 3 字节格式。Java 虚拟机不识别标准 UTF-8 的 4 字节格式;它用自己独创的 2 $\times$ 3 字节(共 6 字节) 格式来代替。

有关标准 UTF-8 格式的更多信息,请参阅《Unicode 标准,版本 6.0.0》第 3.9 节“Unicode 编码形式”。


深度硬核解释 (Utf8)

Java 为什么要离经叛道地自己搞一套“变种 UTF-8”?这背后不是架构师瞎折腾,而是为了妥协 JVM 底层的 C/C++ 历史包袱。

1. 为什么把 \u0000(Null)硬拆成 2 个字节?

在标准的 UTF-8 中,空字符(Null)就是 0x00
但是,JVM 的底层源码(尤其是早期)大量使用了 C/C++ 语言。在 C 语言中,0x00 是字符串的绝对终结符(\0

  • 可怕的后果: 如果 Java 允许把 0x00 编码成 1 个字节放到 class 文件里,当底层 C 代码读取这个文本时,一读到 0x00 就会认为字符串提前结束了!这会导致数据截断或内存崩溃。
  • Java 的解法:0x00 强行用规则 B 拆成两个字节:11000000 10000000(即 0xC0 0x80)。它在二进制上看起来不等于 0,巧妙避开了 C 语言的终结符陷阱,但 JVM 把它反序列化回来时,依然知道它代表 \u0000

2. 为什么不用标准的 4 字节,非要用恶心的 2 × 3 = 6 字节?

标准 UTF-8 在遇到某些现代 Emoji(比如 𠮷、😀)时,会使用 4 个字节来存。
然而,Java 语言的内部核心是基于 UTF-16(char 类型占 2 字节) 的。在 UTF-16 中,普通方法存不下这些超出 U+FFFF 的特殊符号,必须用两个 char(高位代理 + 低位代理)拼接表示。

  • 为了图省事,JVM 编译 class 文件时,直接把这两个 UTF-16 的 char 分别当成普通的 3 字节字符进行编码。
  • 结果: 一个原本在标准 UTF-8 里只需要 4 字节的 Emoji,在 Java 的 class 常量池里被硬生生塞成了 6 个字节!
  • 好处: JVM 读取时极其方便,读出来直接就是两个 char,无缝对接 Java 内存里的 String 运行。

单行摊平格式:看 ByteBuffer 读写纯文本

掌握了上述原理,手写解析器时读取 Utf8 简直就是丝滑无比。我们以最经典的字符串 "JVM"(全 ASCII)和 "中"(中国人的中,汉字)为例:

示例 A:普通文本 "JVM"

1
2
3
4
[ 0x01 ]                       <-- 标签 (tag) = 1, 代表 CONSTANT_Utf8
[ 0x0003 ] <-- 长度 (length) = 3 字节
[ 0x4A ] [ 0x56 ] [ 0x4D ] <-- 刚好 3 个字节,对应 ASCII 码的 'J', 'V', 'M'

示例 B:常用汉字 "中"(码点为 \u4E2D,属于规则 C)

\u4E2D 转换为二进制:0100 1110 0010 1101
填充到 3 字节模板:1110xxxx 10xxxxxx 10xxxxxx 后,得到 11100100 10111000 10101101(即 0xE4 0xB8 0xAD)。

1
2
3
4
[ 0x01 ]                       <-- 标签 (tag) = 1, 代表 CONSTANT_Utf8
[ 0x0003 ] <-- 长度 (length) = 3 字节
[ 0xE4 ] [ 0xB8 ] [ 0xAD ] <-- 3 字节数据,JVM 读取后当场还原为汉字 "中"

总结:常量池终极复盘

到此为止,你已经看完了常量池最核心的全部底层结构。我们不妨回过头来给他们分个类:

常量类型分类 代表结构 存储策略
底层终点站 CONSTANT_Utf8_info 真正闭眼存变种 UTF-8 纯文本的容器
死数据值 Integer / Float / Long / Double 直接硬编码数值,不引用任何人(Long/Double 占两槽)
中转格式名片 CONSTANT_NameAndType_info 自己没数据,单纯拉两条线引用名字和描述符的 Utf8
顶层高管零件 Class / String / Methodref / Fieldref 业务直接使用的零件,层层套娃引用底层的名片和 Utf8

整个常量池,就是一套极其完美的工业级数据解构与重组方案。你写的一万行 Java 代码,就是被这样条理清晰地揉碎,塞进了这一张由字节构建的天罗地网里!

4.5 Field

4.5 字段表(Fields)第一部分

1. 结构与规则部分(翻译 + 解释)

📜 原文:

Each field is described by a field_info structure.
No two fields in one class file may have the same name and descriptor (§4.3.2).

🇨🇳 翻译:

每个字段都由一个 field_info 结构体进行描述。
在同一个 class 文件中,任何两个字段都不能同时拥有相同的名字和描述符(§4.3.2)。

💡 解释:什么是“名字和描述符不能同时相同”?

在 Java 源码里,你肯定知道不能在同一个类里定义两个一模一样的变量,比如:

1
2
3
int age;
String age; // 编译报错:变量 age 已在类中定义

但在 Class 文件底层,它是怎么判定重复的呢?就是靠 名字(Name) + 描述符(Descriptor,即类型)

规范这句话的意思是:只要“名字”和“类型”不完全一样,JVM 就能分得清。比如在底层的 class 文件里,其实是允许出现一个叫 ageint 变量和一个叫 ageString 变量共存的(虽然 Java 语言本身为了防止人类混乱不允许这么写,但底层字节码是可以兼容的)。


📜 原文:

The structure has the following format:

1
2
3
4
5
6
7
8
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}

The items of the field_info structure are as follows:

🇨🇳 翻译:

该结构体具有以下格式:
(结构体内容如上)
field_info 结构体中的具体项含义如下:

💡 解释:这 5 个零件分别是什么?

当你的 ByteBuffer 指针走到字段表时,每个字段都固定占用了前 8 个字节(4 个 u2),后面跟着一个变长的属性数组。我们来拆解这 5 个零件:

  1. access_flags (2字节):代表它的身份标签(是 public 还是 static 还是 final 等)。
  2. name_index (2字节):指向常量池的索引。因为字段总得有个名字(比如 age),去常量池里找那个 CONSTANT_Utf8_info 就能拿到字符串。
  3. descriptor_index (2字节):也是指向常量池的索引。代表这个字段是什么类型的(比如 int 在底层表现为 "I"String 表现为 "Ljava/lang/String;")。
  4. attributes_count (2字节):这个字段背后的属性个数。
  5. attributes[] (变长)重点!又出现了你最熟悉的变长写法! 长度由上面的 attributes_count 决定。比如这个字段如果是 final 的常量,这里面就会塞一个叫 ConstantValue 的属性来记录它的具体初始值。

2. 访问标志部分(翻译 + 解释)

📜 原文:

access_flags
The value of the access_flags item is a mask of flags used to denote access permission to and properties of this field. The interpretation of each flag, when set, is specified in Table 4.5-A.

🇨🇳 翻译:

access_flags(访问标志)
access_flags 项的值是一个掩码(Mask),用于表示该字段的访问权限和属性。当设置了对应的标志位时,各个标志的含义如表 4.5-A 所示。

(这一大段都是重复的修饰符的解释,不用看了)解释:什么是“掩码(Mask)”?

这就是计算机底层最喜欢的位运算特技
如果一个变量是 public static final 的,JVM 难道要存三个字符串进去吗?太浪费空间了!

JVM 把所有的修饰符都做成了一个 16 位的二进制开关。每一个修饰符只占其中一个二进制位:

  • ACC_PUBLIC = 0x0001 (二进制: 0000 0000 0000 0001)
  • ACC_STATIC = 0x0008 (二进制: 0000 0000 0000 1000)
  • ACC_FINAL = 0x0010 (二进制: 0000 0000 0001 0000)

如果你的字段同时是这三个修饰符,JVM 只需要把它们做一次按位或(|)运算
$0x0001 | 0x0008 | 0x0010 = 0x0019$
最后在 access_flags 里填入 0x0019。你用 ByteBuffer 读到 0x0019 后,只需要用 (flags & 0x0001) != 0 就能反向推导出它是不是 public 的!


3. 表 4.5-A 字段访问与属性标志(翻译)

标志名称 (Flag Name) 标志值 (Value) 具体含义 (Interpretation)
ACC_PUBLIC 0x0001 声明为 public;可以从其所属包的外部进行访问。
ACC_PRIVATE 0x0002 声明为 private;只能在定义它的类内部使用。
ACC_PROTECTED 0x0004 声明为 protected;可以在子类中被访问。
ACC_STATIC 0x0008 声明为 static(静态变量)。
ACC_FINAL 0x0010 声明为 final;在对象构造完成后绝不能再被直接赋值(参考 JLS §17.5)。
ACC_VOLATILE 0x0040 声明为 volatile;并发时不能被线程工作内存缓存(保证可见性)。
ACC_TRANSIENT 0x0080 声明为 transient;不会被持久化对象管理器写入或读取(不参与默认的对象序列化)。
ACC_SYNTHETIC 0x1000 声明为 synthetic(合成字段);表示该字段由编译器自动生成,不存在于源码中。
ACC_ENUM 0x4000 声明为枚举类型(enum)的一个元素。

💡 额外的小补充:

  • ACC_SYNTHETIC:这个很有意思。比如你在写内部类访问外部类的私有变量时,Java 编译器为了让内部类能访问成功,会在后台偷偷自动生成一个字段,这个字段在源码里看不见,就会被打上 ACC_SYNTHETIC 标记。
  • ACC_ENUM:当你写一个 enum Season { SPRING, SUMMER } 时,底层的 SPRING 实际上就是一个静态字段,它会被打上 0x4000 标记,告诉 JVM “我是一个枚举项”。

来了!这部分信息量很大,它给字段的“身份标签”制定了极其严格的组合排他规则,同时明确了字段的名字、类型以及如何挂载附加属性。

咱们同样分为三个部分,逐字精准翻译加硬核大白话拆解。


4.5 字段表(Fields)第二部分

1. 访问标志的组合禁用规则(翻译 + 解释)

📜 原文:

Fields of classes may set any of the flags in Table 4.5-A. However, each field of a class may have at most one of its ACC_PUBLIC, ACC_PRIVATE, and ACC_PROTECTED flags set (JLS §8.3.1), and must not have both its ACC_FINAL and ACC_VOLATILE flags set (JLS §8.3.1.4).
Fields of interfaces must have their ACC_PUBLIC, ACC_STATIC, and ACC_FINAL flags set; they may have their ACC_SYNTHETIC flag set and must not have any of the other flags in Table 4.5-A set (JLS §9.3).

🇨🇳 翻译:

普通类(Classes)的字段可以设置表 4.5-A 中的任意标志。然而,一个类的字段最多只能同时设置 ACC_PUBLICACC_PRIVATEACC_PROTECTED 标志中的一个(参考 JLS §8.3.1);并且绝不能同时设置 ACC_FINALACC_VOLATILE 标志(参考 JLS §8.3.1.4)。

接口(Interfaces)的字段必须设置其 ACC_PUBLICACC_STATICACC_FINAL 标志;它们可以设置 ACC_SYNTHETIC 标志,但绝不能设置表 4.5-A 中的任何其他标志(参考 JLS §9.3)。

💡 解释:JVM 底层的互斥安检

如果有人手写字节码或者用恶意工具篡改 Class 文件,把一个字段同时设为 public private 怎么办?JVM 在类加载的“验证”阶段就会根据这段话直接翻脸,抛出 ClassFormatError

这里规定了两个铁律:

  1. 三大访问权限只能三选一:一个变量不能既是 public 又是 private
  2. finalvolatile 势不两立final 意味着变量一旦初始化就再也不能变了;而 volatile 是为了让多线程并发时,每次都去主内存读“最新发生改变的值”。一个绝对不变的量,去奢求读取它的“最新动态变化”,这在逻辑上是彻底矛盾的,所以两者的二进制位绝对不能同时为 1。
  3. 接口变量的“天生强制属性”:在 Java 中,接口里定义的变量默认且强制是 public static final 的(也就是全局常量)。底层规范在这里焊死了:接口字段的 access_flags 必须把这三个标志位通通亮起(进行按位或),多一个或者少一个都不行!

2. 字段属性与预留位(翻译 + 解释)

📜 原文:

The ACC_SYNTHETIC flag indicates that this field was generated by a compiler and does not appear in source code.
The ACC_ENUM flag indicates that this field is used to hold an element of an enumerated type.
All bits of the access_flags item not assigned in Table 4.5-A are reserved for future use. They should be set to zero in generated class files and should be ignored by Java Virtual Machine implementations.

🇨🇳 翻译:

ACC_SYNTHETIC 标志表示该字段由编译器自动生成,不存在于源码中。
ACC_ENUM 标志表示该字段用于保存枚举类型的一个元素。

所有在表 4.5-A 中未分配的 access_flags 项的其余二进制位均保留供未来使用。在生成的 class 文件中,这些位应该被设置为 0,并且 Java 虚拟机实现应当忽略它们。

💡 解释:给未来留条后路

16 位的 access_flagsu2)目前只用了其中的一部分。为了防止以后 Java 升级新特性时没位置放,规范在这里警告:没用到的二进制位老老实实写 0,JVM 读到了也假装看不见。


3. 名字、描述符与属性表(翻译 + 解释)

📜 原文:

name_index
The value of the name_index item must be a valid index into the constant_pool table. The constant_pool entry at that index must be a CONSTANT_Utf8_info structure (§4.4.7) which represents a valid unqualified name denoting a field (§4.2.2).
descriptor_index
The value of the descriptor_index item must be a valid index into the constant_pool table. The constant_pool entry at that index must be a CONSTANT_Utf8_info structure (§4.4.7) which represents a valid field descriptor (§4.3.2).
attributes_count
The value of the attributes_count item indicates the number of additional attributes of this field.
attributes[]
Each value of the attributes table must be an attribute_info structure (§4.7).
A field can have any number of optional attributes associated with it.
The attributes defined by this specification as appearing in the attributes table of a field_info structure are listed in Table 4.7-C.
The rules concerning attributes defined to appear in the attributes table of a field_info structure are given in §4.7.
The rules concerning non-predefined attributes in the attributes table of a field_info structure are given in §4.7.1.

🇨🇳 翻译:

name_index(名称索引)
name_index 项的值必须是 constant_pool(常量池)表中的一个有效索引。该索引处的常量池条目必须是一个 CONSTANT_Utf8_info 结构体(§4.4.7),用以表示一个合法的、指代字段的简单名称(Unqualified Name)(§4.2.2)。

descriptor_index(描述符索引)
descriptor_index 项的值必须是 constant_pool 表中的一个有效索引。该索引处的常量池条目必须是一个 CONSTANT_Utf8_info 结构体(§4.4.7),用以表示一个合法的字段描述符(§4.3.2)。

attributes_count(属性计数)
attributes_count 项的值表示该字段所携带的附加属性的数量。

attributes[](属性表)
attributes 表中的每个值都必须是一个 attribute_info 结构体(§4.7)。
一个字段可以关联任意数量的可选属性。
本规范中定义允许出现在 field_info 结构体的 attributes 表中的属性列在表 4.7-C 中。
有关定义在 field_infoattributes 表中出现的属性的规则在 §4.7 中给出。
有关 field_infoattributes 表中非预定义(自定义)属性的规则在 §4.7.1 中给出。

💡 解释:名字、类型和它们的“挂件”

现在你可以写代码用 ByteBuffer 精确收割这几个数了。

  • name_index 指向的字符串就是变量名。比如 private int age;,那这个索引指向的文本就是 "age"。所谓“简单名称(Unqualified Name)”,就是不能带有斜杠或包名,纯粹的名字。
  • descriptor_index 决定了它是什么类型。如果变量是 int,它指向的文本就是 "I";如果是 String,它指向的文本就是 "Ljava/lang/String;"
  • attributes[] 是个大招:字段可不只是有名字和类型就够了。
  • 如果你写了 public static final int MAX = 100;,那这个 100 存哪?它就存放在 attributes[] 里面一个叫 ConstantValue 的属性里!
  • 如果你给字段加了注解 @Autowired,这个注解信息也会变成一个叫 RuntimeVisibleAnnotations 的属性塞进这个 attributes[] 数组。

4.3.2 字段描述符(Field Descriptors)

1. 语法规则与定义(翻译 + 解释)

📜 原文:

A field descriptor represents the type of a class, instance, or local variable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FieldDescriptor:
FieldType

FieldType:
BaseType
ObjectType
ArrayType

BaseType:
(one of)
B C D F I J S Z

ObjectType:
L ClassName ;

ArrayType:
[ ComponentType

ComponentType:
FieldType

🇨🇳 翻译:

一个字段描述符代表一个类变量(静态变量)、实例变量或局部变量的类型。

字段类型(FieldType)由以下三者之一组成:
  • 基本类型(BaseType)B, C, D, F, I, J, S, Z 中的任意一个。

  • 对象类型(ObjectType):以 L 开头,紧跟类名(ClassName),并以 ; 结尾。

  • 数组类型(ArrayType):以 [ 开头,后面紧跟组件类型(ComponentType)。

  • 组件类型(ComponentType):等同于字段类型(FieldType)。

💡 解释:JVM 为什么要发明这套“暗号”?

如果要在字节码里存变量类型,直接存字符串 "int" 或者 "java.lang.String" 不行吗?
行是行,但是太浪费空间了!Class 文件为了极致地压榨空间,把所有基本类型都缩减成了单个 ASCII 字符

  • 你源码里写 int a;,Class 文件只存一个字符 I
  • 你源码里写 String s;,它用 L; 把类名包裹起来,变成 Ljava/lang/String;(这里的 L 可以理解为 Link 或者 Local 对象引用,; 用来当结束符,防止后面拼接其他东西时粘连)。

📜 原文:

The characters of BaseType, the L and ; of ObjectType, and the [ of ArrayType are all ASCII characters.
ClassName represents a binary class or interface name encoded in internal form (§4.2.1).
The interpretation of field descriptors as types is shown in Table 4.3-A.
A field descriptor representing an array type is valid only if it represents a type with 255 or fewer dimensions.

🇨🇳 翻译:

BaseType 的字符、ObjectTypeL;、以及 ArrayType[ 全部都是 ASCII 字符
ClassName(类名)代表以内部形式(Internal Form)编码的二进制类或接口的名称(§4.2.1)。
字段描述符与具体类型的对应关系如表 4.3-A 所示。
一个代表数组类型的字段描述符,只有在其代表的数组维度小于或等于 255 维时才是合法的

💡 解释:两个潜规则

  1. 内部形式(Internal Form):我们在 Java 源码里写包名用点分隔,比如 java.lang.Object。但在 Class 文件底层,一律把点换成正斜杠,变成 java/lang/Object。这就是所谓的内部形式。
  2. 255维限制:Java 限制了数组最多只能有 255 维。也就是说,你最多只能写 int[][...255个...]。要是有人闲得无聊或者恶意写了 256 个 [,JVM 直接拒绝加载。

2. 表 4.3-A 字段描述符对照表(翻译 + 必背硬核解释)

字符项 (FieldType term) 类型 (Type) 含义与解释 (Interpretation)
B byte 有符号字节型(signed byte)
C char UTF-16 编码的 Unicode 字符(基本多语言平面内的码点)
D double 双精度浮点值(double-precision floating-point value)
F float 单精度浮点值(single-precision floating-point value)
I int 整型(integer)
J long 长整型(long integer)
L ClassName ; reference 引用类型:类 ClassName 的一个实例
S short 有符号短整型(signed short)
Z boolean 布尔型:truefalse
[ reference 引用类型:增加一个数组维度

⚠️ 避坑提示(必看):

  • 为什么 longJ 因为 L 已经被对象引用(ObjectType)占用了,所以 long 只能顺延委屈一下用 J
  • 为什么 booleanZ 因为 B 已经被 byte 占用了。在一些老旧的体系里,boolean 被看作是 integer 的零值(Zero)或非零值,所以用了 Z

3. 官方实力举例(翻译 + 拆解)

📜 原文:

The field descriptor of an instance variable of type int is simply I.
The field descriptor of an instance variable of type Object is Ljava/lang/Object;. Note that the internal form of the binary name for class Object is used.
The field descriptor of an instance variable of the multidimensional array type double[][][] is [[[D.

🇨🇳 翻译:

  • 类型为 int 的实例变量,其字段描述符仅仅就是 I
  • 类型为 Object 的实例变量,其字段描述符为 Ljava/lang/Object;。请注意,这里使用的是 Object 类的二进制名称的内部形式(用斜杠代替点)。
  • 多维数组类型 double[][][] 的实例变量,其字段描述符为 [[[D

💡 解释:剥洋葱式的数组解析法

对于多维数组 [[[D,你的解析器应该像剥洋葱一样去读:

  1. 看到第一个 [:哦,是个数组!维度 +1。
  2. 看到第二个 [:哦,还是数组!维度再 +1(变成2维)。
  3. 看到第三个 [:维度再 +1(变成3维)。
  4. 看到 D:最里面的基础类型是 double
  5. 最终拼装结果:这是一个 double 类型的 3 维数组。

🛠️ 怎么在你的 Java 解析器里优雅地展现它?

在你的代码里,当从常量池里拿到 descriptor_index 对应的字符串后,你可以写一个简单的工具类来把这个“暗号”翻译成人类能看懂的可读文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public static String decodeFieldDescriptor(String descriptor) {
if (descriptor == null || descriptor.isEmpty()) return "";

int arrayDim = 0;
// 1. 数一数前面有几个 [,计算数组维度
while (descriptor.charAt(arrayDim) == '[') {
arrayDim++;
}

// 2. 截取掉 [ 之后的真实类型部分
String baseTypeSpec = descriptor.substring(arrayDim);
String typeName;

// 3. 匹配暗号
if (baseTypeSpec.startsWith("L")) {
// 对象类型:去掉开头的 L 和结尾的 ;,并把斜杠换回点
typeName = baseTypeSpec.substring(1, baseTypeSpec.length() - 1).replace('/', '.');
} else {
// 基本类型转换
switch (baseTypeSpec.charAt(0)) {
case 'B': typeName = "byte"; break;
case 'C': typeName = "char"; break;
case 'D': typeName = "double"; break;
case 'F': typeName = "float"; break;
case 'I': typeName = "int"; break;
case 'J': typeName = "long"; break;
case 'S': typeName = "short"; break;
case 'Z': typeName = "boolean"; break;
default: typeName = "unknown"; break;
}
}

// 4. 把数组的方括号拼回去
StringBuilder sb = new StringBuilder(typeName);
for (int i = 0; i < arrayDim; i++) {
sb.append("[]");
}
return sb.toString();
}

// 测试一下:
// decodeFieldDescriptor("[[[D") -> 得到 "double[][][]"
// decodeFieldDescriptor("Ljava/lang/String;") -> 得到 "java.lang.String"


4.3.3 方法描述符(Method Descriptors)

1. 语法规则与定义(翻译 + 解释)

📜 原文:

A method descriptor contains zero or more parameter descriptors, representing the types of parameters that the method takes, and a return descriptor, representing the type of the value (if any) that the method returns.

1
2
3
4
5
6
7
8
9
10
11
12
13
MethodDescriptor:
( {ParameterDescriptor} ) ReturnDescriptor

ParameterDescriptor:
FieldType

ReturnDescriptor:
FieldType
VoidDescriptor

VoidDescriptor:
V

The character V indicates that the method returns no value (its result is void).

🇨🇳 翻译:

一个方法描述符包含零个或多个参数描述符(代表该方法接收的参数类型)以及一个返回值描述符(代表该方法返回的值的类型,如果有的话)。

  • 方法描述符格式:必须以左括号 ( 开头,中间紧跟零个或多个参数描述符,以右括号 ) 结尾,最后再紧跟一个返回值描述符。
  • 参数描述符(ParameterDescriptor):本质上就是字段类型(FieldType)。
  • 返回值描述符(ReturnDescriptor):可以是字段类型(FieldType),也可以是空返回值描述符(VoidDescriptor)。
  • 空返回值描述符(VoidDescriptor):由字符 V 表示,说明该方法没有返回值(即返回类型为 void)。

💡 解释:括号里的“排排坐”

方法描述符的本质就是:( [入参类型列表] ) 返回值类型
注意:参数之间是没有任何逗号、空格或者分号隔离的!它们紧密地贴在一起。JVM 在解析的时候,完全靠我们上一节学到的字段描述符规则,从左到右去硬啃。


2. 官方经典示例(翻译 + 拆解)

📜 原文:

The method descriptor for the method:
Object m(int i, double d, Thread t) {...}
is:
(IDLjava/lang/Thread;)Ljava/lang/Object;
Note that the internal forms of the binary names of Thread and Object are used.

🇨🇳 翻译:

对于以下方法:
Object m(int i, double d, Thread t) {...}
其对应的方法描述符为:
(IDLjava/lang/Thread;)Ljava/lang/Object;
请注意,这里同样使用了 ThreadObject 二进制类名的内部形式(用斜杠代替点)。

💡 解释:人肉拆解这串“天书”

我们把 (IDLjava/lang/Thread;)Ljava/lang/Object; 像切香肠一样切开:

  • ( :方法参数列表开始。
  • I :第一个参数是 int
  • D :第二个参数是 double
  • Ljava/lang/Thread; :第三个参数是 Thread 对象。看到 L 开始找 ; 结尾,中间是包名。
  • ) :参数列表结束。
  • Ljava/lang/Object; :返回值类型是 Object 对象。

你看,只要掌握了字段描述符,方法描述符就是个拼图游戏。


3. 255 长度限制大坑(翻译 + 极其重要的硬核解释)

📜 原文:

A method descriptor is valid only if it represents method parameters with a total length of 255 or less, where that length includes the contribution for this in the case of instance or interface method invocations.
The total length is calculated by summing the contributions of the individual parameters, where a parameter of type long or double contributes two units to the length and a parameter of any other type contributes one unit.

🇨🇳 翻译:

一个方法描述符只有在其代表的方法参数总长度小于或等于 255 时才是合法的。如果是实例方法接口方法的调用,这个总长度还必须包含隐式参数 this 的份额

总长度的计算规则是将每个独立参数所占的单位进行累加:

  • 类型为 longdouble 的参数,对长度的贡献为 2 个单位
  • 任何其他类型的参数,对长度的贡献为 1 个单位

💡 解释:为什么 longdouble 要算两个位置?(面试超高频)

你在写解析器或者未来写操作局部变量表(Local Variables Table)的代码时,会发现 JVM 的局部变量槽(Slot)是 32 位的。

  • intboolean、对象引用(Reference),全都是 32 位,正好占用 1 个 Slot。
  • longdouble 是 64 位的,一条好汉占两个坑!它们必须连续占用 2 个 Slot

所以,这里的“总长度限制 255”其实指的是:传进来的参数,在局部变量表里占用的 Slot 数量不能超过 255 个!

🌟 隐藏的 this 刺客:

如果你写了一个普通的实例方法(非 static 静态方法):

1
2
public void test(double a, long b) {}

在源码里看起来它只有 2 个参数。但是因为它不是静态方法,JVM 在调用它时,必须把当前对象隐式地作为第一个参数传进去(也就是 this 引用)。
所以它在局部变量表里的实际排位是:

  • Slot 0: this 引用(占 1 个单位)
  • Slot 1-2: double a(占 2 个单位)
  • Slot 3-4: long b(占 2 个单位)
  • 总长度贡献 = 1 + 2 + 2 = 5

4. 静态与实例方法的“无差别”对待(翻译 + 解释)

📜 原文:

A method descriptor is the same whether the method it describes is a class method or an instance method. Although an instance method is passed this, a reference to the object on which the method is being invoked, in addition to its intended arguments, that fact is not reflected in the method descriptor.
The reference to this is passed implicitly by the Java Virtual Machine instructions which invoke instance methods (§2.6.1, §4.11).

🇨🇳 翻译:

无论一个方法描述符描述的是类方法(静态方法)还是实例方法(非静态方法),方法描述符的格式都是完全相同的

尽管实例方法在调用时,除了接收显式的参数外,还会被额外传递一个指向调用该方法的对象的引用(即 this),但这一事实并不会在方法描述符中体现出来。对 this 引用的传递,是由用于调用实例方法的 Java 虚拟机指令(如 invokevirtual 等)隐式自动处理的(§2.6.1, §4.11)。

💡 解释:描述符不穿防弹衣

这句话的意思是:
如果你有下面两个方法,一个静态一个动态:

1
2
3
public static void foo(int x) {} // 描述符:(I)V
public void bar(int x) {} // 描述符:(I)V

它们在常量池里存的方法描述符字符串一模一样,都是 (I)V
方法描述符只老老实实记录你在源码里写出来的参数。至于 bar 方法需要的 this 参数,不用写在描述符里,JVM 在执行 invokevirtual 字节码指令时,自己会知道在弹栈压栈时偷偷把 this 塞进局部变量表的 Slot 0


🛠️ 怎么在你的 Java 解析器里把“方法天书”解开?

由于方法描述符是个复杂的字符串,在写解析器时,建议写一个 MethodDescriptorParser。它需要一个状态机或者循环指针,把小括号里的参数一个一个抠出来。

下面送你一段实用的解析核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class MethodDescriptor {
private final List<String> parameterTypes = new ArrayList<>();
private String returnType;

public static MethodDescriptor parse(String descriptor) {
MethodDescriptor md = new MethodDescriptor();
int i = 0;

// 1. 校验开头必须是 '('
if (descriptor.charAt(i++) != '(') throw new IllegalArgumentException("Invalid descriptor");

// 2. 循环读取参数直到遇到 ')'
while (descriptor.charAt(i) != ')') {
int start = i;
// 处理数组维度 [
while (descriptor.charAt(i) == '[') { i++; }

if (descriptor.charAt(i) == 'L') {
// 对象类型,一直走到分号结束
while (descriptor.charAt(i) != ';') { i++; }
i++; // 把分号也包含进去
} else {
// 基本类型,只占一个字符
i++;
}
// 截取出了一个完整的参数描述符(例如 "I" 或 "Ljava/lang/String;")
md.parameterTypes.add(descriptor.substring(start, i));
}

i++; // 跳过 ')'

// 3. 剩下全部作为返回值类型
md.returnType = descriptor.substring(i);
return md;
}
}