Class 文件、字段表、方法表都可以携带自己的 属性表(attribute_info)集合,用于描述某些场景专有的信息数据。为了能正确解析 Class 文件,在最新的《Java 虚拟机规范》的 Java SE 12 版本中,预定义属性已经增加到了 29 项,其中部分属性的具体信息见下表:

属性名称 使用位置 含义
Code 方法表 Java 代码编译成的字节码指令
LineNumberTable Code 属性 Java 源码的行号与字节码指令的对应关系
Exceptions 方法表 方法抛出的异常列表
LocalVariableTable Code 属性 方法的局部变量描述
LocalVariableTypeTable JDK 5 中新增的属性,它使用特征签名代替描述符,是为了引入范型语法之后能描述范型参数化类型而添加
SourceFile
ConstantValue 字段表 由 final 关键字定义的常量值
StackMapTable Code 属性 JDK 6 中新增的属性,供新的类型检查验证器(Type Checker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配
Signature 类、方法表、字段表 JDK 5 中新增的属性,用于支持范型情况下的方法签名。由于 Java 的范型采用擦除法实现,为避免类型信息被擦除后导致签名混乱,需要这个属性记录范型中的相关信息
MethodParameters 方法表 JDK 8 中新增的属性,用于支持将方法名称编译进 Class 文件中,并可运行时通过反射 API 获取
……

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

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length

Code 属性

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

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 max_stack 1
u2 max_locals 1
u4 code_length 1
u1 code code_length
u2 exception_table_length 1
exception_info exception_table exception_table_length
u2 attributes_count 1
attribute_info attributes attributes_count

attribute_name_index 是一项指向 CONSTANT_Utf8_info 型常量的索引,此常量值固定为“Code”,它代表了该属性的属性名称,attribute_length 指示了属性值的长度,它的值为 0x0000001D,即十进制的 29。
image.png

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

max_locals 代表了局部变量表所需的存储空间。在这里 max_locals 的单位是 变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位。对于 byte、char、float、int、short、boolean 和 returnAddress 等长度不超过 32 位的数据类型,每个局部变量占用一个变量槽,而 double 和 long 这两种 64 位的数据类型则需要两个变量槽来存放。方法参数、方法体中定义的局部变量都需要依赖局部变量表来存放。

注意,并不是在方法中用了多少个局部变量,就把这些局部变量所占变量槽数量之和作为 max_locals 的值,操作数栈和局部变量表直接决定一个该方法的栈帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。Java 虚拟机的做法是将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量槽可以被其他局部变量所使用,Javac 编译器会根据变量的作用域来分配变量槽给各个变量使用,根据同时生存的最大局部变量数量和类型计算出 max_locals 的大小。
image.png

1. 字节码指令

code_length 和 code 用来存储 Java 源程序编译后生成的字节码指令。code_length 代表字节码长度,code 用于存储字节码指令的一系列字节流。当虚拟机读取到 code 中的一个字节码时,就可以对应找出这个字节码所代表指令,并且可以知道这条指令后面是否需要跟随参数以及后续的参数应当如何解析。一个 u1 数据类型的取值范围为 0x00~0xFF,对应十进制的 0~255,也就是一共可以表达 256 条指令,截止目前 《Java 虚拟机规范》已经定义了其中约 200 条编码值所对应的指令含义。

如图可以看到,code_length 的值为 0x00000005,虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随其后的 5 个字节,并根据字节码指令表翻译出所对应的字节码指令。
image.png
翻译 0x2AB70001B1 的过程为:

  • 读入 2A,查表得 0x2A 对应的指令为 aload_0,这个指令的含义是将第 0 个变量槽中为 reference 类型的本地变量推送到操作数栈顶。

  • 读入 B7,查表得 0xB7 对应的指令为 invokespecial,这条指令的作用是以栈顶的 reference 类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private 方法或者它的父类的方法。这个方法有一个 u2 类型的参数说明具体调用哪一个方法,它指向常量池中的一个 CONSTANT_Methodref_info 类型常量,即此方法的符号引用。

  • 读入 0001,这是 invokespecial 指令的参数,代表一个符号引用,查常量池得 0x0001 对应的常量为实例构造器 () 方法的符号引用。

image.png

  • 读入 B1,查表得 0xB1 对应的指令为 return,含义是从方法的返回,并且返回值为 void。这条指令执行后,当前方法正常结束。

我们再次使用 javap 命令把示例代码的 Class 文件中的字节码指令也计算出来:
image.png
从图中可知,我们的分析结果是正确的。但我们注意到 () 和 inc() 方法都是没有参数的,args_size 为什么为 1 呢?而且参数列表和方法体内都没有定义任何局部变量,Locals 又为什么为 1 呢?

实际上在任何实例方法里,都可以通过 this 关键字访问到此方法所属的对象。这个访问机制对 Java 程序的编写很重要,而它的实现很简单,仅仅是通过在 Javac 编译器编译的时候把对 this 关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数值从 1 开始计算。

2. 显示异常处理表集合

在字节码指令之后的是这个方法的 显式异常处理表集合,异常表(Exception Table)对于 Code 属性来说并不是必须存在的,我们的示例代码中就没有异常表生成,可以看到 exception_table_length 的值为 0x0000。
image.png
如果存在异常表,那它的格式应如下表所示:

类型 名称 数量 类型 名称 数量
u2 start_pc 1 u2 handler_pc 1
u2 end_pc 1 u2 catch_type 1

如果当字节码从第 start_pc 到第 end_pc 之间出现了类型为 catch_type 或者其子类的异常(catch_type 为指向一个 CONSTANT_Class_info 型常量的索引),则转到第 handler_pc 行继续处理。当 catch_type 的值为 0 时,代表任意异常情况都需要转到 handler_pc 处进行处理。

异常处理流程:

在程序正常执行时,finally 代码块会在 try 代码块之后运行。当 try 代码块触发异常时,如果该异常没有被 catch 代码块捕获,则 finally 代码块会直接运行,并在运行之后重新抛出该异常。如果该异常被 catch 代码块捕获,则 finally 代码块在 catch 代码块之后运行。

如果不幸,catch 代码块也触发了异常,则 finally 代码块同样会运行,并会抛出 catch 代码块触发的异常。在某些极端不幸情况下,finally 代码块也触发了异常,那么只好中断当前 finally 代码块的执行并往外抛异常。

代码示例:

我们通过一个代码示例及编译后的字节码来演示异常表是如何运作的:

  1. public int inc() {
  2. int x;
  3. try {
  4. x = 1;
  5. return x;
  6. } catch (Exception e) {
  7. x = 2;
  8. return x;
  9. } finally {
  10. x = 3;
  11. }
  12. }

image.png
Javac 编译器为这段 Java 源码生成了四条异常表记录,其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。这四条记录对应四条可能出现的代码执行路径:

  • 如果 try 语句块中出现属于 Exception 或其子类的异常,转到 catch 语句块处理
  • 如果 try 语句块中出现不属于 Exception 或其子类的异常,转到 finally 语句块处理
  • 如果 catch 语句块中出现任何异常,转到 finally 语句块处理。
  • 如果 finally 语句块中出现任何异常,方法非正常退出。

异常表(Exception Table)执行流程:

当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。

如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧并且在调用者中重复上述操作。finally 代码块的编译比较复杂,Java 编译器会复制 finally 代码块中的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中,所以 finally 无论如何都会被执行。

如果 finally 中有 return 语句,则 catch 内 throw 的异常会被忽略,从 jvm 层面怎么解释?

catch 里抛的异常会被 finally 捕获了,再执行完 finally 代码后重新抛出该异常。由于 finally 代码块中有个 return 语句,因此在重新抛出前就返回了。

image.png
针对异常执行路径,Java 编译器会生成一个或多个异常表条目,监控整个 try-catch 代码块,并且捕获所有种类的异常(在 javap 中以 any 指代)。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块。并且在这个 finally 代码块的最后,Java 编译器会重新抛出所捕获的异常。

LineNumberTable 属性

LineNumberTable 属性用于描述 Java 源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到 Class 文件之中,可以在 Javac 编译时使用 -g:none 或 -g:lines 选项来取消或要求生成这项信息。如果选择不生成 LineNumberTable 属性,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试时也无法按照源码行来设置断点。

LineNumberTable 属性的结构

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 line_number_table_length 1
line_number_info line_number_table line_number_table_length
  • line_number_info 表包含 start_pcline_number 两个 u2 类型的数据项,前者是字节码行号,后者是对应的 Java 源码行号。

继续分析我们的示例代码,从图中可以看到我们的方法中包含了 LineNumberTable 属性:
image.png
对应我们的字节码就是(接着上面 Code 属性中的 attribute 属性表):
image.png

  • 0x0001:表示接着上面 Code 属性中的 attributes_count,表示 Code 属性中有一个额外属性。
  • 0x000A:即属性名称索引(attribute_name_index)指向常量池中的第十项常量。

image.png

  • 0x00000006:即属性表长度(attribute_length)为 6。
  • 0x0001:即 line_number_table_length 为 1,且属性表总长为 6,正好还剩下 4 字节存储一个 line_number_info 结构。
  • 0x0000:即 line_number_info 结构中的 start_pc 值为 0。
  • 0x0001:即 line_number_info 结构中的 line_number 值为 1。

    Exceptions 属性

    这里的 Exceptions 属性是在方法表中与 Code 属性平级的一项属性,不要与上面的异常表混淆。Exceptions 属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在 throws 关键字后面列举的异常。
类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 number_of_exceptions 1
u2 exception_index_table number_of_exceptions

此属性中的 number_of_exceptions 项表示方法可能抛出多少种受查异常,每一种受查异常使用一个 exception_index_table 项表示;exception_index_table 是一个指向常量池中 CONSTANT_Class_info 型常量的索引,代表了该受查异常的类型。
image.png

LocalVariableTable 属性

LocalVariableTable 属性用于描述栈帧中局部变量表的变量与 Java 源码中定义的变量之间的关系,它不是运行时必需的属性,但默认会生成到 Class 文件中,可以在 Javac 编译时使用 -g:none 或 -g:vars 选项来取消或要求生成这项信息。

如果没有生成这项属性,当其他人引用这个方法时,所有的参数名称都将会丢失,譬如 IDE 将会使用诸如 arg0、arg1 之类的占位符代替原有的参数名,虽然对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 local_variable_table_length 1
local_variable_info local_variable_table local_variable_table_length

其中 local_variable_info 项目代表了一个栈帧与源码中的局部变量的关联,结构如下表所示:

类型 名称 数量
u2 start_pc 1
u2 length 1
u2 name_index 1
u2 descriptor_index 1
u2 index 1
  • start_pc:这个局部变量的生命周期开始的字节码偏移量
  • length:该局部变量作用范围覆盖的长度,结合 start_pc 就是该局部变量在字节码中的作用域范围。
  • name_index:局部变量的名称
  • descriptor_index:局部变量的描述符。
  • index 是这个局部变量在栈帧的局部变量表中变量槽的位置。当这个变量数据类型是 64 位类型时(double 和 long),它占用的变量槽为 index 和 index+1 两个。

在 JDK 5 引入泛型之后,LocalVariableTable 属性增加了一个姐妹属性——LocalVariableTypeTable。这个新增的属性结构与 LocalVariableTable 非常相似,仅仅是把记录的字段描述符的 descriptor_index 替换成了字段的特征签名(Signature),使用字段的特征签名来完成泛型的描述。

代码示例:

  1. public int inc(int first, int second) throws RuntimeException {
  2. return m + first + second;
  3. }

image.png

SourceFile 属性

SourceFile 属性用于记录生成这个 Class 文件的源码文件名称。这个属性也是可选的,可以使用 Javac 的 -g:none 或 -g:source 选项来关闭或要求生成这项信息。在 Java 中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名。这个属性是一个定长的属性,其结构如下表所示:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 sourcefile_index 1

sourcefile_index 指向常量池中 CONSTANT_Uft8_info 型常量的索引,常量值是源码文件的文件名。继续分析示例代码:
image.png
从图中可以看到,attribute_name_index 的值为 0x000D,即十进制的 13;sourcefile_index 的值为 0x000E,即十进制的 14,对应的常量池项为:
image.png

ConstantValue 属性

ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值。只有被 static 关键字修饰的变量才可以使用这项属性。虚拟机对非 static 类型的变量的赋值是在实例构造器 () 方法中进行的;而对于类变量则有两种方式可以选择:在类构造器 () 方法中或者使用 ConstantValue 属性。

目前 Oracle 公司实现的 Javac 编译器的选择是,如果同时使用 final 和 static 来修饰一个变量,并且这个变量的数据类型是基本类型或者 java.lang.String 的话,就将会生成 ConstantValue 属性来进行初始化;如果这个变量没有被 final 修饰或者并非基本类型及字符串,则将会选择在 () 方法中进行初始化。

ConstantValue 属性的结构

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 constantvalue_index 1

StackMapTable 属性

StackMapTable 属性在 JDK 6 增加到 Class 文件规范之中,它是一个相当复杂的变长属性,位于 Code 属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用,目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。

新的验证器在同样能保证 Class 文件合法性的前提下,省略了在运行期通过数据流分析去确认字节码的行为逻辑合法性的步骤,而在编译阶段将一系列的验证类型(Verification Type)直接记录在 Class 文件之中,通过检查这些验证类型代替了类型推导过程,从而大幅提升了字节码验证的性能。这个验证器在 JDK 6 中首次提供,并在 JDK 7 中强制代替原本基于类型推断的字节码验证器。

StackMapTable 属性中包含零至多个栈映射帧(Stack Map Frame),每个栈映射帧都显式或隐式地代表了一个字节码偏移量,用于表示执行到该字节码时局部变量表和操作数栈的验证类型。类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束。

StackMapTable 属性的结构

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 number_of_entries 1
stack_map_frame stack_map_frame_entries number_of_entries

Signature 属性

Signature 属性在 JDK 5 增加到 Class 文件规范之中,它是一个可选的定长属性,可以出现于类、字段表和方法表结构的属性表中。在 JDK 5 里面大幅增强了 Java 语言的语法,此后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variable)或参数化类型(Parameterized Type),则 Signature 属性会为它记录泛型签名信息。

之所以要专门使用该属性去记录泛型类型,是因为 Java 语言的泛型采用的是擦除法实现的伪泛型。使用擦除法的好处是实现简单,运行期也能够节省一些类型所占的内存空间。但坏处是运行期就无法像 C# 等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得泛型信息。

Signature 属性就是为了弥补这个缺陷而增设的,现在 Java 的反射 API 能够获取的泛型类型,最终的数据来源也是这个属性。

MethodParameters 属性

MethodParameters 是在 JDK 8 时新加入到 Class 文件格式中的,它是一个用在方法表中的变长属性。作用是记录方法的各个形参名称和信息。

最初,基于存储空间的考虑,Class 文件默认是不储存方法参数名称的,因为给参数起什么名字对计算机执行程序来说是没有任何区别的,所以只要在源码中妥当命名就可以了。随着 Java 的流行,这点确实为程序的传播和二次复用带来了诸多不便,由于 Class 文件中没有参数的名称,如果只有单独的程序包而不附加上 JavaDoc 的话,在 IDE 中编辑使用包里面的方法时是无法获得方法调用的智能提示的,这就阻碍了 JAR 包的传播。

后来 -g:var 就成为了 Javac 以及许多 IDE 编译 Class 时采用的默认值,这样会将方法参数的名称生成到 LocalVariableTable 属性之中。但是 LocalVariableTable 属性是 Code 属性的子属性,而抽象方法和接口方法是没有方法体的,自然就不会有局部变量表。所以在 JDK 8 中新增的这个属性,使得编译器可以(编译时加上 -parameters 参数)将方法名称也写进 Class 文件中,而且 MethodParameters 是方法表的属性,与 Code 属性平级的,可以运行时通过反射 API 获取。

MethodParameters 属性的结构

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 parameters 1
parameter parameters parameters_count

parameter 属性的结构

类型 名称 数量
u2 name_index 1
u2 access_flags 1
  • name_index 是一个指向常量池 CONSTANT_Utf8_info 常量的索引值,代表了该参数的名称
  • access_flags 是参数的状态指示器,它可以包含以下三种状态中的一种或多种:
    • 0x0010(ACC_FINAL)表示该参数被 final 修饰。
    • 0x1000(ACC_SYNTHETIC)表示该参数并未出现在源文件中,是编译器自动生成的。
    • 0x8000(ACC_MANDATED)表示该参数是在源文件中隐式定义的。如 this 关键字。

代码示例:

  1. public interface InterfaceTest {
  2. void test(String first, Integer second);
  3. }

image.png