JVM 揭秘:一个 class 文件的前世今生

作者 | 童辉

杏仁 Java 开发工程师,关注底层技术。

引子:我们都知道,要运行一个包含 main 方法的 java 文件,首先要将其编译成 class 文件,然后加载 JVM 中,就可以运行了,但是这里存在一些疑问,比如编译之后的 class 文件中到底是什么东西呢?JVM 是如何执行 class 文件的呢?下面我们就以一个很简单的例子来看一下 JVM 到底是如何运行的。

1. 准备

后面所介绍的内容都以下面的 java 文件和 class 文件为例子:

java 文件:

class 文件:

2. class 文件的结构

从上面可以看到,class 文件确实和它的另一个名字字节码文件一样是由一个个的字节码组成的。这里要注意的是因为 class 文件是由一个个字节组成的,所以如果当一个数据大于一个字节的时候,是使用无符号大端模式进行存储的,大小端模式的区别可以参考这里。那么这些字节表示什么意思呢?JVM 是如何解析这些字节数据的呢?我们到 oracle 的官方文档上看一下他们是如何定义 class 文件的结构的:

从上面可以看到,一个 Class 文件中的每一个字节都有指定的意义,比如一开始的 4 个字节代表的是 magic number,这个值对所有的 Class 文件都一样,就是 CAFEBABE,接下来的 2 个字节是次版本号。再比如 cp_info,这是一个非常重要的字段,就是后面要着重介绍的常量池。

如果需要看每个字段的代表的意思可以看一下Java Language and Virtual MachineSpecifications  https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.1

上面的结构看起来可以比较抽象,那么可以看一下下面这张示意图:

现在大家应该可以想到了,实际上 class 文件中的所有的字节都代表了固定的信息,所以 JVM 只要根据 class 文件的格式就可以知道这个 class 文件中的存放了什么内容了,比如说方法的信息,字段信息等。

3. class文件的重要组成

现在我们已经知道 class 文件的结构,现在来介绍一下 class 文件中一些重要组成部分。

3.1 常量池

常量池就是前面看到的 ClassFile 里的 cp_info 字段。我们先来直观的看一下常量池到底长什么样子:

上面就是 HelloWorld.class 的常量池。常量池的头两个字节表明了常量池中常量项的个数,因为只有两个字节所以常量项是有数量限制的。具体多少个可以自行计算。常量项个数后面紧跟的就是各个常量项了。每个常量项都有一个 1 个字节的 tag 标志位,用于表示这个常量项具体代表的内容,从图中可以看到如果 tag 是 0A 的话就表示这是一个 MethodRef 的常量项,从名字就可以看出来这是一个表示 Method 信息的常量项。

用专业一点的术语描述的话常量池中保存的内容就是字面量和符号引用。字面量就像类似于文本字符串,或者声明为 final 的常量值。符号引用包括 3 类常量类和接口的全限定名,字段名称和描述符,方法名称和描述符。

特别要注意的一点是常量池中的常量项的索引是从 1 开始的,这样做的目的是满足后面其他结构中需要表明不引用任何一个常量项的含义,这个时候就将索引值置为 0。

从前面的描述可以总结出来,所有的常量池项都具有如下通用格式:

cp_info {
   u1 tag;
   u1 info[];
}

常量池中,每个 cp_info 项(也就是常量项)的格式必须相同,它们都以一个表示 cp_info 类型的单字节 tag 项开头。后面 info[] 项的内容由tag的类型所决定。

tag 的类型有如下几种:

一些常见的常量项:

Class Info:

CONSTANT_Class_Info {
    u1 tag;
    u2 name_index;
}
  • tag 的值为 7

  • name_index 指向了常量池中索引为 name_index 的常量项

UTF8 Info:

CONSTANT_UTF8_Info {
    u1 tag;
    u2 length;
    u1 bytes[length];
}
  • tag 的值为 1

  • length 表示这个 UTF8 编码的字符串的字节数

  • bytes[length] 表示 length 长度的具体的字符串数据

注意:因为 class 文件中的方法名,字段名等都是要引用 UTF8 Info 的,但是 UTF8 Info 的数据长度就是2个字节,所以方法名,字段名的长度最大就是65535。

String Info:

CONSTANT_String_INFO {
    u1 tag;
    u2 string_index;
}
  • tag 的值为 8

  • string_index 指向了常量池中索引为送 string_index 的常量项

Field_Ref Info:

CONSTANT_Fieldref_Info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}
  • tag 的值为 9

  • class_index 指向了常量池中索引为 class_index 的常量项,且这个常量项必须为 Class Info 类型

  • name_and_type_index 指向了常量池中索引为 name_and_type_index 的常量项,且这个常量项必须为 Name And Type Info 类型

Method_Ref Info:

CONSTANT_Methodref_Info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}
  • tag 的值为 10

  • class_index 指向了常量池中索引为 class_index 的常量项,且这个常量项必须为 Class Info 类型

  • name_and_type_index 指向了常量池中索引为 name_and_type_index 的常量项,且这个常量项必须为 Name And Type Info 类型

NameAndType Info:

CONSTANT_NameAndType_Info {
    u1 tag;
    u2 name_index;
    u2 descriptor_index;
}
  • tag 的值为 12

  • name_index 指向了常量池中索引为 name_index 的常量项

  • descriptor_index 指向了常量池中索引为 descriptor_index 的常量项

3.2 字段

和之前的常量池一样,因为每个 class 中字段的数量是不确定的,所以字段部分的开头两个字节用于表示当前 class 文件中的字段的个数,紧跟着的才是具体的字段。

先来看一下字段的结构

   Field_Info {
        u2 access_flag;
        u2 name_index;
        u2 descriptor_index;
        u2 attribute_count;
        attribute_info attributes[attribute_count];
    }
  • access_flag 表示该字段的访问修饰符,字段的访问修饰符和类的表示方式相似,但是具体的内容不一样

字段的访问标识       

  • name_index 指向常量池中的 name_index 索引的常量项

  • descriptor_index 指向常量池中的 descriptor_index 索引的常量项

  • attribute_count 表示该字段的属性个数

  • attributes[attribute_count] 表示该字段的具体的属性

注意:这里字段的 descriptor 代表的字段的类型,但是类型不是写代码的时候 int,String 这样整个单词的,它是一些字符的简写,如下:

所以,举个例子如果字段是 String 类型,那么它的 descriptor 就是 Ljava/lang/Object;  如果字段是 int[][],那么它的 descriptor 就是 [[I

字段的属性和下面介绍的方法的属性是一样的,下文统一介绍。

3.3 方法

方法和字段一样,也需要有一个表示方法个数的字段,同时这个字段后面紧跟的就是具体的方法

同样,来看一下方法的结构:

   Method_Info {
        u2 access_flag;
        u2 name_index;
        u2 descriptor_index;
        u2 attribute_count;
        attribute_info attributes[attribute_count]
    }
  • access_flag 的意义和之前field一样,只不过取值不同,method 的access flag 可以取的值如下:

  • name_index 的意义和 field 的也一样,表示了方法的名称

  • descriptor_index 的意义和 field 也一样,只不过其表示方法不同,让我们来看一下它是如何表示的:

method 的 descriptor 由两部分组成,一部分是参数的 descriptor,一部分是返回值的 descriptor,所以 method 的 descriptor 的形式如下:

( ParameterDescriptor* ) ReturnDescriptor

而参数的 descriptor 就是 field 的 descriptor,返回值的descriptor 也是 field 的 descriptor 但是多了一个类型就是 void 类型,其的 descriptor 如下:

VoidDescriptor:V

所以举个例子,如果一个方法的签名是

Object m(int i, double d, Thread t) {..}

那么它的 descriptor 就是

(IDLjava/lang/Thread;)Ljava/lang/Object;
  • attribute_count 的意义和 field 一样表示属性的个数

  • attributes[attribute_count] 和 field 也一样表示具体的属性,属性的个数由 attribute_count 决定

3.4 属性

3.4.1 属性结构

属性这个数据结构可以出现在 class 文件,字段表,方法表中。有些属性是特有的,有些属性是三个共有的。

属性的描述如下:

这里我们就不详细解释每一个属性了,我们来看一个方法表中最重要的属性,即 Code Attribute。为什么说它重要,因为我们的函数的代码就是在 Code Attribute 中(实际上存储的是指令)。其他属性的一些解释可以参考 Oracle 的 JVM 规范中的描述    https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7-300

3.4.2 Code Attribute

首先来看一下 Code Attribute 的结构

Code_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 max_stack;
    u2 max_locals;
    u4 code_length;
    u1 code[code_length];
    u2 exception_table_length;
    {   u2 start_pc;
        u2 end_pc;
        u2 handler_pc;
        u2 catch_type;
    } exception_table[exception_table_length];
    u2 attributes_count;
    attribute_info attributes[attributes_count];
}

可以看到 Code Attribute 属性是非常复杂的,下面我们简单解释一下每个成员的含义:

  • attribute_name_index 指向的常量池中常量项的索引,而且这个常量项的类型必须是 UTF8 Info,值必须是 "Code"

  • attribute_length 表示这个属性的长度,但是不包括开始的 6 个字节

  • max_stack 表示 Code 属性所在的方法在运行时形成的函数栈帧中的操作数栈的最大深度

  • max_locals 表示最大局部变量表的长度

  • code_length表示 Code 属性所在的方法的长度(这个长度是方法代码编译成字节后字节的长度)

  • code[length]表示的就是具体的代码,所以说 java 函数的代码长度是有限制的,编译出来的字节指令的长度只能是 4 个字节所能代表的最大值。所以一个函数的代码不能太长,否者是不能编译的

  • exception_table_length 表示方法会抛出的异常数量

  • exception_table[exception_table_length] 表示具体的异常

  • attributes_count 表示 Code 属性中子属性的长度,之所以说属性复杂就是因为属性中还可以嵌套属性

  • attributes[attributes_count] 代表具体的属性

现在来直观的看一下 Code Attribute 的组成,下面就是 HelloWorld.class   中的 Code Attribute 属性:

3.4.3 Code Attribute的两个子属性

这里额外提一个 Code Attribute 中的两个子属性。不知道大家有没有想过为什么我们用 IDE 运行程序出错时,IDE 可以准确的定位到是哪一行代码出错了? 为什么我们在 IDE 中使用一个方法的时候可以看到这个方法的参数名,并且调试的时候可以根据参数名获取变量值?很关键的原因就在于 Code 属性的这两个子属性。

LineNumberTable

LineNumberTable的结构

LineNumberTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 line_number_table_length;
    {   u2 start_pc;
        u2 line_number;
    } line_number_table[line_number_table_length];
}

我们着重要看的是 line_number_table 这个成员,可以看到这个成员表示的就是 字节码指令和源码的对应关系 ,其中 start_pc 是 Code Attribute 中的 code[] 数组的索引值,line_number 是源文件的行号

LocalVariableTable

LocalVariableTable 的结构

LocalVariableTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 local_variable_table_length;
    {   u2 start_pc;
        u2 length;
        u2 name_index;
        u2 descriptor_index;
        u2 index;
    } local_variable_table[local_variable_table_length];
}

其中最关键的成员大家也可以想到,肯定是 local_variable_table[local_variable_table_length] ,它里面属性的意义如下:

  • start_pc 和 length 表示局部变量的索引范围([start_pc, start_pc + length))

  • name_index 表示变量名在常量池中的索引

  • descriptor_index 表示变量描述符在常量池中的索引

  • index 表示此局部变量在局部变量表中的索引

LocalVariableTable 属性实际上是用于描述栈帧中局部变量表中的变量与 Java 源码中定义的变量之间的关系,所以根据这个属性,其他人引用这个方法时就可以知道这个方法的属性名,并且可以在调试的时候根据参数名称从上下文中获得参数值。

4. 执行引擎

前面讲的是 class 文件的静态结构,当 JVM 解析完 class 文件之后就会将其转成运行时结构,并将其存放在方法区中(也就是常说的永久代),然后会创建类对象(也就是 Class 对象)提供访问类数据的接口。

执行的时候 JVM 总是会先从 main 方法开始执行,其实就是从 Class 的所有方法中找到 main 方法,然后从 main 方法的 Code Attribute 中找到方法体的字节码然后调用执行引擎执行。所以要知道 JVM 是如何执行代码的就要了解一些字节码的内容。

4.1 运行时栈帧结构

先来看一看JVM的运行时结构

因为JVM是一个基于栈的虚拟机,所以基本上所有的操作都是需要通过对栈的操作完成的。执行的过程就是从 main 函数开始(一开始就会为 main 函数创建一个函数栈帧),执行 main 函数的指令(在 Code Attribute 中),如果要调用方法就创建一个新的函数栈帧,如果函数执行完成就弹出第一个函数栈帧。

4.2 JVM的指令

不管你在 java 源文件中写了什么函数,用了什么高深的算法,经过编译器的编译,到了 class 文件中都是一个个的字节,而 Code Attribute 中的code[] 字段中的字节就是函数翻译过来的字节码指令。

JVM 支持的指令大致上可以分成 3 种:没有操作数的、一个操作数的和;两个操作数的。因为 JVM 用一个字节来表示指令,所以指令的最多只有 256 个。

JVM指令通用形式如下:

4.3 几个常用的指令解析

因为 JVM 的指令太多了,在这里不可能全部都解析一遍,所以就选择了几个指令进行解析。

4.3.1 invokespecial

说明:invokespecial 用于调用实例方法,专门用来处理调用超类方法、私有方法和实例初始化方法。

indexByte1 和indexByte2 用于组成常量池中的索引((indexbyte1 << 8)|indexbyte2)。所指向的常量项必须是 MethodRef Info 类型。同时该条指令还会创建一个函数栈帧,然后从当前的操作数栈中出栈被调用的方法的参数,并且将其放到被调用方法的函数栈帧的本地变量表中。

4.3.2 aload_n

说明:aload_n 从局部变量表加载一个 reference 类型值到操作数栈中,至于从当前函数栈帧的本地变量表中加载哪个变量是有N的值决定的。

4.3.3 astore_n

说明:将一个 reference 类型数据保存到局部变量表中,至于保存在局部变量表的哪个位置就由 N 的值决定。

好了,指令就介绍到这里,要看所有指令的说明可以看 Oracle 的 JVM 指令集,里面有对每一个指令的详细说明。

所以执行引擎要做的工作就是根据每一个指令要执行的功能进行对应的实现。

5. 总结

因为 JVM 的内容太过于丰富,这里只分析了 JVM 执行的主要的流程,还有些内容比如:类加载,类的链接(验证,准备,解析),初始化等过程没有说明。不是说这些内容不重要而是我们平时写代码的时候可以更加关注上面所介绍的一些内容。这里我也针对上面的内容写了一个可以运行的例子, 可以在这里  https://github.com/thlcly/Mini-JVM 找到。

6. 参考

  1. The Java® Virtual Machine Specification

  2. 深入理解Java虚拟机:JVM 高级特性与最佳实践(第2版)

  3. 深入java虚拟机第二版

全文完

以下文章您可能也会感兴趣:

杏仁技术站

长按左侧二维码关注我们,这里有一群热血青年期待着与您相会。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章