JVM学习-Class文件结构

文章原文:https://gaoyubo.cn/blogs/844dc0e7.html

一、Class类文件的结构

任何一个Class文件都对应着唯一的一个类或接口的定义信息。

但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有任何分隔符。

Java 虚拟机规范规定 Class 文件采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数

  • 无符号数: 无符号数属于基本数据类型,以 u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,可以用它来描述数字、索引引用、数量值或 utf-8 编码的字符串值。
  • 表: 表是由多个无符号数或其他表为数据项构成的复合数据类型,名称上都以 _info 结尾。

整个Class文件本质上也可以视作是一张表,这张表由数据项按严格顺序排列构成

英文名称 中文名称 类型 数量
magic 魔数 u4 1
minor_version 次版本号 u2 1
major_version 主版本号 u2 1
constant_pool_count 常量池计数 u2 1
constant_pool 常量池 cp_info constant_pool_count - 1
access_flags 访问标志 u2 1
this_class 类索引 u2 1
super_class 父类索引 u2 1
interfaces_count 接口计数 u2 1
interfaces 接口索引集合 u2 interfaces_count
fields_count 字段计数 u2 1
fields 字段表集合 field_info fields_count
methods_count 方法计数 u2 1
methods 方法表集合 method_info methods_count
attributes_count 属性计数 u2 1
attributes 属性集合 attribute_info attributes_count

其中,cp_infofield_infomethod_infoattribute_info 是更具体的结构,包含了常量池项、字段信息、方法信息和属性信息的详细描述。

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

合"。

示例

java 复制代码
package algorithmAnalysis;

public class JVMTest {
    private int m;
    public int inc(){
        return m+1;
    }
    public static void main(String[] args) {
        System.out.println("gaoyubo");
    }
}

1.1魔数与版本号

Class 文件的头 8 个字节是魔数和版本号,其中头 4 个字节是魔数,也就是 0xCAFEBABE,它可以用来确定这个文件是否为一个能被虚拟机接受的 Class 文件(这通过扩展名来识别文件类型要安全,毕竟扩展名是可以随便修改的)。

后 4 个字节则是当前 Class 文件的版本号,其中第 5、6 个字节是次版本号,第 7、8 个字节是主版本号。

1.2常量池

从第 9 个字节开始,就是常量池的入口,常量池是 Class 文件中:

  • 与其他项目关联最多的的数据类型;
  • 占用 Class 文件空间最大的数据项目;
  • Class 文件中第一个出现的表类型数据项目。

常量池的前两个字节,即第 9、10 个字节,存放着一个 u2 类型的数据,用于表示常量池中的常量数量 cpc(constant_pool_count)。

这个计数值有一个特殊之处,即它是从 1 开始而不是从 0 开始的

举例而言,如果 cpc = 22,那么说明常量池中包含 21 个常量,它们的索引值为 1 到 21。

第 0 项常量被保留为空,以便在某些情况下表示"不引用任何常量池项目",此时将索引值设为 0 即可。

常量池中记录主要包括以下两大类常量:

  • 字面量: 接近于 Java 语言层面的常量概念
    • 文本字符串
    • 声明为 final 的常量值
  • 符号引用 :以一组符号来描述所引用的目标
    • 被模块导出或开放的包(Package)
    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符
    • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
    • 动态调用点动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
常量类型 标志 描述
CONSTANT_Utf8_info 1 UTF-8 编码的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类或接口方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的名称和描述符
CONSTANT_MethodHandle_info 15 方法句柄
CONSTANT_MethodType_info 16 方法类型
CONSTANT_Dynamic_info 17 动态计数常量
CONSTANT_InvokeDynamic_info 18 动态方法调用点
CONSTANT_Module_info 19 模块信息
CONSTANT_Package_info 20 包信息

CONSTANT_Class_info

java 复制代码
... [ tag=7 ] [ name_index ] ...
... [  1位  ] [     2位    ] ...
  • tag 是标志位,用来区分常量类型的,tag = 7 就表示接下来的这个表是一个 CONSTANT_Class_info。
  • name_index 是一个索引值,指向常量池中的一个 CONSTANT_Utf8_info 类型的常量所在的索引值,CONSTANT_Utf8_info 类型常量一般被用来描述类的全限定名、方法名和字段名。它的存储结构如下:
java 复制代码
... [ tag=1 ] [ 当前常量的长度 len ] [ 常量的符号引用的字符串值 ] ...
... [  1位  ] [        2位        ] [         len位         ] ...

CONSTANT_Fieldref_info

类型 名称 数量
ul tag 1
u2 class_index 1
u2 name_and_type_index 1
  • tag: 表示标签,值为CONSTANT_Fieldref(9)。
  • class_index: 是一个指向CONSTANT_Class_info表的索引,该表中存储了字段所属的类或接口。
  • name_and_type_index: 是一个指向CONSTANT_NameAndType_info表的索引,该表中存储了字段的名称和描述符。

CONSTANT_Method ref_into

以下是对固定长度的CONSTANT_Methodref_info表使用符号引用来表示类中声明的方法(不包括接口中的方法)进行优化和润色后的描述:固定长度的CONSTANT_Methodref_info表使用符号引用来表示类中声明的方法(不包括接口中的方法)。

类型 名称 数量
ul tag 1
u2 class_index 1
u2 name_and_type_index 1
  • tag(标签):tag项的值为CONSTANT_Methodref (10)。

  • class_index(类索引):class_index项给出了声明了被引用方法的类的CONSTANT_Class_info表的索引。class_index所指定的CONSTANT_Class_info表必须表示一个类,而不能是接口。指向接口中声明的方法的符号引用应使用CONSTANT_InterfaceMethodref表。

  • name_and_type_index(名称和类型索引):name_and_type_index提供了CONSTANT_NameAndType_info表的索引,该表提供了方法的简单名称和描述符。如果方法的简单名称以"<"(\u003c)符号开头,则该方法必须是一个实例化方法。它的简单名称应为"",并且返回类型必须为void。否则,该方法应该是一个常规方法。

CONSTANT_String_info

尚定长度的CONSTANT_String_info表用于存储文字字符串值,这些值可以表示为java.lang.String类的实例。该表仅存储文字字符串值,不存储符号引用。

类型 名称 数量
ul tag 1
u2 string_index 1
  • tag: 表示标签,值为CONSTANT_String(8)。
  • string_index: 是一个指向CONSTANT_Utf8_info表的索引,该表中存储了实际的字符串值。通过使用这样的表形式,可以方便地存储和引用字符串值,保证了程序的灵活性和可读性。

如果全部介绍,篇幅太长,这里使用IDEA的jclasslib插件,查看效果如下:

常量表中常量项定义如下:

1.3访问标志

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:

这个Class是类还是接口?

  • 接口:
    • 是否定义为public类型;
    • 是否定义为abstract类型;
  • 类:
    • 是否被声明为final;

以下为访问标志定义:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 类或接口是公共的
ACC_FINAL 0x0010 类不能被继承;方法不能被重写
ACC_SUPER 0x0020 当用 invokespecial 指令调用超类构造方法时,要求对该方法的调用使用 super 关键字
ACC_INTERFACE 0x0200 标记接口
ACC_ABSTRACT 0x0400 类没有实现所有的接口方法
ACC_SYNTHETIC 0x1000 标记为由编译器生成的类或方法
ACC_ANNOTATION 0x2000 标记为注解类型
ACC_ENUM 0x4000 标记为枚举类型
ACC_MODULE 0x8000 标记为模块

访问标识通常是通过按位或运算符(|)进行计算的。每个访问标识都对应一个二进制位,通过将需要的标识的二进制位进行按位或运算,可以组合多个标识。

上文的JVMTest.java:它的访问标识应该是 ACC_PUBLICACC_SUPER。以下是分析:

  1. ACC_PUBLIC(0x0001): 这个标志表示类是公共的,可以从其他包访问。
  2. ACC_SUPER(0x0020): 在 Java 5 之前,这个标志是为了向后兼容,当使用 invokespecial 指令调用超类构造方法时,要求对该方法的调用使用 super 关键字。

因此,JVMTest 类的访问标识应该是 ACC_PUBLIC | ACC_SUPER,即 0x0021。

1.4类索引、父类索引与接口索引

类索引(this_class)和父类索引(super_class)

  • 类型:
    • this_classsuper_class 都是 u2 类型的数据。
  • 作用:
    • this_class 用于确定这个类的全限定名。
    • super_class 用于确定这个类的父类的全限定名。
  • 继承关系:
    • 由于 Java 不允许多重继承,父类索引只有一个。
    • 除了 java.lang.Object 之外,所有 Java 类都有父类。
    • 所以,除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。
  • 索引值:
    • 类索引(this_class)和父类索引(super_class)分别用两个 u2 类型的索引值表示。
    • 这两个索引值分别指向一个类型为 CONSTANT_Class_info 的类描述符常量。
  • 全限定名查找:
    • 通过 CONSTANT_Class_info 类型的常量中的索引值,可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。

接口索引集合(interfaces)

  • 类型:
    • interfaces 是一组 u2 类型的数据的集合。
  • 作用:
    • 用于描述这个类实现了哪些接口。
  • 排列顺序:
    • 接口索引集合中的接口将按 implements 关键字后的接口顺序从左到右排列。
  • 注意事项:
    • 如果这个 Class 文件表示的是一个接口,则应当使用 extends 关键字。

通过这三项数据,可以建立起类的继承关系和接口实现关系,确定类的层次结构和实现的接口,如下为全限定名索引查找过程。

class文件中示例

访问标志后面紧跟类索引、父类索引、接口索引,JVMTest.class中表示如下,这里类索引u2值为0x0005,父类索引u2值为0x0006:

使用jclasslib查看u2值对应常量如下,可以看出JVMTest类的父类为Object类:

1.5字段表集合

  • 描述:
    • field_info 用于描述接口或类中声明的字段(变量)。
    • 字段包括类级变量和实例级变量,但不包括在方法内部声明的局部变量。
  • 字段信息包含的修饰符:
    • 作用域修饰符: 可以是 publicprivateprotected
    • 变量类型修饰符: 区分实例变量和类变量,使用 static 修饰符。
    • 可变性修饰符: 使用 final 修饰符。
    • 并发可见性修饰符: 使用 volatile 修饰符,表示是否强制从主内存读写。
    • 序列化修饰符: 使用 transient 修饰符,表示是否可被序列化。
  • 字段数据类型:
    • 包括基本类型、对象和数组等。
    • 数据类型不固定,通过引用常量池中的常量来描述。
  • 字段名称:
    • 字段名称不固定,通过引用常量池中的常量来描述。
  • 修饰符的表示:
    • 修饰符都是布尔值,要么存在某个修饰符,要么不存在。
    • 使用标志位来表示修饰符的存在与否,以便紧凑地表示多个修饰符。

通过 field_info,可以详细描述字段的各种属性和特征,为 Java 类或接口的字段提供了灵活而精确的定义。

因此字段表结构定义如下:

名称 类型 描述 数量
access_flags u2 访问标志 1
name_index u2 字段名索引 1
descriptor_index u2 描述符索引 1
attributes_count u2 属性计数 1
attributes attribute_info 属性集合 attributes_count

字段表访问标志(access_flags)

其中,access_flags字段访问标志定义如下:

名称 标志值 描述
ACC_PUBLIC 0x0001 公共访问标志
ACC_PRIVATE 0x0002 私有访问标志
ACC_PROTECTED 0x0004 受保护访问标志
ACC_STATIC 0x0008 静态字段标志
ACC_FINAL 0x0010 常量字段标志
ACC_VOLATILE 0x0040 可变字段标志(并发可见性)
ACC_TRANSIENT 0x0080 短暂字段标志(不可序列化)
ACC_SYNTHETIC 0x1000 由编译器自动产生的标志
ACC_ENUM 0x4000 枚举类型字段标志

简单描述和描述符(name_index和descriptor_index)

跟随access_flags标志的是两项索引值:name_index和descriptor_index。

  • 这两个索引值紧随 access_flags 标志之后,分别引用常量池中的项。
  • name_index 代表字段的简单名称,指向常量池中的字符串项。
  • descriptor_index 代表字段和方法的描述符,同样指向常量池中的字符串项。
  • 全限定名: 类似于 org/fenixsoft/clazz/TestClass,是类的完整名称,将包名中的 . 替换为 /。为了在使用时避免混淆,通常在最后加入一个分号 ; 表示全限定名结束。

  • 简单名称: 指没有类型和参数修饰的方法或字段名称。例如,incminc() 方法和 m 字段的简单名称。

  • 描述符:

    • 描述符用于描述字段的数据类型、方法的参数列表(包括数量、类型和顺序)以及返回值。
    • 基本数据类型(bytechardoublefloatintlongshortboolean)以及代表无返回值的 void 类型都用一个大写字符表示。
    • 对象类型则用字符 L 加对象的全限定名表示。

如下为描述符的定义

标识字符 含义
B byte
C char
D double
F float
I int
J long
S short
Z boolean
V void
L 对象类型(类或接口),如Ljava/lang/Object
[ 数组类型,可以嵌套,java.lang.String[][]类型的二维数组将被记录成[[Ljava/lang/String 一个整型数组int[]将被记录成[I

方法描述符按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号 () 之内。

  • 无参数、无返回值的方法(如 void inc()):

    • 描述符为 ()V
  • 有返回值的方法(如 java.lang.String toString()):

    • 描述符为 ()Ljava/lang/String;
    • 参数列表为空,返回值为对象类型(Ljava/lang/String;)。
  • 有多个参数和返回值的方法(如 int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)):

    • 描述符为 ([CII[CIII)I
    • 参数列表:
      • ([C:char 数组类型
      • II:两个 int 类型
      • [C:另一个 char 数组类型
      • III:三个 int 类型
    • 返回值:I 表示 int 类型。

属性表

字段表中的固定数据项一直到 descriptor_index 为止,而在 descriptor_index 之后,跟随着一个属性表集合。这个属性表集合用于存储一些额外的信息,允许字段表附加描述零至多项的额外信息。

  1. 属性表计数器:
    • 用于记录附加到字段上的属性个数。
    • 计数器的值决定了接下来有多少个属性项。
  2. 属性表中可能的额外信息:
    • ConstantValue 属性:
      • 如果字段被声明为 final static int m = 123;,则可能存在一项名称为 ConstantValue 的属性。
      • 这个属性的值指向常量 123。
  3. 其他属性项:
    • 根据字段的具体声明,可能存在其他类型的属性,如访问控制等。

通过属性表集合,字段表可以携带额外的信息,例如常量值、访问控制等,以满足不同字段的需求。在本例中,由于字段 m 的声明为 final static int m = 123;,因此可能包含 ConstantValue 属性,指向常量 123。

字段表集合的特性

  1. 不包含从父类或父接口中继承的字段:
    • 字段表集合中不会列出从父类或者父接口中继承而来的字段。
    • 继承的字段在子类的字段表中不会重复出现,因为已经在父类的字段表中定义。
  2. 可能包含编译器生成的字段:
    • 在某些情况下,编译器会自动添加一些字段,例如在内部类中为了保持对外部类的访问性,可能会自动添加指向外部类实例的字段。
  3. 字段重名的合法性:
    • 在 Java 语言中,字段是无法重载的,即两个字段的数据类型、修饰符不管是否相同,都必须使用不同的名称。
    • 但在 Class 文件格式中,只要两个字段的描述符不是完全相同,字段重名是合法的。描述符不同即使字段名称相同也是合法的。

class文件中示例

在class文件中,表示如下,按照顺序分别是fields_count,access_flags,name_index,descriptor_index:

0x0001:说明这个类只有一个字段表数据

0x0002:代表private修饰符的ACC_PRIVATE 标志位为真(ACC_PRIVATE标志的值为0x0002)

0x0008:字面量为m,在常量池中对应内容如下图

0x0009:字面量I,在常量池中对应内容如下图
与类访问标志相同,字段访问标志计算字段访问标志的值也是通过按位或(|)操作将各个标志的值组合而成的。

例如,如果一个字段是 publicstatic 的,那么其访问标志的值为 ACC_PUBLIC | ACC_STATIC

如果有两个字段,那么这个顺序就会重复两次,依次表示两个字段的描述信息。

1.6方法表集合

Class文件存储 格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样。

依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项

因此方法表表结构定义如下:

名称 类型 描述 数量
access_flags u2 访问标志 1
name_index u2 方法名索引 1
descriptor_index u2 描述符索引 1
attributes_count u2 属性计数 1
attributes attribute_info 属性集合 attributes_count

方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚,但方法内部的Java代码去哪里了?

方法内的Java代码在经过Javac编译器编译成字节码指令后,实际上存放在方法属性表集合中的一个名为"Code"的属性里面。属性表作为Class文件格式中最具扩展性的一种数据项目,将在后续介绍。

方法表的访问标志

方法表的访问标志中不包含 ACC_VOLATILEACC_TRANSIENT 标志,因为 volatiletransient 关键字不能修饰方法。

相反,方法表的访问标志中增加了以下标志,因为这些关键字可以修饰方法:

  • ACC_SYNCHRONIZED:用于修饰同步方法,表示该方法是同步方法。
  • ACC_NATIVE:表示该方法用其他语言(如 C)实现,由本地方法库提供。
  • ACC_STRICTFP:表示该方法遵循 IEEE 754 浮点运算规范。
  • ACC_ABSTRACT:表示该方法是抽象方法,没有具体的实现。

以下是方法表的访问标志及其取值:

标志名称 标志值 描述
ACC_PUBLIC 0x0001 公共访问标志
ACC_PRIVATE 0x0002 私有访问标志
ACC_PROTECTED 0x0004 受保护访问标志
ACC_STATIC 0x0008 静态方法标志
ACC_FINAL 0x0010 常量方法标志
ACC_SYNCHRONIZED 0x0020 同步方法标志
ACC_BRIDGE 0x0040 桥接方法标志
ACC_VARARGS 0x0080 可变参数方法标志
ACC_NATIVE 0x0100 本地方法标志
ACC_ABSTRACT 0x0400 抽象方法标志
ACC_STRICTFP 0x0800 严格浮点标志
ACC_SYNTHETIC 0x1000 由编译器自动生成的标志

class文件中示例

按照顺序分别为:method_count,access_flags,name_index,descriptor_index,attributes_count,attribute_name_index

0x0003(method_count):说明这个类有三个方法,编译器自动添加了<init>方法,即实例构造器,如下:

0x0001(access_flags):只有ACC_PUBLIC标志为真

0x000A(name_index):字面量索引位10:字面量为<init>

0x000B(descriptor_index):字面量索引位11,字面量()V,代表void返回类型,参数列表为空

0x0001(attributes_count):表示此方法的属性表集合有1项属性

0x000C(attribute_name_index):属性名称的索引值为0x000C,对应常量为"Code"

字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出 现来自父类的方法信息。

但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器<clinit>()方法和实例构造器<init>()方法

在Java语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名

(Java代码的方法特征签名只包括方法名称、参数顺序及参数类型,而字节码的特征签名还包括方法返回值以及受查异常表)。

由于返回值不包含在特征签名中,因此无法仅仅通过返回值的不同来对一个已有方法进行重载,如下图。

然而,在Class文件格式中,特征签名的范围明显更大。只要两个方法的描述符不完全相同,它们就可以在同一个Class文件中合法共存。具体来说,如果两个方法具有相同的名称和特征签名,但返回值不同,它们仍然可以在同一个Class文件中存在。

1.7属性表集合

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

在《Java虚拟机规范》的Java SE 12版本中,预定义属性已经增加到29项,如下:

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

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

Code属性

在Java程序中,方法体内的代码在经过Javac编译器处理之后,最终被转化为字节码指令,并存储在方法表的属性集合中的Code属性内。需要注意的是,并非所有的方法表都必须包含Code属性。例如,在接口或抽象类中的方法就不存在Code属性。

属性名称 类型 描述 数量
attribute_name_index u2 指向UTF-8常量的索引,表示属性名称(Code) 1
max_stack u2 操作数栈的最大深度 1
max_locals u2 局部变量表的最大容量 1
code_length u4 字节码指令的长度 1
code u1[code_length] 存储实际字节码指令的数组 code_length
exception_table_length u2 异常处理表的长度 1
exception_table exception_info 异常处理表 0或多
attributes_count u2 Code属性的属性数量 1
attributes attribute_info[attributes_count] Code属性的属性集合 0或多
  • max_stack: 操作数栈的最大深度,在方法执行的任意时刻,操作数栈都不会超过这个深度。
  • max_locals: 局部变量表所需的存储空间,以变量槽为单位,变量槽是虚拟机为局部变量分配内存的最小单位。
  • code_length: 字节码指令的长度,限制为不超过65535字节。
  • code: 存储实际字节码指令的一系列字节流。
  • exception_table_length: 异常处理表的长度,记录方法中的异常处理信息。
  • exception_table: 异常处理表,包括起始字节码指令位置、结束字节码指令位置、异常处理程序入口位置和捕获异常的类索引。
  • attributes_count: Code属性的属性数量,用于存储额外的属性信息。
  • attributes: Code属性的属性集合,可能包含一些额外的信息,如调试信息等。

class文件中示例

属性表的attribute_name_index后的00 00 00 2F表示属性值的长度。在这里,00 00 00 2F表示长度为47个字节。它告诉虚拟机在读取属性值时要读取47个字节的内容。如果前面的0x000C的字面量Code虚拟机不认识,那么就可以跳过这些长度。

《Java虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。

按顺序分别为:max_stack,max_locals,code_length,code

0x0001: 操作数栈的最大深度为1

0x0001: 本地变量表容量为1

0x00000005: 字节码区域 所占空间的长度为0x0005。虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并

根据字节码指令表翻译出所对应的字节码指令

翻译"2A B7000A B1"的过程为:

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

这里查的表是 Java 虚拟机规范中定义的字节码指令表。字节码指令表包含了每个操作码(opcode)对应的具体指令和操作。

部分其他指令如下:

指令 助记符 描述
0x03 iconst_2 将整数常量值 2 推送到操作数栈顶
0x10 bipush 将一个字节推送到栈顶,作为整数使用
0x60 iadd 将栈顶两个整数相加
0x2D fsub 将栈顶两个浮点数相减
0xC7 ifnonnull 如果引用不为 null,则跳转

异常表

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

异常表的格式如下:

字段名 数据类型 描述
start_pc u2 起始字节码行号
end_pc u2 结束字节码行号(不含)
handler_pc u2 异常处理代码的字节码行号
catch_type u2 指向一个CONSTANT_Class_info型常量的索引,表示捕获的异常类型。为0时表示捕获所有异常。

演示:

java 复制代码
public int inc() {
    int x;
    try {
        x = 1;
        return x;
    } catch (Exception e) {
        x = 2;
        return x;
    } finally {
        x = 3;
    }
}

编译后的字节码和异常表:

public int inc(); 
Code: 
  Stack=1, Locals=5, Args_size=1 
  0: iconst_1      // 将整数1推送到栈顶,try块中的x=1
  1: istore_1      // 将栈顶的值存储到本地变量表的变量槽1中
  2: iload_1       // 将本地变量表中的变量槽1的值推送到栈顶
  3: istore 4      // 将栈顶的值存储到本地变量表的变量槽4中
  5: iconst_3      // 将整数3推送到栈顶,finally块中的x=3
  6: istore_1      // 将栈顶的值存储到本地变量表的变量槽1中
  7: iload 4       // 将本地变量表中的变量槽4的值推送到栈顶
  9: ireturn       // 从方法返回,返回值为栈顶的值

10: astore_2       // 将栈顶的异常对象存储到本地变量表的变量槽2中
11: iconst_2       // 将整数2推送到栈顶,catch块中的x=2
12: istore_1       // 将栈顶的值存储到本地变量表的变量槽1中
13: iload_1        // 将本地变量表中的变量槽1的值推送到栈顶
14: istore 4       // 将栈顶的值存储到本地变量表的变量槽4中
16: iconst_3       // 将整数3推送到栈顶,finally块中的x=3
17: istore_1       // 将栈顶的值存储到本地变量表的变量槽1中
18: iload 4        // 将本地变量表中的变量槽4的值推送到栈顶
20: ireturn        // 从方法返回,返回值为栈顶的值

21: astore_3       // 将栈顶的异常对象存储到本地变量表的变量槽3中
22: iconst_3       // 将整数3推送到栈顶,finally块中的x=3
23: istore_1       // 将栈顶的值存储到本地变量表的变量槽1中
24: aload_3        // 将本地变量表中的变量槽3的值(异常对象)推送到栈顶
25: athrow         // 抛出栈顶的异常

Exception table: 
  from    to  target type
     0     0    10   Class java/lang/Exception
     5     5    16   any
    10    21    21   Class java/lang/Exception

在这段字节码中,前五行主要是try块的内容。首先,整数1被赋给变量x,然后通过istore_1指令将x的值保存在第一个本地变量槽(slot)中。接下来,将3推送到操作数栈,再通过istore指令将其存储在第四个本地变量槽中,这个槽被称为returnValue

接下来的iload_1指令将第一个本地变量槽中的x值加载到操作数栈顶,然后通过ireturn指令返回这个值。因此,如果try块中没有异常,方法将返回1。

在异常情况下,程序将跳转到第10行(catch块)。异常处理块首先将2赋给变量x,然后通过istore_1指令将x的值保存在第一个本地变量槽中。接着,将之前保存在returnValue中的值(即1)加载到操作数栈顶,然后通过ireturn指令返回这个值。因此,如果发生异常,方法将返回2。

最后,无论是否发生异常,程序都会执行finally块(第21行开始)。在finally块中,将3赋给变量x,并使用athrow指令抛出之前发生的异常。虽然这里没有具体的异常类型,但finally块的主要目的是在方法返回前执行清理工作。

Exceptions属性

这里的Exceptions属性是在方法表中与Code属性平级的一项属性,不要与前面刚刚讲解完的异常表产生混淆。

Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常。

字段名 类型 描述
attribute_name_index u2 指向常量池中CONSTANT_Utf8_info类型的异常表属性名称的索引
attribute_length u4 属性值的长度,不包括attribute_name_index和attribute_length自身的长度
number_of_exceptions u2 异常表中的异常个数
exception_index_table u2 数组 每个元素都是指向常量池中CONSTANT_Class_info类型的索引,表示受检异常的类型

LineNumberTable 属性

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

在调试和排查问题时,LineNumberTable属性是非常有用的,因为它建立了Java源代码和编译后的字节码之间的映射。

字段名 类型 描述
attribute_name_index u2 指向常量池中CONSTANT_Utf8_info类型的属性名称 "LineNumberTable" 的索引
attribute_length u4 属性值的长度,不包括 attribute_name_index 和 attribute_length 自身的长度
line_number_table 包含多个行号项的表,每个行号项包括 start_pc 和 line_number 字段,表示字节码行号和源代码行号的映射关系

LocalVariableTable 属性

LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系。虽然它不是运行时必需的属性,但默认会生成到Class文件中。可以使用Javac中的-g:none或-g:vars选项来选择是否生成这项信息。如果没有生成这项属性,最大的影响之一是当其他人引用这个方法时,所有的参数名称都将会丢失。例如,IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名。这对程序运行没有影响,但会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获取参数值。

LocalVariableTable属性对于理解程序的执行过程以及在调试中获取更多有关局部变量的信息非常有用。

字段名 类型 描述
attribute_name_index u2 指向常量池中CONSTANT_Utf8_info类型的属性名称 "LocalVariableTable" 的索引
attribute_length u4 属性值的长度,不包括 attribute_name_index 和 attribute_length 自身的长度
local_variable_table 包含多个局部变量项的表,每个局部变量项包括 start_pc、length、name_index、descriptor_index 和 index 字段,表示局部变量在字节码中的范围、名称、描述符和索引

SourceFile 属性

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

ourceFile属性有助于在调试时追踪代码,特别是在涉及多个源文件的项目中。

字段名 类型 描述
attribute_name_index u2 指向常量池中CONSTANT_Utf8_info类型的属性名称 "SourceFile" 的索引
attribute_length u4 属性值的长度,不包括 attribute_name_index 和 attribute_length 自身的长度
sourcefile_index u2 指向常量池中CONSTANT_Utf8_info类型的源文件名的索引

SourceDebugExtension 属性

SourceDebugExtension属性是为了存储额外的代码调试信息,特别是在涉及非Java语言编写、但需要编译成字节码并在Java虚拟机中运行的程序时。这个属性的数据项是指向常量池中CONSTANT_Utf8_info型常量的索引,该常量的值是源代码文件的调试信息。

在JDK 5时,引入了SourceDebugExtension属性,用于存储JSR 45提案所定义的标准调试信息。这对于需要在Java虚拟机中运行的非Java语言编写的程序提供了一种标准的调试机制。典型的场景是在进行JSP文件调试时,由于无法通过Java堆栈来定位到JSP文件的行号,可以使用SourceDebugExtension属性来存储额外的调试信息,使程序员能够更快速地从异常堆栈中定位到原始JSP中出现问题的行号。

这个属性在一些特定的情况下很有用,但在一般的Java程序开发中,由于使用Java语言编写,通常不需要额外的非Java调试信息。因此,对于大多数Java应用,可能并不常见。

字段名 类型 描述
attribute_name_index u2 指向常量池中CONSTANT_Utf8_info类型的属性名称 "SourceDebugExtension" 的索引
attribute_length u4 属性值的长度,不包括 attribute_name_index 和 attribute_length 自身的长度
debug_extension 字节数组 包含调试信息的字节数组

还有很多属性如:不再赘述

  • AnnotationDefault
  • BootstrapMethods
  • MethodParameters
    ...

二、字节码指令

在Java虚拟机的指令集中,指令可以分为多个大的类别,以下是其中一些主要的指令类别:

  1. 加载和存储指令(Load and Store Instructions):
    • aaload, aastore, baload, bastore, caload, castore, daload, dastore, faload, fastore, iaload, iastore, laload, lastore, saload, sastore, 等。
  2. 操作数栈管理指令(Stack Management Instructions):
    • pop, pop2, dup, dup_x1, dup_x2, dup2, dup2_x1, dup2_x2, swap, 等。
  3. 数学运算指令(Arithmetic Instructions):
    • iadd, isub, imul, idiv, irem, iinc, ladd, lsub, lmul, ldiv, lrem, fadd, fsub, fmul, fdiv, frem, dadd, dsub, dmul, ddiv, drem, 等。
  4. 类型转换指令(Type Conversion Instructions):
    • i2l, i2f, i2d, l2i, l2f, l2d, f2i, f2l, f2d, d2i, d2l, d2f, i2b, i2c, i2s, 等。
  5. 比较指令(Comparison Instructions):
    • lcmp, fcmpl, fcmpg, dcmpl, dcmpg, ifcmp<cond>, <cond>, if<cond>, 等。
  6. 控制转移指令(Control Transfer Instructions):
    • goto, tableswitch, lookupswitch, ireturn, lreturn, freturn, dreturn, areturn, return, athrow, jsr, ret, if<cond>, 等。
  7. 引用类和对象的指令(Reference Instructions):
    • new, newarray, anewarray, multianewarray, checkcast, instanceof, getfield, putfield, getstatic, putstatic, 等。
  8. 方法调用和返回指令(Method Invocation and Return Instructions):
    • invokevirtual, invokespecial, invokestatic, invokeinterface, invokedynamic, return, areturn, ireturn, lreturn, freturn, dreturn, 等。
  9. 异常处理指令(Exception Handling Instructions):
    • athrow, monitorenter, monitorexit, try-catch-finally 块相关的指令。

这些指令构成了Java虚拟机的指令集,用于执行Java字节码。每个指令都有特定的操作码和操作数,用于在操作数栈上执行相应的操作

字节码指令集在Java虚拟机中具有独特的特点和一些限制:

  1. 操作码长度限制: 指令集的操作码被限制为一个字节,范围为0~255,这意味着指令集的操作码总数不能超过256条。这种设计有助于简化指令的编码和解码过程。
  2. 操作数长度对齐: Class文件格式中放弃了编译后代码的操作数长度对齐。这意味着虚拟机在处理超过一个字节的数据时,需要在运行时从字节中重建具体数据的结构。例如,将一个16位长度的无符号整数存储在两个无符号字节中,需要使用表达式 (byte1 << 8) | byte2 进行重建。

这些设计选择有一些优势和劣势:

优势:

  • 紧凑性: 一个字节的操作码和简化的操作数对于Class文件的紧凑性是有利的,减小了字节码文件的大小。
  • 解析速度: 简单的指令格式和有限的操作码范围有助于提高字节码的解析速度。

劣势:

  • 指令数限制: 256条操作码的限制可能限制了指令集的丰富性,尽管在实践中这仍然足够支持丰富的语义。
  • 运行时处理成本: 虚拟机在处理较大的数据时需要进行运行时的计算,可能增加了一些运行时的成本。

总体而言,这些设计选择是为了在保持紧凑性和解析速度的同时,提供足够的灵活性来支持Java虚拟机的执行需求。

如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模 型来理解,这个执行模型虽然很简单,但依然可以有效正确地工作

do { 
    自动计算PC寄存器的值加1; 
    根据PC寄存器指示的位置,从字节码流中取出操作码; 
    if (字节码存在操作数) 
    	从字节码流中取出操作数; 
    	执行操作码所定义的操作;
} while (字节码流长度 > 0);

2.1字节码与数据类型

如下列举了Java虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令。

如果在表中指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如load指令有操作int类型的iload,但是没有操作byte类型的同类指令。
Java虚拟机的字节码指令集并没有提供专门用于处理整数类型`byte`、`char`和`short`以及布尔类型(`boolean`)的指令。相反,编译器在编译期或运行期进行类型转换,将这些较小的整数类型转换为`int`类型,然后使用`int`类型的字节码指令来进行操作。

具体而言:

  • 带符号扩展(Sign-Extend): 对于byteshort类型,编译器会进行带符号扩展,将它们转换为相应的int类型。这意味着,如果原始值是负数,它会被符号扩展为32位带符号整数。
  • 零位扩展(Zero-Extend): 对于booleanchar类型,同样会进行零位扩展,将它们转换为相应的int类型。这意味着,无论原始值是什么,都会被零位扩展为32位无符号整数。

在处理booleanbyteshortchar类型的数组时,也会使用对应的int类型的字节码指令来进行操作。因此,实际上,大多数对于这些较小整数类型的操作,都是使用int类型作为运算类型来进行的。这种设计简化了字节码指令集,减少了复杂性。

2.2加载和存储指令

加载和存储指令在Java虚拟机中用于在栈帧的局部变量表和操作数栈之间传输数据。这些指令包括:

  • 将一个局部变量加载到操作数栈:

    • iload:将int类型的局部变量加载到操作数栈。
    • iload_<n>:将int类型的局部变量加载到操作数栈,其中 <n> 表示局部变量索引,可以是0到3的数字。

    (类似的指令存在于其他数据类型,如lloadfloaddloadaload

  • 将一个数值从操作数栈存储到局部变量表:

    • istore:将int类型的数值存储到局部变量表。
    • istore_<n>:将int类型的数值存储到局部变量表,其中 <n> 表示局部变量索引,可以是0到3的数字。

    (类似的指令存在于其他数据类型,如lstorefstoredstoreastore

  • 将一个常量加载到操作数栈:

    • bipush:将单字节常量(-128到127之间的整数)推送到操作数栈。
    • sipush:将短整型常量(-32768到32767之间的整数)推送到操作数栈。
    • ldc:将int、float或String类型的常量值从常量池中推送到操作数栈。
    • ldc_w:与ldc类似,但用于更大的常量池索引。

    (其他指令用于加载更大的常量,如ldc2_waconst_nulliconst_m1iconst_<i>lconst_<l>fconst_<f>dconst_<d>

  • 扩充局部变量表的访问索引的指令:

    • wide:用于扩大对局部变量表的访问索引,通常与其他指令一起使用。

一些指令的助记符以尖括号结尾,表示这是一组指令的特殊形式。例如,iload_<n> 表示了一组特殊的iload指令,其中 <n> 可以是0到3的数字。这些特殊指令省略了显式的操作数,因为操作数隐含在指令中。这些指令的语义与原生的通用指令完全一致。

2.3运算指令

Java虚拟机的算术指令用于对两个操作数栈上的值进行特定运算,并将结果重新存入操作数栈顶。主要分为对整型数据和浮点型数据的运算,其中涵盖了加法、减法、乘法、除法、求余、取反、位移、按位或、按位与、按位异或、局部变量自增、比较等操作。

以下是具体的算术指令列表:

整数运算指令(对应不同数据类型,如int、long):

  • 加法指令:iaddladd
  • 减法指令:isublsub
  • 乘法指令:imullmul
  • 除法指令:idivldiv
  • 求余指令:iremlrem
  • 取反指令:ineglneg
  • 位移指令:ishlishriushrlshllshrlushr
  • 按位或指令:iorlor
  • 按位与指令:iandland
  • 按位异或指令:ixorlxor
  • 局部变量自增指令:iinc
  • 比较指令:dcmpgdcmplfcmpgfcmpllcmp

浮点数运算指令(对应不同数据类型,如float、double):

  • 加法指令:fadddadd
  • 减法指令:fsubdsub
  • 乘法指令:fmuldmul
  • 除法指令:fdivddiv
  • 求余指令:fremdrem
  • 取反指令:fnegdneg

在整型数据溢出的情况下,虚拟机规范并未定义具体的结果,只有在除法和求余指令中当除数为零时会抛出ArithmeticException异常。对于浮点数运算,虚拟机要求遵循IEEE 754规范,包括对非正规浮点数值和逐级下溢的运算规则。

在对long类型数值进行比较时,采用带符号的比较方式;而对浮点数值进行比较时,采用IEEE 754规范中的无信号比较方式。

如果某个操作结果没有明确的数学定义的话, 将会使用NaN(Not a Number)值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回NaN。

这些规定确保了在Java虚拟机中进行数值运算时,结果是符合预期并具有可靠性的。

2.4类型转换指令

类型转换指令用于将两种不同的数值类型相互转换,主要分为宽化类型转换(Widening Numeric Conversion)和窄化类型转换(Narrowing Numeric Conversion)两种。

Java虚拟机直接支持宽化类型转换,即将小范围类型向大范围类型进行安全转换。例如:

  • 将int类型转换为long、float或double类型
  • 将long类型转换为float或double类型
  • 将float类型转换为double类型

窄化类型转换必须显式地使用转换指令完成,包括:

  • i2b:将int类型转换为byte类型
  • i2c:将int类型转换为char类型
  • i2s:将int类型转换为short类型
  • l2i:将long类型转换为int类型
  • f2i:将float类型转换为int类型
  • f2l:将float类型转换为long类型
  • d2i:将double类型转换为int类型
  • d2l:将double类型转换为long类型
  • d2f:将double类型转换为float类型

窄化类型转换可能导致转换结果的正负号变化以及数值的精度丢失。在浮点数值窄化转换为整数类型时,需遵循一定规则,如对NaN的处理和使用IEEE 754的向零舍入模式取整。虚拟机规范明确规定数值类型的窄化转换指令不会导致运行时异常。

这些规定确保了在Java虚拟机中进行数值类型转换时,能够预期并具有可靠性的结果。

2.5对象创建与访问指令

Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。以下是涉及对象创建和操作的一些指令:

创建类实例的指令:

  • new:创建一个新的类实例

创建数组的指令:

  • newarray:创建一个基本类型数组
  • anewarray:创建一个引用类型数组
  • multianewarray:创建一个多维数组

访问类字段和实例字段的指令:

  • getfield:获取实例字段的值
  • putfield:设置实例字段的值
  • getstatic:获取类字段(静态字段)的值
  • putstatic:设置类字段(静态字段)的值

数组元素的加载和存储指令:

  • baload:将一个byte或boolean数组元素加载到操作数栈
  • caload:将一个char数组元素加载到操作数栈
  • saload:将一个short数组元素加载到操作数栈
  • iaload:将一个int数组元素加载到操作数栈
  • laload:将一个long数组元素加载到操作数栈
  • faload:将一个float数组元素加载到操作数栈
  • daload:将一个double数组元素加载到操作数栈
  • aaload:将一个引用类型数组元素加载到操作数栈
  • bastore:将一个byte或boolean值存储到byte或boolean数组元素中
  • castore:将一个char值存储到char数组元素中
  • sastore:将一个short值存储到short数组元素中
  • iastore:将一个int值存储到int数组元素中
  • lastore:将一个long值存储到long数组元素中
  • fastore:将一个float值存储到float数组元素中
  • dastore:将一个double值存储到double数组元素中
  • aastore:将一个引用类型值存储到引用类型数组元素中

数组长度的指令:

  • arraylength:获取数组的长度

检查类实例类型的指令:

  • instanceof:检查对象是否是某个类的实例
  • checkcast:检查对象是否可以强制转换为指定类型

2.6操作数栈管理指令

Java虚拟机提供了一些指令,用于直接操作操作数栈。这些指令包括:

将操作数栈的栈顶一个或两个元素出栈:

  • pop:将栈顶一个元素弹出
  • pop2:将栈顶两个元素弹出

复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:

  • dup:复制栈顶一个元素并将复制值重新压入栈顶
  • dup2:复制栈顶两个元素并将复制值或双份的复制值重新压入栈顶
  • dup_x1:复制栈顶一个元素并将复制值与栈顶下面的元素互换位置,然后重新压入栈顶
  • dup2_x1:复制栈顶两个元素并将复制值或双份的复制值与栈顶下面的元素互换位置,然后重新压入栈顶
  • dup_x2:复制栈顶一个元素并将复制值与栈顶下面的两个元素互换位置,然后重新压入栈顶
  • dup2_x2:复制栈顶两个元素并将复制值或双份的复制值与栈顶下面的两个元素互换位置,然后重新压入栈顶

将栈最顶端的两个数值互换:

  • swap:将栈最顶端的两个元素互换位置

2.7控制转移指令

控制转移指令在Java虚拟机中用于有条件或无条件地改变程序执行流程。这些指令包括:

条件分支:

  • ifeq:如果栈顶元素等于0,则跳转
  • iflt:如果栈顶元素小于0,则跳转
  • ifle:如果栈顶元素小于等于0,则跳转
  • ifne:如果栈顶元素不等于0,则跳转
  • ifgt:如果栈顶元素大于0,则跳转
  • ifge:如果栈顶元素大于等于0,则跳转
  • ifnull:如果栈顶元素为null,则跳转
  • ifnonnull:如果栈顶元素不为null,则跳转
  • if_icmpeq:如果栈顶两个int型元素相等,则跳转
  • if_icmpne:如果栈顶两个int型元素不相等,则跳转
  • if_icmplt:如果栈顶两个int型元素第一个小于第二个,则跳转
  • if_icmpgt:如果栈顶两个int型元素第一个大于第二个,则跳转
  • if_icmple:如果栈顶两个int型元素第一个小于等于第二个,则跳转
  • if_icmpge:如果栈顶两个int型元素第一个大于等于第二个,则跳转
  • if_acmpeq:如果栈顶两个引用类型元素相等,则跳转
  • if_acmpne:如果栈顶两个引用类型元素不相等,则跳转

复合条件分支:

  • tableswitch:通过索引访问表格来进行跳转,用于switch语句的实现
  • lookupswitch:通过键值对访问表格来进行跳转,用于switch语句的实现

无条件分支:

  • goto:无条件跳转
  • goto_w:无条件跳转(宽索引)
  • jsr:跳转到子例程(调用子例程)
  • jsr_w:跳转到子例程(调用子例程,宽索引)
  • ret:返回子例程

宽索引是使用4个字节而不是标准的1个字节来表示跳转目标的偏移量。这使得这两个指令能够处理更大范围的代码偏移,允许跳转到更远的位置。

2.8方法调用和返回指令

在Java虚拟机的指令集中,方法调用是通过一系列不同的指令完成的,这些指令涵盖了不同类型的方法调用。以下是五个主要的方法调用指令:

  1. invokevirtual指令:

    • 用于调用对象的实例方法。
    • 根据对象的实际类型进行分派,这是虚方法分派的典型方式。
    • 是Java语言中最常见的方法分派方式。

    虚方法分派(Virtual Method Dispatch)是指在面向对象编程中,根据对象的实际类型(运行时类型)来确定调用哪个版本的方法。这种分派方式主要用于处理多态性,确保在运行时调用的是对象实际所属类的方法,而不是编译时所声明的类型。

    是面向对象编程中实现多态的重要机制之一。

  2. invokeinterface指令:

    • 用于调用接口方法。
    • 在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  3. invokespecial指令:

    • 用于调用一些需要特殊处理的实例方法。
    • 包括实例初始化方法、私有方法和父类方法。
  4. invokestatic指令:

    • 用于调用类静态方法(static方法)。
  5. invokedynamic指令:

    • 用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
    • 与前四条调用指令不同,invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

方法调用指令与数据类型无关。方法的返回操作则根据返回值的类型有不同的指令,包括:

  • ireturn(用于返回boolean、byte、char、short和int类型的值),
  • lreturn(long类型的值),
  • freturn(float类型的值),
  • dreturn(double类型的值),
  • areturn(引用类型的值)。

此外,还有一条return指令,供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。

2.9异常处理指令

在Java虚拟机中,athrow 指令用于显式抛出异常。当在程序中使用 throw 语句时,编译器会将相应的异常对象推送到操作数栈顶,然后通过 athrow 指令将异常抛出。athrow 指令的使用类似于其他指令,只不过它专门用于抛出异常。

异常处理(catch语句)不是由特定的字节码指令来实现的,而是通过异常表(Exception Table)来完成。异常表是一种数据结构,用于在方法的字节码中记录异常处理器的信息,包括受监控的范围、捕获的异常类型以及对应的异常处理代码的起始位置等信息。

异常表的作用是在方法的字节码执行过程中,当发生异常时,虚拟机会根据异常表中的信息确定如何处理异常。以下是异常表的主要结构:

  • start_pc、end_pc: 定义了受监控范围的起始和结束位置。在这个范围内,如果发生异常,则按照异常表中的处理器信息进行处理。
  • handler_pc: 指定了异常处理器的起始位置,即对应异常发生时要执行的代码的入口。
  • catch_type: 指定了捕获的异常类型,是一个对常量池中CONSTANT_Class_info型常量的索引,表示捕获的异常类型。如果catch_type的值为0,表示捕获所有类型的异常(相当于Java中的catch(Exception e))。

异常表中的每一项都对应着一个异常处理器,Java虚拟机在发现异常时会遍历异常表,找到第一个匹配的异常处理器,然后跳转到相应的处理代码块。如果没有找到匹配的异常处理器,那么异常将会传递到上层调用栈。

2.10同步指令

字节码指令在Java虚拟机中的执行是原子性的。每个字节码指令都被视为一个原子操作,它们要么完全执行,要么不执行。这种原子性保证了在多线程环境中,一个线程执行的字节码指令不会被其他线程中断或插入。

但是代码指令则为非原子性,例如读取和写入共享变量。在多线程环境下,为了确保线程安全,可能需要使用额外的同步机制。

因此,Java虚拟机支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都使用管程(Monitor,通常称为"锁")来实现。

  • 方法级的同步(隐式同步): 方法级的同步是隐式的,无需通过字节码指令控制。虚拟机可以通过检查方法的访问标志(ACC_SYNCHRONIZED)来确定一个方法是否被声明为同步方法。

    • 当调用同步方法时,调用指令检查方法的访问标志,如果设置了,执行线程要求首先成功持有管程(锁),然后才能执行方法。最后,在方法完成时,无论是正常完成还是非正常完成,都会释放管程。
    • 在同步方法执行期间,执行线程持有管程,其他线程无法获取相同的管程。如果同步方法执行期间抛出异常,并且在方法内部无法处理此异常,同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
  • 同步一段指令序列: 同步一段指令序列通常由Java语言中的synchronized语句块表示。

    • Java虚拟机提供了monitorentermonitorexit两条指令来支持synchronized关键字的语义。
    • 这种同步方式需要Javac编译器与Java虚拟机共同协作来支持,举例如下:
    java 复制代码
    void onlyMe(Foo f) { 
    	synchronized(f) { 
    		doSomething();
    	}
    }

    ​ 编译后,这段代码生成的字节码序列如下:

    java 复制代码
    Method void onlyMe(Foo)
    0 aload_1          // 将对象f入栈
    1 dup              // 复制栈顶元素(即f的引用)
    2 astore_2         // 将栈顶元素存储到局部变量表变量槽2中
    3 monitorenter     // 以栈顶元素(即f)作为锁,开始同步
    4 aload_0          // 将局部变量槽0(即this指针)的元素入栈
    5 invokevirtual #5 // 调用doSomething()方法
    8 aload_2          // 将局部变量槽2的元素(即f)入栈
    9 monitorexit      // 退出同步
    10 goto 18         // 方法正常结束,跳转到18返回
    
    13 astore_3        // 从这步开始是异常路径,见下面异常表的Target
    14 aload_2          // 将局部变量槽2的元素(即f)入栈
    15 monitorexit      // 退出同步
    16 aload_3          // 将局部变量槽3的元素(即异常对象)入栈
    17 athrow           // 把异常对象重新抛出给onlyMe()方法的调用者
    
    18 return          // 方法正常返回

    为了保证在方法异常完成时monitorenter和monitorexit指 令依然可以正确配对执行,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有的异常,它的目的就是用来执行monitorexit指令。

三、公有设计、私有实现

Java虚拟机规范对于Java程序与虚拟机实现之间的关系的规定。它明确了虚拟机实现者在设计虚拟机时的自由度和灵活性。一些关键点包括:

  • 公有设计与私有实现之分界线: Java虚拟机规范定义了Java虚拟机应有的共同程序存储格式(Class文件格式)和字节码指令集。这些规范为Java平台上的不同实现提供了一个通用的交互手段。规范强调了实现者可以灵活地在实现中进行优化和修改,只要保持对Class文件的正确读取和包含在其中的语义的准确实现。
  • 实现的伸缩性: 实现者可以根据虚拟机的目标和关注点选择不同的实现方式。这包括将Java虚拟机代码翻译成另一种虚拟机的指令集或将其翻译成宿主机处理程序的本地指令集。这种伸缩性使得虚拟机可以在性能、内存消耗和可移植性等方面进行权衡和优化。
  • 即时编译器(Just-In-Time Compiler)等例外情况: 在某些情况下,一些工具如调试器、性能监视器和即时编译器可能需要访问一些通常被认为是虚拟机后台的元素,这可能对实现者的自由度产生一些限制。

虚拟机实现者有很大的灵活性来调整实现以提高性能、降低内存消耗或实现其他目标,同时保持对Java虚拟机规范的兼容性。这种设计理念为不同的Java虚拟机实现提供了空间,以满足各种不同的需求。

四、Class文件结构的发展

Class文件结构在Java技术体系中具有稳定性和可扩展性。以下是一些重要的观点:

  • Class文件结构的稳定性: 自《Java虚拟机规范》初版订立以来,Class文件结构已经有二十多年的历史。在这段时间里,尽管Java技术体系发生了巨大的改变,包括语言、API等方面的变化,但是Class文件结构一直保持相对稳定,主体结构和字节码指令的语义和数量几乎没有变动。
  • 对访问标志和属性表的改进: 随着Java技术的演进,Class文件的访问标志和属性表也进行了一些改进。访问标志新增了一些标志,如ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM、ACC_BRIDGE、ACC_VARARGS。属性表集合中新增了一系列属性,主要用于支持新的语言特性,如枚举、变长参数、泛型、动态注解等,以及为了性能改进和调试信息。
  • 平台中立和可扩展性的重要性: Class文件格式具有平台中立、紧凑、稳定和可扩展的特点,这是实现Java技术体系中平台无关和语言无关两项特性的关键支柱。这种设计使得Java程序可以在不同的硬件和操作系统上运行,同时为未来的语言特性和扩展提供了空间。

二十余年间,字节码的数量和语义只发生过屈指可数的几次变动,例如JDK1.0.2时改动过invokespecial指令的语义,JDK 7增加了invokedynamic指令,禁止了ret和jsr指令。