• 05-类文件结构


    1. 平台无关、语言无关

    Java 在刚刚诞生之时曾经提出过一个非常著名的宣传口号“一次编写,到处运行(Write Once,Run Anywhere)”,这句话充分表达了当时软件开发人员对冲破平台界限的渴求。

    “与平台无关”的理想最终只有实现在操作系统以上的应用层:Oracle 公司以及其他虚拟机发行商发布过许多可以运行在各种不同硬件平台和操作系统上的 Java 虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。

    各种不同平台的 Java 虚拟机,以及所有平台都统一支持的程序存储格式 —— 字节码(Byte Code)是构成平台无关性的基石。


    实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java 虚拟机不与包括 Java 语言在内的任何程序语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集、符号表以及若干其他辅助信息。

    作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将 Java 虚拟机作为他们语言的运行基础,以 Class 文件作为他们产品的交付媒介。例如,使用 Java 编译器可以把 Java 代码编译为存储字节码的 Class 文件,使用 JRuby 等其他语言的编译器一样可以把它们的源程序代码编译成 Class 文件。虚拟机丝毫不关心 Class 的来源是什么语言,它与程序语言之间的关系如图所示。

    Java 语言中的各种语法、关键字、常量变量和运算符号的语义最终都会由多条字节码指令组合来表达,这决定了字节码指令所能提供的语言描述能力必须比 Java 语言本身更加强大才行。因此,有一些 Java 语言本身无法有效支持的语言特性并不代表在字节码中也无法有效表达出来,这为其他程序语言实现一些有别于 Java 的语言特性提供了发挥空间。

    2. Class 类文件的结构

    Class 文件是一组以 8 个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符,这使得整 Class 文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用 8 个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个 8 个字节进行存储。

    根据《Java 虚拟机规范》的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。

    • 无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
    • 表是由多个“无符号数”或者“其他表”作为数据项构成的复合数据类型(比如 constant_pool 中每一项常量都是一个表结构的数据),为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上也可以视作是一张表,这张表由下图(两边是一个意思)所示的数据项按严格顺序排列构成

    无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用「一个前置的容量计数器(xxx_count)加若干个连续的数据项」的形式,这时候称这一系列连续的某一类型的数据为某一类型的“集合”。

    再强调一次!Class 的结构不像 XML 等描述语言,由于它没有任何分隔符号,所以在类文件结构(如上图所示)中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(Byte Ordering,Class 文件中字节序为 Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,全部都不允许改变。

    3. HelloWorld

    // HelloWorld 示例
    public class HelloWorld {
        public static void main(String[] args) {
            System.out.println("hello world");
        }
    }
    

    执行 javac -parameters -d . HellowWorld.java 编译为 HelloWorld.class 后如下所示(Class 文件是一组以 8 个字节为基础单位的二进制流,所以左侧的行号进位是 8,而下图每一行有 16 字节,故行之间的间隔为 20):

    4. 魔数和 Class 版本

    每个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。

    [ca fe ba be] 00 00 00 34 00 23 0a 00 06 00 15 09
    

    紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。

    ca fe ba be [00 00 00 34] 00 23 0a 00 06 00 15 09
    

    btw:即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。

    5. 常量池

    5.1 概述

    紧接着主、次版本号之后的是常量池入口,常量池可以比喻为 Class 文件里的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 Class 文件空间最大的数据项目之一,另外,它还是在 Class 文件中第一个出现的表类型数据项目。

    由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。

    与 Java 中语言习惯不同,这个容量计数是从 1 而不是 0 开始的。在 Class 文件格式规范制定之时,设计者将第 0 项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为 0 来表示。

    Class 文件结构中只有常量池的容量计数是从 1 开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从 0 开始。

    5.2 存放类型

    常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

    字面量比较接近于 Java 语言层面的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,主要包括下面几类常量:

    • 被模块导出或者开放的包(Package)
    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符
    • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
    • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

    5.3 动态连接

    Java 代码在进行 Javac 编译的时候,并不像 C 和 C++ 那样有“连接”这一步骤,而是在虚拟机加载 Class 文件的时候进行动态连接。也就是说,在 Class 文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。

    当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态连接的内容,在后面介绍“虚拟机类加载过程”时再详细讲解。

    5.4 常量池的项目类型

    常量池中每一项常量都是一个表,最初常量表中共有 11 种结构各不相同的表结构数据,后来为了更好地支持动态语言调用,额外增加了 4 种动态语言相关的常量,为了支持 Java 模块化系统(Jigsaw),又加入了 CONSTANT_Module_info 和 CONSTANT_Package_info 两个常量,所以截至 JDK 13,常量表中分别有 17 种不同类型的常量。

    这 17 类表都有一个共同的特点,表结构起始的第一位是个 u1 类型的标志位(tag,取值见表中标志列),代表着当前常量属于哪种常量类型。17 种常量类型所代表的具体含义如表所示。

    常量池中的 17 种数据类型的结构总表(把“表结构”想成“C 结构体”可能会好些):

    5.5 结合 HelloWorld

    (0)8~9 字节,表示常量池长度,00 23(35)表示常量池有 #1~#34 项,注意 #0 项不计入,也没有值。

    ca fe ba be 00 00 00 34 [00 23] 0a 00 06 00 15 09
    

    (1)第 #1 项 0a 表示一个 Method 信息,00 06 和 00 15(21)表示它引用了常量池中 #6 和 #21 项来获得这个方法的〈所属类〉和〈方法名〉。

    ca fe ba be 00 00 00 34 00 23 [(0a) 00 06 00 15] 09
    

    (2)第 #2 项 09 表示一个 Field 信息,00 16(22)和 00 17(23)表示它引用了常量池中 #22 和 # 23 项来获得这个成员变量的〈所属类〉和〈成员变量名〉。

    ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 [(09)
    00 16 00 17] 08 00 18 0a 00 19 00 1a 07 00 1b 07
    

    (3)第 #3 项 08 表示一个字符串常量名称,00 18(24)表示它引用了常量池中 #24 项。

    00 16 00 17 [(08) 00 18] 0a 00 19 00 1a 07 00 1b 07
    

    (4)第 #4 项 0a 表示一个 Method 信息,00 19(25) 和 00 1a(26) 表示它引用了常量池中 #25 和 #26 项来获得这个方法的〈所属类〉和〈方法名〉。

    00 16 00 17 08 00 18 [(0a) 00 19 00 1a] 07 00 1b 07
    

    (5)第 #5 项 07 表示一个 Class 信息,00 1b(27) 表示它引用了常量池中 #27 项。

    00 16 00 17 08 00 18 0a 00 19 00 1a [(07) 00 1b] 07
    

    (6)第 #6 项 07 表示一个 Class 信息,00 1c(28) 表示它引用了常量池中 #28 项。

    00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b [(07)
    00 1c] 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
    

    (7)第 #7 项 01 表示一个 utf8 串,00 06 表示长度,3c 69 6e 69 74 3e 是 <init>

    00 1c [(01) 00 06 <3c 69 6e 69 74 3e>] 01 00 03 28 29
    

    (8)第 #8 项 01 表示一个 utf8 串,00 03 表示长度,28 29 56 是 ()V 其实就是表示无参、无返回值。

    00 1c 01 00 06 3c 69 6e 69 74 3e [(01) 00 03 <28 29
    56>] 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
    

    (9)第 #9 项 01 表示一个 utf8 串,00 04 表示长度,43 6f 64 65 是 Code

    56 [(01) 00 04 <43 6f 64 65>] 01 00 0f 4c 69 6e 65 4e
    

    (10)第 #10 项 01 表示一个 utf8 串,00 0f(15) 表示长度,4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 是 LineNumberTable

    56 01 00 04 43 6f 64 65 [(01) 00 0f <4c 69 6e 65 4e
    75 6d 62 65 72 54 61 62 6c 65>] 01 00 12 4c 6f 63
    

    (11)第 #11 项 01 表示一个 utf8 串,00 12(18) 表示长度,4c 6f 63 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 是 LocalVariableTable

    75 6d 62 65 72 54 61 62 6c 65 [(01) 00 12 <4c 6f 63
    61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65>] 01
    

    (12)第 #12 项 01 表示一个 utf8 串,00 04 表示长度,74 68 69 73 是 this

    61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 [(01)
    00 04 <74 68 69 73>] 01 00 1d 4c 63 6e 2f 69 74 63
    

    (13)第 #13 项 01 表示一个 utf8 串,00 1d(29) 表示长度,4c ... 3b 是 Lcn/itcast/jvm/t5/HelloWorld;

    00 04 74 68 69 73 [(01) 00 1d <4c 63 6e 2f 69 74 63
    61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f
    57 6f 72 6c 64 3b>] 01 00 04 6d 61 69 6e 01 00 16
    

    (14)第 #14 项 01 表示一个 utf8 串,00 04 表示长度,6d 61 69 6e 是 main

    57 6f 72 6c 64 3b [(01) 00 04 <6d 61 69 6e>] 01 00 16
    

    (15)第 #15 项 01 表示一个 utf8 串,00 16(22) 表示长度,28 ... 56 为 ([Ljava/lang/String;)V,其实就是参数为字符串数组,无返回值。

    57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e [(01) 00 16
    <28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72
    69 6e 67 3b 29 56>] 01 00 04 61 72 67 73 01 00 13
    

    (16)第 #16 项 01 表示一个 utf8 串,00 04 表示长度,61 72 67 73 是 args

    69 6e 67 3b 29 56 [(01) 00 04 <61 72 67 73>] 01 00 13
    

    (17)第 #17 项 01 表示一个 utf8 串,00 13(19) 表示长度,5b ... 3b 是 [Ljava/lang/String;

    69 6e 67 3b 29 56 01 00 04 61 72 67 73 [(01) 00 13
    <5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69
    6e 67 3b>] 01 00 10 4d 65 74 68 6f 64 50 61 72 61
    

    (18)第 #18 项 01 表示一个 utf8 串,00 10(16) 表示长度,4d ... 73 是 MethodParameters

    6e 67 3b [(01) 00 10 <4d 65 74 68 6f 64 50 61 72 61
    6d 65 74 65 72 73>] 01 00 0a 53 6f 75 72 63 65 46
    

    (19)第 #19 项 01 表示一个 utf8 串,00 0a(10) 表示长度,53 ... 65 是 SourceFile

    6d 65 74 65 72 73 [(01) 00 0a <53 6f 75 72 63 65 46
    69 6c 65>] 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64
    

    (20)第 #20 项 01 表示一个 utf8 串,00 0f(15) 表示长度,48 ... 61是 HelloWorld.java

    69 6c 65 [(01) 00 0f <48 65 6c 6c 6f 57 6f 72 6c 64
    2e 6a 61 76 61>] 0c 00 07 00 08 07 00 1d 0c 00 1e
    

    (21)第 #21 项 0c 表示一个 「名+类型」,00 07 00 08 引用了常量池中 #7、#8 两项。

    2e 6a 61 76 61 [(0c) 00 07 00 08] 07 00 1d 0c 00 1e
    

    (22)第 #22 项 07 表示一个 Class 信息,00 1d(29) 引用了常量池中 #29 项。

    2e 6a 61 76 61 0c 00 [(07) 00 08 07 00 1d] 0c 00 1e
    

    (23)第 #23 项 0c 表示一个 「名+类型」,00 1e(30) 00 1f (31)引用了常量池中 #30、#31 两项。

    2e 6a 61 76 61 0c 00 07 00 08 07 00 1d [(0c) 00 1e
    00 1f] 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64
    

    (24)第 #24 项 01 表示一个 utf8 串,00 0b(11) 表示长度,是 hello world

    00 1f [(01) 00 0b <68 65 6c 6c 6f 20 77 6f 72 6c 64>]
    

    (25)第 #25 项 07 表示一个 Class 信息,00 20(32) 引用了常量池中 #32 项。

    [(07) 00 20] 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74
    

    (26)第#26项 0c 表示一个 「名+类型」,00 21(33) 00 22(34)引用了常量池中 #33、#34 两项。

    07 00 20 [(0c) 00 21 00 22] 01 00 1b 63 6e 2f 69 74
    

    (27)第 #27 项 01 表示一个 utf8 串,00 1b(27) 表示长度,63 ... 64 是 cn/itcast/jvm/t5/HelloWorld

    07 00 20 0c 00 21 00 22 [(01) 00 1b <63 6e 2f 69 74
    63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c
    6f 57 6f 72 6c 64>] 01 00 10 6a 61 76 61 2f 6c 61
    

    (28)第 #28 项 01 表示一个 utf8 串,00 10(16) 表示长度,6a ... 74 是 java/lang/Object

    6f 57 6f 72 6c 64 [(01) 00 10 <6a 61 76 61 2f 6c 61
    6e 67 2f 4f 62 6a 65 63 74>] 01 00 10 6a 61 76 61
    

    (29)第 #29 项 01 表示一个 utf8 串,00 10(16) 表示长度,6a ... 6d 是 java/lang/System

    6e 67 2f 4f 62 6a 65 63 74 [(01) 00 10 <6a 61 76 61
    2f 6c 61 6e 67 2f 53 79 73 74 65 6d>] 01 00 03 6f
    

    (30)第 #30 项 01 表示一个 utf8 串,00 03 表示长度,6f 75 74 是 out

    2f 6c 61 6e 67 2f 53 79 73 74 65 6d [(01) 00 03 <6f
    75 74>] 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72
    

    (31)第 #31 项 01 表示一个 utf8 串,00 15(21) 表示长度,4c ... 3b 是 Ljava/io/PrintStream;

    75 74 [(01) 00 15 <4c 6a 61 76 61 2f 69 6f 2f 50 72
    69 6e 74 53 74 72 65 61 6d 3b>] 01 00 13 6a 61 76
    

    (32)第 #32 项 01 表示一个 utf8 串,00 13(19) 表示长度,6a ... 6d 是 java/io/PrintStream

    69 6e 74 53 74 72 65 61 6d 3b [(01) 00 13 <6a 61 76
    61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d>]
    

    (33)第 #33 项 01 表示一个 utf8 串,00 07 表示长度,70 ... 6e 是 println

    [(01) 00 07 <70 72 69 6e 74 6c 6e>] 01 00 15 28 4c 6a
    

    (34)第 #34 项 01 表示一个 utf8 串,00 15(21) 表示长度,28 ... 56 是 (Ljava/lang/String;)V

    01 00 07 70 72 69 6e 74 6c 6e [(01) 00 15 <28 4c 6a
    61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b
    29 56>] 00 21 00 05 00 06 00 00 00 00 00 02 00 01
    

    仔细看一下会发现,其中有些常量似乎从来没有在代码中出现过,如 <init>LineNumberTableLocalVariableTable 等,这些看起来在源代码中不存在的常量是哪里来的?

    这部分常量的确不来源于 Java 源代码,它们都是编译器自动生成的,会被后面即将讲到的字段表(field_info)、方法表(method_info)、属性表(attribute_info)所引用,它们将会被用来描述一些不方便使用“固定字节”进行表达的内容,譬如描述方法的返回值是什么。

    因为 Java 中的“类”是无穷无尽的,无法通过简单的无符号数来描述一个方法用到了什么类,因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达。

    6. 访问标识和继承信息

    6.1 访问标识

    在常量池结束之后,紧接着的 2 个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口、是否定义为 public 类型、是否定义为 abstract 类型、如果是类的话,是否被声明为 final 等等。具体的标志位以及标志的含义见下表。

    access_flags 中一共有 16 个标志位可以使用,当前只定义了其中 9 个,没有使用到的标志位要求一律为 0。以 HelloWorld 的代码为例:

    // 00 21 表示该 class 是一个类,公共的
    29 56 [00 21] 00 05 00 06 00 00 00 00 00 02 00 01
    

    HelloWorld 是一个普通 Java 类,不是接口、枚举、注解或者模块,被 public 关键字修饰但没有被声明为 final 和 abstract,并且它使用了 JDK 1.2 之后的编译器进行编译,因此它的 ACC_PUBLIC、ACC_SUPER 标志应当为真,而 ACC_FINAL、ACC_INTERFACE、ACC_ABSTRACT、ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM、ACC_MODULE 这 7 个标志应当为假,因此它的 access_flags 的值应为:0x0001 | 0x0020 = 0x0021

    6.2 类型的继承关系

    类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据的集合,Class 文件中由这 3 项数据来确定该类型的继承关系。

    6.2.1 类索引和父类索引

    「类索引」用于确定这个类的全限定名,「父类索引」用于确定这个类的父类的全限定名。

    由于 Java 语言不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。

    类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过 CONSTANT_Class_info 类型的常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。

    // 00 05 表示根据常量池中 #5 找到本类全限定名
    29 56 00 21 [00 05] 00 06 00 00 00 00 00 02 00 01
    
    // 00 06 表示根据常量池中 #6 找到父类全限定名
    29 56 00 21 00 05 [00 06] 00 00 00 00 00 02 00 01
    

    6.2.2 接口索引集合

    「接口索引集合」就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 关键字(如果这个 Class 文件表示的是一个接口,则应当是 extends 关键字)后的接口顺序从左到右排列在接口索引集合中。

    对于接口索引集合,入口的第一项 u2 类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为 0,后面接口的索引表不再占用任何字节。

    // 00 00 接口计数器,本类实现的接口数量为 0
    29 56 00 21 00 05 00 06 [00 00] 00 00 00 02 00 01
    

    7. 字段表集合

    字段表(field_info)用于描述接口或者类中声明的变量。Java 语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。读者可以回忆一下在 Java 语言中描述一个字段可以包含哪些信息?

    字段可以包括的修饰符有字段的作用域(public、private、protected 修饰符)、是实例变量还是类变量(static 修饰符)、可变性(final)、并发可见性(volatile 修饰符,是否强制从主内存读写)、可否被序列化(transient 修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。

    上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。下表列出了字段表的最终格式。

    字段修饰符放在 access_flags 项目中,它与类中的 access_flags 项目是非常类似的,都是一个 u2 的数据类型,其中可以设置的标志位和含义如下表所示。

    7.1 字段访问标志

    很明显,由于语法规则的约束,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED 这 3 个标志最多只能选择其一,ACC_FINAL、ACC_VOLATILE 不能同时选择。接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL 标志,这些都是由 Java 本身的语言规则所导致的。

    7.2 字段名称和描述符

    跟随 access_flags 标志的是 2 项索引值:name_index 和 descriptor_index。它们都是对常量池项的引用,分别代表着「字段的简单名称」以及「字段和方法的描述符」。

    现在需要解释一下“简单名称”、“描述符”以及前面出现过多次的“全限定名”这 3 种特殊字符串的概念。

    全限定名和简单名称很好理解,以 HelloWorld 的代码为例,“cn/itcast/jvm/t5/HelloWorld”是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”号表示全限定名结束。

    简单名称则就是指没有类型和参数修饰的方法或者字段名称,这个类中的 main 方法的简单名称分别就是“main”。

    相比于全限定名和简单名称,「字段和方法的描述符」就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示,而对象类型则用字符 L 加对象的全限定名来表示,详见下表。

    【注】void 类型在《Java 虚拟机规范》之中单独列出为“VoidDescriptor”,笔者为了结构统一,将其列在基本数据类型中一起描述。

    对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为 java.lang.String[][] 类型的二维数组将被记录成“[[Ljava/lang/String;”,一个整型数组 int[] 将被记录成“[I”。

    用描述符来描述方法时,按照「先参数列表、后返回值」的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法 java.lang.String toString() 的描述符为“()Ljava/lang/String;”,方法 int indexOf(char[]source, int sourceOffset, int sourceCount, char[]target, int targetOffset, int targetCount, int fromIndex) 的描述符为“([CII[CIII)I”。

    字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本 Java 代码之中不存在的字段(在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段)。另外,在 Java 语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于 Class 文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就是合法的。

    8. 方法表集合

    Class 文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项,如下表所示。这些数据项目的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。

    8.1 方法访问标志

    因为 volatile 关键字和 transient 关键字不能修饰方法,所以方法表的访问标志中没有了 ACC_VOLATILE 标志和 ACC_TRANSIENT 标志。与之相对,synchronized、native、strictfp 和 abstract 关键字可以修饰方法,方法表的访问标志中也相应地增加了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 标志。对于方法表,所有标志位及其取值可参见下表。

    8.2 方法体

    行文至此,也许有的读者会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚,但方法里面的代码去哪里了?方法里的 Java 代码,经过 Javac 编译器编译成字节码指令之后,存放在「方法属性表集合」中一个名为“Code”的属性里面,属性表作为 Class 文件格式中最具扩展性的一种数据项目,将在下一节中详细讲解。

    我们继续以 HelloWorld 的 Class 文件为例对方法表集合进行分析。如下所示,第一个 u2 类型的数据(即计数器容量)的值为 0x0002,代表集合中有 2 个方法,这两个方法为编译器添加的实例构造器 <init> 和源码中定义的方法 main()

    第一个方法的访问标志值为 0x0001,也就是只有 ACC_PUBLIC 标志为真,名称索引值为 0x0007,查常量池得方法名为 <init>,描述符索引值为 0x0008,对应常量为“()V”,属性表计数器 attributes_count 的值为 0x0001,表示此方法的属性表集合有 1 项属性,属性名称的索引值为 0x0009,对应常量池中常量为“Code”,说明此属性是方法的字节码描述,而属性值所占用的位数用一个 u4 来表示(00 00 00 2F 转十进制为 47)。

    8.3 方法重写

    与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器 <clinit>() 方法和实例构造器 <init>() 方法。

    在 Java 语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的「特征签名」。特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以 Java 语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在 Class 文件格式之中,特征签名的范围明显要更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个 Class 文件中的。

    【注】在《Java 虚拟机规范》第 2 版的 4.4.4 节及《Java 语言规范》第 3 版的 8.4.2 节中分别都定义了〈字节码层面的方法特征签名〉以及 〈Java 代码层面的方法特征签名〉,〈Java 代码的方法特征签名〉只包括方法名称、参数顺序及参数类型,而〈字节码的特征签名〉还包括方法返回值以及受查异常表,请读者根据上下文语境注意区分。

    9. 属性表集合

    属性表(attribute_info)在前面的讲解之中已经出现过数次,Class 文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。

    与 Class 文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格顺序,并且《Java 虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。

    为了能正确解析 Class文件,《Java 虚拟机规范》最初只预定义了 9 项所有 Java 虚拟机实现都应当能识别的属性,而在最新的《Java 虚拟机规范》的 Java SE 12 版本中,预定义属性已经增加到 29 项,这些属性具体见下表。后文中将对这些属性中的关键的、常用的部分进行讲解。

    对于每一个属性,它的名称都要从常量池中引用一个 CONSTANT_Utf8_info 类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个 u4 的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足如下所定义的结构。

    9.1 Code 属性

    Java 程序方法体里面的代码经过 Javac 编译器处理之后,最终变为字节码指令存储在 Code 属性内。Code 属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在 Code 属性,如果方法表有 Code 属性存在,那么它的结构将如下表所示。

    9.1.1 结构

    (1) attribute_name_index、attribute_length

    是一项指向 CONSTANT_Utf8_info 型常量的索引,此常量值固定为“Code”,它代表了该属性的属性名称,attribute_length 指示了属性值的长度,由于属性名称索引与属性长度一共为 6 个字节,所以属性值的长度固定为整个属性表长度减去 6 个字节。

    (2) max_stack

    代表了操作数栈(Operand Stack)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度。

    (3) max_locals

    代表了〈局部变量表〉所需的存储空间。在这里,max_locals 的单位是「变量槽 (Slot)」,变量槽是虚拟机为局部变量分配内存所使用的最小单位。

    对于 byte、char、float、int、short、boolean 和 returnAddress 等长度不超过 32 位的数据类型,每个局部变量占用一个变量槽,而 double 和 long 这两种 64 位的数据类型则需要两个变量槽来存放。方法参数(包括实例方法中的隐藏参数“this”)、显式异常处理程序的参数(Exception Handler Parameter,就是 try-catch 语句中 catch 块中所定义的异常)、方法体中定义的局部变量都需要依赖局部变量表来存放。

    注意,并不是在方法中用了多少个局部变量,就把这些局部变量所占变量槽数量之和作为 max_locals 的值,操作数栈和局部变量表直接决定一个该方法的栈帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。

    Java 虚拟机的做法是将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量槽可以被其他局部变量所使用,Javac 编译器会根据变量的作用域来分配变量槽给各个变量使用,根据同时生存的最大局部变量数量和类型计算出 max_locals 的大小。

    public class VarTest {
    
        private void test1() {
            String a = "a";
            System.out.println(a);
            String b = "b";
        }
    
        private void test2() {
            {
                String a = "a";
                System.out.println(a);
            }
            String b = "b";
        }
    }
    

    将上述测试代码编译后,通过 jclasslib 工具查看字节码:

    (4) code_length、code

    用来存储 Java 源程序编译后生成的字节码指令。code_length 代表字节码长度,code 是用于存储字节码指令的一系列字节流。既然叫字节码指令,那顾名思义每个指令就是一个 u1 类型的单字节,当虚拟机读取到 code 中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及后续的参数应当如何解析。

    我们知道一个 u1 数据类型的取值范围为 0x00~0xFF,对应十进制的 0~255,也就是一共可以表达 256 条指令。目前,《Java 虚拟机规范》已经定义了其中约 200 条编码值对应的指令含义,编码与指令之间的对应关系可自行查阅“虚拟机字节码指令表”。

    关于 code_length,有一件值得注意的事情,虽然它是一个 u4 类型的长度值,理论上最大值可以达到 2^32,但是《Java 虚拟机规范》中明确限制了一个方法不允许超过 65535 条字节码指令,即它实际只使用了 u2 的长度,如果超过这个限制,Javac 编译器就会拒绝编译。一般来讲,编写 Java 代码时只要不是刻意去编写一个超级长的方法来为难编译器,是不太可能超过这个最大值的限制的。但是,某些特殊情况,例如在编译一个很复杂的 JSP 文件时,某些 JSP 编译器会把 JSP 内容和页面输出的信息归并于一个方法之中,就有可能因为方法生成字节码超长的原因而导致编译失败。

    (5) exception_table_length、exception_table

    在字节码指令之后的是这个方法的显式异常处理表(下文简称“异常表”)集合,「异常表」对于 Code 属性来说并不是必须存在的。

    如果存在「异常表」,那它的格式应如表所示,包含四个字段,这些字段的含义为:如果当字节码从第 start_pc 行到第 end_pc 行之间(不含第 end_pc 行)出现了类型为 catch_type 或者其子类的异常(catch_type 为指向一个 CONSTANT_Class_info 型常量的索引),则转到第 handler_pc 行继续处理。当 catch_type 的值为 0 时,代表任意异常情况都需要转到 handler_pc 处进行处理。

    「异常表」实际上是 Java 代码的一部分,尽管字节码中有最初为处理异常而设计的跳转指令,但《Java 虚拟机规范》中明确要求 Java 语言的编译器应当选择使用「异常表」而不是通过跳转指令来实现 Java 异常及 finally 处理机制。

    如下是一段演示「异常表」如何运作的例子,这段代码主要演示了在字节码层面 try-catch-finally 是如何体现的。

    编译器为这段 Java 源码生成了 3 条异常表记录,对应 3 条可能出现的代码执行路径。从 Java 代码的语义上讲,这 3 条执行路径分别为:

    1. 如果 try 语句块中出现属于 Exception 或其子类的异常,转到 catch 语句块处理;
    2. 如果 try 语句块中出现不属于 Exception 或其子类的异常,转到 finally 语句块处理;
    3. 如果 catch 语句块中出现任何异常,转到 finally 语句块处理。

    返回到我们上面提出的问题,这段代码的返回值应该是多少?→ 如果没有出现异常,返回值是 1;如果出现了 Exception 异常,返回值是 2;如果出现了 Exception 以外的异常,方法非正常退出,没有返回值。

    下面来分析一下字节码的执行过程,从字节码的层面上看看为何会有这样的返回结果:

    字节码中第 0~4 行所做的操作就是将整数 1 赋值给变量 x,并且将此时 x 的值复制一份副本到最后一个本地变量表的变量槽中(这个变量槽里面的值在 ireturn 指令执行前将会被重新读到操作栈顶,作为方法返回值使用。为了讲解方便,笔者给这个变量槽起个名字:returnValue)。

    如果这时候没有出现异常,则会继续走到第 5~9 行,将变量 x 赋值为 3,然后将之前保存在 returnValue 中的整数 1 读入到操作栈顶,最后 ireturn 指令会以 int 形式返回操作栈顶中的值,方法结束。

    如果出现了异常,PC 寄存器指针转到第 10 行,第 10~20 行所做的事情是将 2 赋值给变量 x,然后将变量 x 此时的值赋给 returnValue,最后再将变量 x 的值改为 3。方法返回前同样将 returnValue 中保留的整数 2 读到了操作栈顶。从第 21 行开始的代码,作用是将变量 x 的值赋为 3,并将栈顶的异常抛出,方法结束。

    9.1.2 例 1

    Code 属性是 Class 文件中最重要的一个属性,如果把一个 Java 程序中的信息分为代码(Code,方法体里面的 Java 代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个 Class 文件里,Code 属性用于描述代码,所有的其他数据项目都用于描述元数据。

    上图是上一节分析过的实例构造器 <init>() 方法的 Code 属性。它的操作数栈的最大深度和本地变量表的容量都为 0x0001,字节码区域所占空间的长度为 0x0005。虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的 5 个字节,并根据字节码指令表翻译出所对应的字节码指令。翻译“2A B7 00 0A B1”的过程为:

    1. 读入 2A,查表得 0x2A 对应的指令为 aload_0,这个指令的含义是将第 0 个变量槽中为 reference 类型的本地变量推送到操作数栈顶。
    2. 读入 B7,查表得 0xB7 对应的指令为 invokespecial,这条指令的作用是以栈顶的 reference 类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private 方法或者它的父类的方法。这个方法有一个 u2 类型的参数说明具体调用哪一个方法,它指向常量池中的一个 CONSTANT_Methodref_info 类型常量,即此方法的符号引用。
    3. 读入 00 0A,这是 invokespecial 指令的参数,代表一个符号引用,查常量池得 0x000A 对应的常量为实例构造器 <init>() 方法的符号引用。
    4. 读入 B1,查表得 0xB1 对应的指令为 return,含义是从方法的返回,并且返回值为 void。这条指令执行后,当前方法正常结束。

    这段字节码虽然很短,但我们可以从中看出它执行过程中的数据交换、方法调用等操作都是基于栈(操作数栈)的。我们可以初步猜测,Java 虚拟机执行字节码应该是基于栈的体系结构。但又发现与通常基于栈的指令集里都是无参数的又不太一样,某些指令(如 invokespecial)后面还会带有参数。

    9.1.3 例 2

    如果大家注意到 javap 中输出的“Args_size”的值,可能还会有疑问:这个类有两个方法 —— 实例构造器 <init>()inc(),这两个方法很明显都是没有参数的,为什么 Args_size 会为 1?而且无论是在参数列表里还是方法体内,都没有定义任何局部变量,那 Locals 又为什么会等于 1?

    如果有这样疑问的读者,大概是忽略了一条 Java 语言里面的潜规则:在任何实例方法里面,都可以通过“this”关键字访问到此方法所属的对象。

    这个访问机制对 Java 程序的编写很重要,而它的实现非常简单,仅仅是通过在 Javac 编译器编译的时候把对 this 关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数值从 1 开始计算。这个处理只对实例方法有效,如果方法被声明为 static,那 Args_size 就不会等于 1 而是等于 0 了。

    9.2 其他属性

    P239 ~ P250

    1. Exceptions 属性
    2. LineNumberTable 属性
    3. LocalVariableTable 及 LocalVariableTypeTable 属性
    4. SourceFile 及 SourceDebugExtension 属性
    5. ConstantValue 属性
    6. InnerClasses 属性
    7. Deprecated 及 Synthetic 属性
    8. StackMapTable 属性
    9. Signature 属性
    10. BootstrapMethods 属性
    11. MethodsParaMeters 属性
    12. 模块化相关属性
    13. 运行时注解相关属性
  • 相关阅读:
    MySQL 8.0系列——轻松改配置,云上友好
    测试expire_logs_days参数
    mongodb单实例安装
    搭建PXC集群指引
    控制mysqldump导出的SQL文件的事务大小
    实战MySQL8.0.17 Clone Plugin
    windows环境下 curl 安装和使用
    git 创建tag , 查看tag , 删除tag
    git 基本操作
    git 一个分支完全覆盖另一个分支
  • 原文地址:https://www.cnblogs.com/liujiaqi1101/p/14785310.html
Copyright © 2020-2023  润新知