Java编程之字节码文件结构
一、什么是字节码
首先,我们知道计算机底层之间只能识别二进制的机器码 0 和 1,无法直接理解开发者用不同语言编写的代码。因此,我们需要将编写的代码转换为计算机可以识别和执行的二进制机器码。然而,由于不同的 CPU 指令集和操作系统的差异,即使我们直接将开发语言编译成二进制机器码,不同的 CPU 指令集和操作系统也会导致执行的命令不同。为了解决这个问题,大多数语言创造者选择了一种与操作系统和机器指令集无关的、平台中立的自定义格式,作为应用程序编译后存储的文件格式。
比如,Sun 公司在 Java 语言创建之初,就提出过 Write Once,Run Anywhere
(一次编写,到处运行) 的口号,并且为之实践,不仅创建了 ByteCode (字节码文件) 作为存储 Java 语言的中间格式,而且还创建了 Java Virtual Machine (Java 虚拟机) 来执行字节码文件。Sun 公司以及其它虚拟机提供商发布了许多可以运行在各种不同平台上的 Java 虚拟机,这些虚拟机可以加载及运行相同的字节码文件,统一使用字节码作为存储格式,使相同的字节码可以在不同的平台上运行,实现了 “一次编译,到处运行” 的设计理念。
在 1997 年发布的第一版 Java 虚拟机规范中,就经承诺过: In the future,we will consider bounded extensions to the Java virtual machine to provide better support for other languages (在未来,我们会对 Java 虚拟机进行适当的扩展,以便更好地支持其它语言运行于 JVM 之上),在 JDK 1.7 到 1.8 版本期间,JVM 设计者通过 JSR-292 基本兑现了这个承诺。
到现在为止,已经有很多语言可以运行在 Java 虚拟机之上,如 Clojure、Groovy、JRuby、Jython、Scala 等,这些语言之所以能够在 Java 虚拟机中运行,主要是因为它们在编译后会生成符合规范的字节码文件,而 Java 虚拟机运行的正是字节码文件。因此,无论哪种语言,只要能将这些语言中的指令编译为符合规范的字节码文件,就可以使得编写的应用程序能够在 Java 虚拟机中正常运行。
二、示例字节码文件
为了进行后续的字节码文件分析,先贴一段 Java 示例代码,将其转换为字节码文件,然后使用软件 Nodepad++ 打开示例字节码文件,使用 HEX-Editor 插件观察十六进制的字节码文件内容。
(1) 先给出示例 Java 代码,内容如下:
1 | public class Test { |
(2) 使用 javac 命令将 Java 代码编译为 class 字节码 文件,命令如下:
1 | $ javac Test |
(3) 最后,使用 Notepad++ 打开该示例字节码文件,使用 HEX-Editor 插件来观察十六进制下字节码文件组成,操作如下 (注意: 如果 Notepad++ 中没有安装 HEX-Editor 插件请先安装):
使用插件进行展示后,我们可以观察到如下的字节码文件内容:
三、字节码文件的相关概念
在介绍字节码格式之前,确实有必要先介绍一些与之相关的概念,这样能够更好地理解字节码文件的结构和含义。
3.1 字面量/符号引用
在字节码文件常量池中,很多常量存储的都是 字面量(Literal) 或者 符号引用(Symbolic References),这俩个概念如下:
字面量
和 符号引用
是字节码文件中常量池的重要组成部分,它们提供了字节码文件中使用的各种常量的定义和引用方式。字面量 直接存储常量的值,而 符号引用 则提供了对其它类、方法、字段或接口的引用,使得字节码文件可以描述和处理更加复杂的程序结构和逻辑。
3.2 全限定名/简单名称/描述符
全限定名(Fully Qualified Name),简单名称(Simple Name) 和 描述符(Descriptor) 是字节码文件中常用的概念,用于表示类、方法和字段的命名和类型信息。这里对这些概念进行一个简单介绍,如下:
而字节码文件中的 描述符 存在多种类型,基本上是由以下组成:
这些概念在常量池表中起到重要的作用,通过使用 全限定名、简单名称 和 描述符,可以准确地表示类、字段和方法的信息,帮助虚拟机正确加载和解析字节码文件中的内容。
注: 上面的内容中还有一些需要注意的点,如下:
● 对于数组类型,每一维度将使用一个前置的 “[“ 字符来描述。例如,一个定义为 “java.lang.String[][]” 类型的二维数组将被记录为 “[[Ljava/lang/String;”,一个整型数组 “int[]” 被记录为 “[I”。
● 用描述符来描述方法时,按照先参数列表,后返回值的顺序描述。参数列表按照参数的严格顺序放在一组小括号 “()” 内。例如,方法 “java.lang.String tostring()” 的描述符为 “()Ljava/lang/String;”,方法 “int abc(int[]x, int y)” 的描述符为 “([II)I”。
四、字节码文件数据类型
4.1 字节码文件与类和接口的关系
字节码文件
是编译 Java
源代码后生成的 二进制文件,用于存储 类
和接口
的具体实现和信息。每个字节码文件对应着唯一的类或接口定义。然而,并非所有的类和接口都必须存储在字节码文件中。在 Java 中,类和接口可以通过类加载器在运行时动态生成,无需提前定义在字节码文件中。这种动态生成的类和接口可以通过 Java 的反射机制来使用和操作,为 Java 提供了动态编程的能力。
4.2 字节码文件中两种基本数据类型
字节码文件 是由 特定的结构 组成的,在它的内部没有使用分隔符来区分不同的段落。字节码文件中的字节顺序和数量都遵循严格的规定。所有 16 位、32 位和 64 位长度的数据都由连续的 8 位字节构成。多字节数据项总是按照 big-endian 顺序存储,即高位字节在地址最低位,低位字节在地址最高位。因此,一个完整的字节码文件由一组 8 位字节单位的字节流组成。这种规定确保了字节码文件的可移植性和一致性。
根据 Java 虚拟机规范的定义,字节码文件采用一种类似于 C 语言结构体的伪结构来存储数据。这种伪结构中只包含两种基本数据类型,即 无符号数 和 表。
数据类型 | 说明 |
---|---|
无符号数 | 无符号数是基本的数据类型,以 u1、u2、u4 和 u8 组成,分别表示 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。无符号数在字节码中用于描述数字、索引引用、数量值或者按照 UTF-8 编码构成的字符串值。 |
表 | 表是一种复合数据类型,由多个无符号数或其它表作为数据项组成。在字节码文件中,所有的表通常以 “_info” 结尾命名。表被用于描述具有层次关系的复合结构数据。实际上,整个 class 文件本质上就是一个表,由表中的各种数据项构成。 |
4.3 字节码文件的组成结构
字节码文件
的构成大体上对应着一个 ClassFile 文件结构,结构的组成中总共包含以下几个部分:
1 | ClassFile { |
在 ClassFile 结构中,存在 魔数
、主/副版本号
、常量池
、访问标志
、类索引
、父类索引
、接口索引集合
、字段表集合
、方法表集合
和 属性表集合
等内容。这种结构化的方式,以及按顺序排列的文件结构,使得字节码文件能够准确地描述出 Java
类和接口的定义
、结构和行为
。
实际上,整个字节码文件可以看作是一张表,由多个部分组成,可以归纳如下:
名称 | 类型 | 长度 | 说明 |
---|---|---|---|
magic | u4 | 4字节 | 魔数,用于识别 class 文件格式 |
minor_version | u2 | 2字节 | 副版本号 |
major_version | u2 | 2字节 | 主版本号 |
constant_pool_count | u2 | 2字节 | 常量池计数器 |
constant_pool | cp_info | n字节 | 常量池 |
access_flags | u2 | 2字节 | 访问标志 |
this_class u2 | 2字节 | 类索引 | |
super_class | u2 | 2字节 | 父类索引 |
interfaces_count | u2 | 2字节 | 接口计数器 |
interfaces | u2 | 2字节 | 接口索引集合 |
fields_count | u2 | 2字节 | 字段计数器 |
fields | field_info | n字节 | 字段表集合 |
methods_count | u2 | 2字节 | 方法计数器 |
methods | method_info | n字节 | 方法表集合 |
attributes_count | u2 | 2字节 | 附加属性计数器 |
attributes | attribute_info | n字节 | 附加属性集合 |
上面表中的类型存在多种,去重以后对这些类型进行划分,可以大致划分如下:
类型 | 长度 | 说明 |
---|---|---|
u1 | 1字节 | 1个字节 |
u2 | 2字节 | 2个字节 |
u4 | 4字节 | 4个字节 |
u8 | 8字节 | 8个字节 |
cp_info | n字节 | 常量表 |
field_info | n字节 | 字段表 |
method_info | n字节 | 方法表 |
attribute_info | n字节 | 属性表 |
五、字节码文件数据格式
前面笼统的介绍了字节码文件的组成结构,知道了字节码的构成中都有哪些内容,不过还不够清晰,这里将对其中的各项进行一个详细的介绍,如下:
5.1 魔数
(1) 魔数简介
字节码文件的前 4 个字节是魔数 (Magic Number)。 魔数的唯一作用是确定一个文件是否是一个可以被 Java 虚拟机所接受的字节码文件。它的固定值为 0xCAFEBABE,该值在 1991 年前后就已经确定,并且不会改变。魔数是一个固定的标识,用于标示一个文件的格式。通过检查文件的开头四个字节是否等于魔数,Java 虚拟机可以确认文件的有效性,进而决定是否加载并解析该文件。这种魔数机制可确保只有符合规定格式的字节码文件才能被虚拟机正确解析和执行。
其实,很多文件存储标准中都使用魔术来进行标识,例如 gif、jpeg 等文件头中,都有魔数进行标识。使用魔数而不是扩展名来进行标识,主要是基于安全方面的考虑,因为文件扩展名可以随意地改动,而文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过,同时又不会引起混淆即可。
(2) 魔数分析示例
根据上面的”示例字节码文件”为例,我们判断一下该文件是否为一个符合规范的字节码文件。关于示例的字节码文件内容开头部分,这里给出一个示例截图,如下所示:
通过图中可知,在示例字节码文件内容的开头存在 ca fe ba be 这个标识,这也说明了该文件是一个可以被 Java 虚拟机所接受的有效 Java 字节码文件。
5.2 主/副版本号
(1) 主/副版本号简介
字节码文件前 4 个字节是魔数,而后面 4 个字节是字节码编译版本号。 其中第 56 个字节代表的含义是编译的 副版本号(minor_version),第 78 个字节代表的含义是编译的 主版本号(major_version),它们一起共同构成了字节码文件的格式版本号。
Java 的版本号对应的值是从 45 开始的,并且 JDK 1.1 之后的每个 JDK 大版本发布主版本号时,都会使版本号值加 1 (JDK 1.0~1.1 使用了 45.0~45.3 的版本号)。不同版本的 Java 编译器编译的字节码文件对应的版本是不一样的,大部分情况下高版本的 Java 虚拟机可以兼容运行由低版本编译器生成的字节码文件,但是低版本的 Java 虚拟机不能运行由高版本编译的字节码文件,否则 Java 虚拟机将会抛出 java.lang.UnsupportedClassVersionError 异常。
为了方便理解,我将字节码文件的主要版本号对应关系整理如下表:
这个表格展示了字节码文件主要版本号与对应的 JDK 版本之间的关系。不同的 JDK 版本会对应不同的主要版本号,每个主要版本号都引入了一些新的特性和功能。
(2) 主/副版本号分析示例
根据上面的”示例字节码文件”为例,我们判断一下该示例字节码文件的版本号。这里给出一个示例截图,如下所示:
通过图中可知,在示例字节码文件内容中,副版本号十六进制为 0x0000,主版本号十六进制为 0x0034,转换为十进制后为分别为 0 和 52,这说明当前字节码文件的副版本号为 0,主版本号为 52,即编译当前字节码文件的 JDK 版本为 JDK 8,
5.3 常量池
(1) 常量池简介
在主/次版本号后面就是常量池 (Constant Pool)。常量池在字节码文件中扮演着重要的角色,它可以被视为一个资源库。作为字节码文件结构中与其它项目关联最多的数据类型之一,常量池在字节码文件中占据了较大的空间,并且是第一个出现的表类型数据项。
除了占据空间较大的特点外,常量池对于字段和方法的解析也至关重要。它存储了与 类、接口、方法 和 字段 相关的各种常量信息,包括 字面量、符号引用 等。这些常量信息在程序的执行过程中被频繁使用,如方法调用、字段访问等。
随着 Java 虚拟机的不断发展,常量池的内容也不断丰富,支持更多类型的常量和引用。可以说,常量池是整个字节码文件的基石,为字节码的解析和执行提供了必要的数据支持。
(2) 常量池的组成
常量池主要是以 常量池计数器(constant_pool_count) 和 常量池表(constant_pool) 组成,如下:
名称 | 描述 | 类型 | 数量 | 作用 |
---|---|---|---|---|
constant_pool_count | 常量池计数器 | u2 | 1 | 记录常量池表中的常量数量 |
constant_pool | 常量池表 | cp_info[] | constant_pool_count - 1 | 记录常量 |
常量池计数器 (constant_pool_count)
常量池计数器 (constant_pool_count) 是一项 u2 类型的无符号数,常放在常量池的入口,用于记录常量池表的大小。
需要注意的是,常量池计数器的值是从 1 开始的,而不是 0。例如,当 constant_pool_count = 1 时,表示常量池中有 0 个常量项,而不是 1 个。这种设计是为了与索引的编号方式保持一致,常量池中的每个常量项都可以通过索引进行访问,索引从 1 开始,所以容量计数也从 1 开始。
常量池表 (constant_pool)
常量池表 (constant_pool) 用于存储字节码文件中使用到的各种常量。常量池表的结构比较复杂,它由多个常量表项 (cp_info) 组成。
常量池表的作用是保存字节码中使用的字面量、符号引用和其它常量。字面量包括整数、浮点数、字符串等字面值常量,而符号引用包括类和接口的全限定名、字段和方法的名称和描述符等。常量池表的主要作用是提供给字节码指令引用和使用这些常量。
每个常量表项在常量池表中占据一个位置,常量表项的索引从 1 开始 (索引为 0 的位置被 JVM 保留)。常量池表中的常量表项可以通过索引相互引用,从而构成复杂的关联关系。
注: 常量池表中的常量是按照索引顺序排列的,可以通过索引快速定位到对应的常量表项。
(3) 常量表项的类型和结构
常量池中的 常量池表 由多个 常量表项(cp_info) 组成,其中包含了不同类型的常量。目前 (JDK 8 版本) 常量池共有 14 种不同的常量类型,如下表所示:
根据上述表格中每个类型的描述,可以得知每种类型都用于描述常量池中的内容,其中大部分是字面量和符号引用。例如,使用 CONSTANT_Integer_info 类型来描述常量池中的字面量信息,而且该类型只能表示整型字面量信息。
这意味着常量池中的每个项都有特定的类型来指示其含义和用途。这些类型对于解析和引用字节码文件中的类、字段、方法和字面量等信息至关重要。通过使用不同的常量类型,可以准确地表示不同种类的数据,并在需要时进行正确的解析和使用。
(4) 分析常量池中的常量
分析常量池计数器
根据上面的”示例字节码文件”为例,我们分析一下该示例字节码文件中常量池计数器的值。这里给出示例字节码对应内容的截图,如下所示:
通过图中可知,在示例字节码文件内容中,常量池计数器位置的值十六进制为 0x0015,转换为十进制为 21,因此 constant_pool_count=21,也就是说示例字节码文件常量池中有 21-1,即 20,表明常量池表中存在 20 个常量。
分析常量池中的常量
由于分析常量池常量内容过多,就不单独放本篇博文中进行介绍。这里暂时先只给出通过 NodePad++ 观察到的”示例字节码文件”中常量池内容部分,如下图所示:
(5) 使用命令分析常量池常量
其实除了使用 NodePad++ 打开字节码对常量池进行分析外,还有一些其它的工具可以分析字节码常量池。比如,在 JDK 中就自带了字节码工具,可以使用命令行方式分析常量池信息,其命令如下:
1 | $ javap -verbose Test.class |
命令执行后输出的结果如下:
1 | Classfile Test.class |
可以看到输出的结果中,已经很明确的标识不同的索引位置中的常量,以及这些常量引用其它常量的情况,可以通过索引位置来查看常量的具体内容。例如,可以看到某个常量是通过索引引用了另一个常量,或者某个常量是字面量值的直接表示。通过这些工具的输出结果,可以更方便地分析和理解字节码文件中常量池中的常量以及它们之间的关系。
5.4 访问标志
(1) 访问标志简介
在常量池之后就是访问标志 (access_flags)。访问标志是一项 u2 类型的无符号数,从字面意思上理解的话访问标志其实就是修饰符的意思,用于标识一个类或接口被哪个访问标识所修饰。包括这个 Class 是类还是接口,修饰符是否定义为 public 类型,是否被 abstract 修饰为抽象类。而且,如果当前 Class 是一个类的话,则还可以标识当前类是否被声明为 final 等。
通过访问标志,我们可以了解到类或接口的修饰情况。比如,如果访问标志中包含 public,则表示该类或接口是公共的,可以被其它类访问;如果访问标志中包含 abstract,则表示该类是一个抽象类,不能直接实例化;如果访问标志中包含 final,则表示该类是最终类,不能被继承。
通过解析访问标志,我们可以了解类或接口的基本特性和限制,有助于理解其在 Java 虚拟机中的行为和使用方式。
(2) 访问标志表
目前 (JDK8 版本) 在字节码文件中,访问标志包括的类型如下表所示:
访问标志中一共有 16 个标志位,当前只定义了 8 个,表中没有使用的标志是为未来扩充而预留的,这些预留的标志在编译器中应该设置为 0。
(3) 访问标志分析示例
根据上面的”示例字节码文件”为例,我们分析一下该示例字节码文件中的访问标志。分析之前我们先根据示例 Java 源代码中的内容猜测一下,它可能包含哪些标志位,这里先列出几条猜测:
猜测1: 示例的字节码文件使用 JDK8 进行编译,所以 ACC_SUPER 值为真;
猜测2: 示例的 Test 类被 public 关键字修饰,所以 ACC_PUBLIC 值为真;
猜测3: 示例的 Test 类是一个普通类,它并不是接口、枚举、注解,没有使用 final、abstract 修饰,所以 ACC_ENUM、ACC_ANNOTATION、ACC_SYNTHETIC、ACC_ABSTRACT、ACC_INTERFACE、ACC_FINAL 值为假;
为了验证猜测,这里同样使用 NodePad++ 打开字节码文件,观察字节码中访问标志的这块内容,如下图所示:
根据上图中的内容,可以观察到示例中的访问标志 access_flags 值十六进制为 0x0021,由于访问标志的值会使用 “按位或运算符” 进行设置或计算,即 accessFlags = ACC_PUBLIC | ACC_FINAL,所以 0x0021 = 0x0001 | 0x0020。其中 0x0001 表示使用了 public 修饰符,即 ACC_PUBLIC 标志为真。而 0x0020 表示允许使用 invokespecial 指令的新语义,即 ACC_SUPER 标志为真。
5.5 类索引、父类索引、接口索引集合
(1) 类索引/父类索引/接口索引集合概述
紧随访问标志之后的就是类索引 (this_class)、父类索引 (super_class) 和接口索引集合 (interfaces)。 这些索引信息用于描述类和接口之间的继承和实现关系。
关于类索引、父类索引和接口索引集合这三个的数据项,汇总到表中如下所示:
(2) 类索引
类索引简介
类索引 (this_class) 是一项 u2 类型的无符号数,用于指示当前类在常量池中的索引位置。通过类索引可以在常量池中定位到对应的常量项,从而获取当前类的全限定名,并进一步获取有关该类的详细信息。
类索引分析示例
根据上面的”示例字节码文件”为例,我们分析一下该示例字节码文件的类索引信息。这里给出示例字节码对应内容的截图,如下所示:
通过图中可知,在示例字节码文件中,类索引的十六进制值为 0x0004,转化为十进制后为 4。这意味着该字节码文件引用了常量池中索引为 4 的常量项。
在常量池中,索引为 4 的常量项属于 CONSTANT_Class_info 类型,它用于表示一个类或接口的符号引用。常量池中索引为 4 的常量项又引用了常量池中索引为 19 的常量项。通过查找常量池中索引为 19 的常量项,我们可以得到该常量项的字面量值为 Test。因此,根据字节码文件的结构和常量池中的内容,可以确定当前类的类名为 Test。
(3) 父类索引
父类索引简介
父类索引 (super_class) 也是一个是一项 u2 类型的无符号数,用于指示当前类的父类在常量池中的索引位置。通过父类索引可以在常量池中找到对应的常量项,并获取父类的相关信息。
值得注意的是,如果一个类没有明确的父类,则它可能是一个顶级类或接口,父类索引 (super_class) 的值将为常量池中的特殊索引值 0。这意味着它没有直接的父类,或者继承自 Java 的基础类 Object。
父类索引分析示例
根据上面的”示例字节码文件”为例,我们分析一下该示例字节码文件的父类索引信息。这里给出示例字节码对应内容的截图,如下所示:
通过图中可知,在示例字节码文件中,父类索引的十六进制值为 0x0005,转化为十进制后为 5。这意味着该字节码文件引用了常量池中索引为 5 的常量项。
在常量池中,索引为 5 的常量项属于 CONSTANT_Class_info 类型,它用于表示一个类或接口的符号引用。常量池中索引为 5 的常量项又引用了常量池中索引为 20 的常量项。通过查找常量池中索引为 20 的常量项,我们可以得到该常量项的字面量值为 java/lang/Object。因此,根据字节码文件的结构和常量池中的内容,可以确定当前父类的类名为 java/lang/Object。
(4) 接口索引集合
接口计数器简介
接口计数器 (interfaces_count) 是一项 u2 类型的无符号数,用于表示当前类或接口实现的接口数量。
接口索引集合简介
接口索引集合 (interfaces) 是一个变长的表结构,用于描述当前类所实现的接口。在字节码文件中,接口索引集合紧随父类索引之后,用于指示当前类实现的接口列表。
接口索引集合的长度根据接口的数量而定,每个索引占据两个字节。通过接口索引集合,字节码文件能够明确地表示当前类所实现的接口,确保类在实现接口时符合接口的要求。
注: 接口索引集合中的接口顺序与类实现接口的顺序保持一致,即第一个接口索引对应类实现的第一个接口,第二个接口索引对应类实现的第二个接口,依此类推。
接口索引集合分析示例
根据上面的”示例字节码文件”为例,我们分析一下该示例字节码文件的接口索引集合。这里给出示例字节码对应内容的截图,如下所示:
通过图中可知,在示例字节码文件中,接口计数器的十六进制值为 0x0000,转化为十进制后值为 0,这表明接口名索引集合为空,即当前类没有实现任何接口。
5.6 字段表集合
(1) 字段表集合简介
在类索引、父类索引和接口索引集合之后就是字段表集合 (fields)。 字段表集合中的 字段表(field_info) 用于描述接口或者类中声明的变量。每个字段表记录了一个字段的相关信息,包括字段的访问标志、字段名、字段描述符 以及可选的属性集合。
注:
● 在 Java 中,字段包括类变量和实例变量,但是不包括方法内的局部变量。
● 字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
● 在 Java 语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。
(2) 字段表集合的组成
字段表集合主要是以 字段表集合计数器(fields_count) 和 字段表集合(fields) 组成,如下:
字段表集合计数器 (fields_count)
字段表集合计数器 (fields_count) 是一个项 u2 类型的无符号数,用于表示字段表集合中字段表的数量。
字段表集合 (fields)
字段表集合 (fields) 用于描述类或接口中声明的字段信息。它是一个数组,是一个由多个 字段表(field_info) 组成的数据结构。
每个字段表包含了一个字段的详细信息,包括字段的名称、访问标志、描述符、属性表等。字段的名称表示字段的标识符,访问标志表示字段的访问级别和属性,描述符表示字段的类型和参数,属性表包含了与字段相关的附加信息,例如注解、默认值等。
字段表集合中的每个字段表都按顺序排列,并且字段表的数量由字段表集合计数器 (fields_count) 确定。通过解析字段表集合,可以获取字节码文件中所有声明的字段的信息,从而了解类或接口的结构和属性。
(3) 字段表 field_info 结构
字段表 field_info 的结构如下所示:
1 | field_info { |
将上面的结构汇总到表格中,如下:
字段访问标志 (access_flags)
字节码中的字段可以使用多个 标志(access_flags) 来描述当前字段的访问权限和基本属性,这些标志是一个 u2 的数据类型,其中可以设置的标志位和含义如下表所示:
注: 由于语法约束,字段访问标志遵循如下规则:
● ① ACC_FINAL、ACC_VOLATILE 不能同时选择,而 ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED 三个标志最多只能选择其一。
● ② 接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL 标志,这些都是由 Java 本身的语言规则所导致的。
字段名称索引 (name_index)
字段名称索引 (name_index) 存储了字段的名称在常量池中的索引值。通过读取字段表中的 name_index 字段,可以获取字段名称在常量池中的索引值,进而可以在常量池中查找到对应的名称常量项,得到字段的名称,实现对字段名称的引用和解析,使得在字节码文件中能够准确地识别和使用字段。
注: 常量池中记录的名称是一个非限定名称,这里介绍下全限定名称和非限定名称这两个概念:
● 全限定名称: 如 “club/mydlq/TestClass” 就是全限定名称,和类名类似,不过分隔符使用的不是 “.”,而是使用的 “/“ 作为分隔符,并且为了使连续的多个全限定名之间不产生混淆,在使用时会在最后加入一个 “;” 号表示全限定名结束。
● 非限定名称: 也称为简单名称,如 “java.lang.Object” 则表示为 “Object”,就是非限定名称。
字段描述符索引 (descriptor_index)
字段描述符索引也是一个关联到常量池中常量的索引,它的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符的规则,基本数据类型(byte、char、double、float、int、long、short、boolean)及代表无返回值的 void 类型,都用一个大写字符来表示,而对象则用字符 “L + 对象的全限定名” 来表示,归总如下表:
字段属性表集合计数器 (attributes_count)
字段属性表集合计数器 (attributes_count) 是一个项 u2 类型的无符号数,表示该字段拥有的属性数量。通过读取该字段,可以确定字段属性表的数量,并在后续解析中按照数量进行迭代和处理。
字段属性表集合 (attributes)
字段属性表集合用于存储一些额外的信息,比如初始化值、一些注释信息等。字段属性表集合是一个数组,包含了多个属性表 (attribute_info)。
属性表 attribut_info 结构如下:
1 | attribute_info { |
不同属性具体的作用,需要参考属性表集合中对各个属性的介绍。
(4) 分析示例字节码中的字段表集合
根据上面的”示例字节码文件”为例,我们分析一下该示例字节码文件的字段表集合。这里给出示例字节码对应内容的截图,如下所示:
根据图中的内容,可以分析出示例字节码文件中字段计数器的十六进制值为 0x0002,转换为十进制为 2,则说明字段表中存在两个字段。第一个字段为 private int a,第二个字段: private String b。
5.7 方法表集合
(1) 方法表集合简介
在字段表集合之后就是方法表集合 (methods)。 方法表集合用于对字节码文件中的方法进行描述,跟字段表集合非常相似,方法表 (method_info) 中的结构与同字段表一样,依次包括 访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes) 几项。这些数据项目的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。
在字节码文件中,每一个方法表项都对应着一个类或者接口中的方法信息,比如方法的访问修饰符 (public、private 或 protected),方法的返回值类型以及方法的参数信息等。
一方面方法表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,方法表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息 (如,类或接口初始化方法
注:
在 Java 语言中,要重载 (Overload) 一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在字节码文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个字节码文件中。
也就是说,尽管 Java 语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和 Java 语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同
(2) 方法表集合的组成
常量池主要是以 方法表集合计数器(methods_count) 和 方法表集合(methods) 组成,如下:
方法表集合计数器 (methods_count)
方法表集合计数器 (methods_count) 是一个项 u2 类型的无符号数,用于表示方法表集合中方法表的数量。
方法表集合 (methods)
方法表集合 (methods) 用于存储类或接口中定义的方法的信息。它是一个数组,它是一个由多个方法属性表 (method_info) 组成的结构。
每个方法都可以包含多个属性,这些属性提供了有关方法的额外信息,例如方法的访问修饰符、参数列表、异常表、局部变量表等。
每个方法属性表由多个字段组成,包括方法名称索引(name_index)、方法描述符索引(descriptor_index)、访问标志(access_flags)、属性表集合(attributes)。
(3) 方法表 method_info 结构
方法表 method_info 的结构如下所示:
1 | method_info { |
将上面的结构汇总到表格中,如下:
方法访问标志 (access_flags)
和字段表中的访问标志一样,方法表中也可以使用多个标志 (access_flags) 来描述当前方法的访问权限和基本属性,例如 public、private、static 等。
方法访问标志是一个 u2 的数据类型,其中可以设置的标志位和含义如下表所示:
方法名称索引 (name_index)
方法名称索引 (name_index) 存储了方法的名称在常量池中的索引值。通过读取方法表中的 name_index 字段,可以获取方法名称在常量池中的索引值,进而可以在常量池中查找到对应的名称常量项,得到方法的名称,并进一步获取有关该方法的信息,如方法的参数和返回类型等。
方法描述符索引 (descriptor_index)
方法描述符索引 (descriptor_index) 存储了方法描述符在常量池中的索引值。通过该索引可以在常量池中定位到对应的常量项,从而获取该方法在常量池中的方法描述符。
字段描述符是一个字符串,用于描述字段的类型和属性。它包含一系列字符,每个字符代表一种类型或属性。例如,常用的字段描述符有 “I” 表示整数类型,”Ljava/lang/String;” 表示字符串类型。
方法属性表集合计数器 (attributes_count)
方法属性表集合计数器 (attributes_count) 是一个项 u2 类型的无符号数,表示该字段拥有的属性数量。通过读取该字段,可以确定方法属性表的数量,并在后续解析中按照数量进行迭代和处理,根据属性的类型和内容进一步理解和分析方法的特性和行为。
方法属性集合 (attributes)
方法属性表集合用于存储一些额外的信息,比如初始化值、一些注释信息等。方法属性表集合是一个数组,包含了多个属性表 (attribute_info)。
属性表 attribut_info 结构如下:
1 | attribute_info { |
不同属性具体的作用,需要参考属性表集合中对各个属性的介绍。
(4) 分析示例字节码中的方法表集合
根据上面的”示例字节码文件”为例,我们分析一下该示例字节码文件的方法表集合。这里给出示例字节码对应内容的截图,如下所示:
根据图中的内容,可以分析出示例字节码文件中字段计数器的十六进制值为 0x0002,转换为十进制为 2,则说明字段表中存在两个方法。第一个方法为 public void
图中的属性结构为 attribute_info 结构,其中属性名称索引值为 0x000c,关联常量池中的第 12 个常量,常量的字面量为 Code,即该属性的名称,该属性一般用于存储方法的字节码指令。
注: 因图大小原因,上图中只分析了方发表集合中的第一个方法表
5.8 属性表集合
(1) 属性表集合简介
在方法表集合之后就是 “属性表集合” (attributes)。属性表集合用于记录字节码文件中的辅助信息。 比如,字节码文件中的源文件名称,以及任何带有 RetentionPolicy.CLASS 或者 RetentionPolicy.RUNTIME 的注解。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试。
此外,字段表和方法表中也都有各自的属性表,以描述某些场景专有的信息。
注: 属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但 Java 虚拟机运行时会忽略掉它不认识的属性。
(2) 属性表 attribute_info 结构
属性表集合计数器 (attributes_count)
属性表集合计数器 (attributes_count) 的值表示当前 class 文件属性表的成员个数。
属性表结构 (attribute_info)
属性表都是 attribute_info 结构,其结构比较灵活,各种不同的属性只要满足以下结构即可:
1 | attribute_info { |
具体描述信息归总到下表中:
(3) 属性类型
属性表实际上可以有很多类型,Java 8 里面定义了 23 种属性。下面这些是虚拟机中预定义的属性:
(4) 部分属性详解
① Code 属性
Code 属性用于存放方法体中的代码。方法体中的代码在经过 Javac 编译器处理之后,会变成字节码指令存储在 Code 属性内。一般情况下 Code 属性出现在方法表属性集合中,但并非所有方法表都有 Code 属性,如接口或者抽象类中的抽象方法,他们没有具体的方法体,因此也就不会有 Code 属性。
Code 属性的结构如下:
② Exceptions 属性
Exceptions 属性位方法表的属性表中,该属性指出了一个方法可能抛出的非运行时异常,也就是方法描述时在 throws 关键字后面列举的异常,一个方法表的属性表中最多只能有一个 Exceptions 属性。
Exceptions 属性的结构如下:
此属性中的 number_of_exceptions 项表示方法可能抛出 number_of_exceptions 种非运行时异常,每一种非运行时异常使用一个 exception_index_table 项表示。而 exception_index_table 是一个指向常量池中 CONSTANT_Class_info 型常量的索引。
③ LineNumberTable 属性
LineNumberTable 属性用于描述 Java 源码行号与字节码行号 (字节码的偏移量) 之间的对应关系,位于 Code 结构的属性表中。它并不是运行时必需的属性,但默认会生成到 Class 文件之中,可以在 Javac 中使用 -g:none 或 -g:lines 选项来取消或要求生成这项信息。如果选择不生成 LineNumberTable 属性,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候也无法按照源码行来设置断点。
LineNumberTable 属性的结构如下:
④ LocalVariableTable 属性
LocalVariableTable 位于 Code 属性的属性表中,用于调试器在执行方法的过程中确定某个局部变量的值。在 Code 属性的属性表中 LocalVariableTable 属性可以按照任意顺序出现,不过 Code 属性中的每个局部变量最多只能有一个 LocalVariableTable 属性。
LocalVariableTable 属性的结构如下:
⑤ Deprecated 属性
Deprecated 属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过代码中使用 @deprecated 注解进行设置。
Deprecated 属性的结构如下:
⑥ Synthetic 属性
Synthetic 属性代表此字段或者方法并不是由 Java 源码直接产生的,而是由编译器自行添加的,在 JDK 5 之后,标识一个类、字段或者方法是编译器自动产生的,也可以设置它们访问标志中的 ACC_SYNTHETIC 标志位。
编译器通过生成一些在源代码中不存在的 Synthetic 方法、字段甚至是整个类的方式,实现了越权访问 (越过 private 修饰器) 或其它绕开了语言限制的功能,这可以算是一种早期优化的技巧,其中最典型的例子就是枚举类中自动生成的枚举元素数组和嵌套类的桥接方法 (Bridge Method)。所有由不属于用户代码产生的类、方法及字段都应当至少设置 Synthetic 属性或者 ACC_SYNTHETIC 标志位中的一项,唯一的例外是实例构造器
Synthetic 属性的结构如下:
⑦ 其它属性…略
JKD 8 虚拟机规范中总共定义了 23 个属性,这里就不一一介绍了,详情可以看 Orable 的 Java 虚拟机规范。