文章原文:https://gaoyubo.cn/blogs/844dc0e7.html
任何一个Class文件都对应着唯一的一个类或接口的定义信息。
但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有任何分隔符。
Java 虚拟机规范规定 Class 文件采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
_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_info
、field_info
、method_info
和 attribute_info
是更具体的结构,包含了常量池项、字段信息、方法信息和属性信息的详细描述。
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的“集
合”。
package algorithmAnalysis; public class JVMTest { private int m; public int inc(){ return m+1; } public static void main(String[] args) { System.out.println("gaoyubo"); } }
Class 文件的头 8 个字节是魔数和版本号,其中头 4 个字节是魔数,也就是 0xCAFEBABE
,它可以用来确定这个文件是否为一个能被虚拟机接受的 Class 文件(这通过扩展名来识别文件类型要安全,毕竟扩展名是可以随便修改的)。
后 4 个字节则是当前 Class 文件的版本号,其中第 5、6 个字节是次版本号,第 7、8 个字节是主版本号。
从第 9 个字节开始,就是常量池的入口,常量池是 Class 文件中:
常量池的前两个字节,即第 9、10 个字节,存放着一个 u2 类型的数据,用于表示常量池中的常量数量 cpc
(constant_pool_count)。
这个计数值有一个特殊之处,即它是从 1 开始而不是从 0 开始的。
举例而言,如果cpc = 22
,那么说明常量池中包含 21 个常量,它们的索引值为 1 到 21。
第 0 项常量被保留为空,以便在某些情况下表示“不引用任何常量池项目”,此时将索引值设为 0 即可。
常量池中记录主要包括以下两大类常量:
常量类型 | 标志 | 描述 |
---|---|---|
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 | 包信息 |
... [ tag=7 ] [ name_index ] ... ... [ 1位 ] [ 2位 ] ...
... [ tag=1 ] [ 当前常量的长度 len ] [ 常量的符号引用的字符串值 ] ... ... [ 1位 ] [ 2位 ] [ len位 ] ...
类型 | 名称 | 数量 |
---|---|---|
ul | tag | 1 |
u2 | class_index | 1 |
u2 | name_and_type_index | 1 |
以下是对固定长度的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表用于存储文字字符串值,这些值可以表示为java.lang.String类的实例。该表仅存储文字字符串值,不存储符号引用。
类型 | 名称 | 数量 |
---|---|---|
ul | tag | 1 |
u2 | string_index | 1 |
如果全部介绍,篇幅太长,这里使用IDEA的jclasslib
插件,查看效果如下:
常量表中常量项定义如下:
在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:
这个Class是类还是接口?
以下为访问标志定义:
标志名称 | 标志值 | 含义 |
---|---|---|
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_PUBLIC
和ACC_SUPER
。以下是分析:
ACC_PUBLIC
(0x0001): 这个标志表示类是公共的,可以从其他包访问。ACC_SUPER
(0x0020): 在 Java 5 之前,这个标志是为了向后兼容,当使用invokespecial
指令调用超类构造方法时,要求对该方法的调用使用super
关键字。因此,
JVMTest
类的访问标识应该是ACC_PUBLIC | ACC_SUPER
,即 0x0021。
this_class
和 super_class
都是 u2
类型的数据。this_class
用于确定这个类的全限定名。super_class
用于确定这个类的父类的全限定名。java.lang.Object
之外,所有 Java 类都有父类。java.lang.Object
外,所有 Java 类的父类索引都不为 0。this_class
)和父类索引(super_class
)分别用两个 u2
类型的索引值表示。CONSTANT_Class_info
的类描述符常量。CONSTANT_Class_info
类型的常量中的索引值,可以找到定义在 CONSTANT_Utf8_info
类型的常量中的全限定名字符串。interfaces
是一组 u2
类型的数据的集合。implements
关键字后的接口顺序从左到右排列。extends
关键字。通过这三项数据,可以建立起类的继承关系和接口实现关系,确定类的层次结构和实现的接口,如下为全限定名索引查找过程。
访问标志后面紧跟类索引、父类索引、接口索引,JVMTest.class中表示如下,这里类索引u2值为0x0005,父类索引u2值为0x0006:
使用jclasslib查看u2值对应常量如下,可以看出JVMTest类的父类为Object类:
field_info
用于描述接口或类中声明的字段(变量)。public
、private
、protected
。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
字段访问标志定义如下:
名称 | 标志值 | 描述 |
---|---|---|
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 | 枚举类型字段标志 |
跟随access_flags标志的是两项索引值:name_index和descriptor_index。
access_flags
标志之后,分别引用常量池中的项。name_index
代表字段的简单名称,指向常量池中的字符串项。descriptor_index
代表字段和方法的描述符,同样指向常量池中的字符串项。
全限定名: 类似于
org/fenixsoft/clazz/TestClass
,是类的完整名称,将包名中的.
替换为/
。为了在使用时避免混淆,通常在最后加入一个分号;
表示全限定名结束。简单名称: 指没有类型和参数修饰的方法或字段名称。例如,
inc
和m
是inc()
方法和m
字段的简单名称。描述符:
- 描述符用于描述字段的数据类型、方法的参数列表(包括数量、类型和顺序)以及返回值。
- 基本数据类型(
byte
、char
、double
、float
、int
、long
、short
、boolean
)以及代表无返回值的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
之后,跟随着一个属性表集合。这个属性表集合用于存储一些额外的信息,允许字段表附加描述零至多项的额外信息。
final static int m = 123;
,则可能存在一项名称为 ConstantValue
的属性。通过属性表集合,字段表可以携带额外的信息,例如常量值、访问控制等,以满足不同字段的需求。在本例中,由于字段 m
的声明为 final static int m = 123;
,因此可能包含 ConstantValue
属性,指向常量 123。
在class文件中,表示如下,按照顺序分别是fields_count,access_flags,name_index,descriptor_index:
0x0001:说明这个类只有一个字段表数据
0x0002:代表private修饰符的ACC_PRIVATE 标志位为真(ACC_PRIVATE标志的值为0x0002)
0x0008:字面量为m
,在常量池中对应内容如下图
0x0009:字面量I
,在常量池中对应内容如下图
与类访问标志相同,字段访问标志计算字段访问标志的值也是通过按位或(
|
)操作将各个标志的值组合而成的。例如,如果一个字段是
public
和static
的,那么其访问标志的值为ACC_PUBLIC | ACC_STATIC
如果有两个字段,那么这个顺序就会重复两次,依次表示两个字段的描述信息。
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_VOLATILE
和 ACC_TRANSIENT
标志,因为 volatile
和 transient
关键字不能修饰方法。
相反,方法表的访问标志中增加了以下标志,因为这些关键字可以修饰方法:
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 | 由编译器自动生成的标志 |
按照顺序分别为: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文件中存在。
属性表(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 |
在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或多 |
属性表的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”的过程为:
2A
,查表得到 aload_0
指令,作用是将第 0 个变量槽中的 reference
类型的本地变量推送到操作数栈顶。B7
,查表得到 invokespecial
指令,该指令以栈顶的 reference
类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private
方法或者它的父类的方法。该方法有一个 u2
类型的参数,指向常量池中的一个 CONSTANT_Methodref_info
类型常量,即此方法的符号引用。000A
,这是 invokespecial
指令的参数,代表一个符号引用。查常量池得到 0x000A
对应的常量,表示实例构造器 <init>()
方法的符号引用。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时表示捕获所有异常。 |
演示:
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属性是在方法表中与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属性用于描述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属性用于描述栈帧中局部变量表的变量与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属性用于记录生成这个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属性是为了存储额外的代码调试信息,特别是在涉及非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虚拟机的指令集中,指令可以分为多个大的类别,以下是其中一些主要的指令类别:
aaload
, aastore
, baload
, bastore
, caload
, castore
, daload
, dastore
, faload
, fastore
, iaload
, iastore
, laload
, lastore
, saload
, sastore
, 等。pop
, pop2
, dup
, dup_x1
, dup_x2
, dup2
, dup2_x1
, dup2_x2
, swap
, 等。iadd
, isub
, imul
, idiv
, irem
, iinc
, ladd
, lsub
, lmul
, ldiv
, lrem
, fadd
, fsub
, fmul
, fdiv
, frem
, dadd
, dsub
, dmul
, ddiv
, drem
, 等。i2l
, i2f
, i2d
, l2i
, l2f
, l2d
, f2i
, f2l
, f2d
, d2i
, d2l
, d2f
, i2b
, i2c
, i2s
, 等。lcmp
, fcmpl
, fcmpg
, dcmpl
, dcmpg
, ifcmp<cond>
, <cond>
, if<cond>
, 等。goto
, tableswitch
, lookupswitch
, ireturn
, lreturn
, freturn
, dreturn
, areturn
, return
, athrow
, jsr
, ret
, if<cond>
, 等。new
, newarray
, anewarray
, multianewarray
, checkcast
, instanceof
, getfield
, putfield
, getstatic
, putstatic
, 等。invokevirtual
, invokespecial
, invokestatic
, invokeinterface
, invokedynamic
, return
, areturn
, ireturn
, lreturn
, freturn
, dreturn
, 等。athrow
, monitorenter
, monitorexit
, try-catch-finally
块相关的指令。这些指令构成了Java虚拟机的指令集,用于执行Java字节码。每个指令都有特定的操作码和操作数,用于在操作数栈上执行相应的操作
字节码指令集在Java虚拟机中具有独特的特点和一些限制:
(byte1 << 8) | byte2
进行重建。这些设计选择有一些优势和劣势:
优势:
劣势:
总体而言,这些设计选择是为了在保持紧凑性和解析速度的同时,提供足够的灵活性来支持Java虚拟机的执行需求。
如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模 型来理解,这个执行模型虽然很简单,但依然可以有效正确地工作
do { 自动计算PC寄存器的值加1; 根据PC寄存器指示的位置,从字节码流中取出操作码; if (字节码存在操作数) 从字节码流中取出操作数; 执行操作码所定义的操作; } while (字节码流长度 > 0);
如下列举了Java虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令。
如果在表中指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如load指令有操作int类型的iload,但是没有操作byte类型的同类指令。
Java虚拟机的字节码指令集并没有提供专门用于处理整数类型`byte`、`char`和`short`以及布尔类型(`boolean`)的指令。相反,编译器在编译期或运行期进行类型转换,将这些较小的整数类型转换为`int`类型,然后使用`int`类型的字节码指令来进行操作。具体而言:
byte
和short
类型,编译器会进行带符号扩展,将它们转换为相应的int
类型。这意味着,如果原始值是负数,它会被符号扩展为32位带符号整数。boolean
和char
类型,同样会进行零位扩展,将它们转换为相应的int
类型。这意味着,无论原始值是什么,都会被零位扩展为32位无符号整数。在处理boolean
、byte
、short
和char
类型的数组时,也会使用对应的int
类型的字节码指令来进行操作。因此,实际上,大多数对于这些较小整数类型的操作,都是使用int
类型作为运算类型来进行的。这种设计简化了字节码指令集,减少了复杂性。
加载和存储指令在Java虚拟机中用于在栈帧的局部变量表和操作数栈之间传输数据。这些指令包括:
将一个局部变量加载到操作数栈:
iload
:将int类型的局部变量加载到操作数栈。iload_<n>
:将int类型的局部变量加载到操作数栈,其中 <n>
表示局部变量索引,可以是0到3的数字。(类似的指令存在于其他数据类型,如lload
、fload
、dload
、aload
)
将一个数值从操作数栈存储到局部变量表:
istore
:将int类型的数值存储到局部变量表。istore_<n>
:将int类型的数值存储到局部变量表,其中 <n>
表示局部变量索引,可以是0到3的数字。(类似的指令存在于其他数据类型,如lstore
、fstore
、dstore
、astore
)
将一个常量加载到操作数栈:
bipush
:将单字节常量(-128到127之间的整数)推送到操作数栈。sipush
:将短整型常量(-32768到32767之间的整数)推送到操作数栈。ldc
:将int、float或String类型的常量值从常量池中推送到操作数栈。ldc_w
:与ldc
类似,但用于更大的常量池索引。(其他指令用于加载更大的常量,如ldc2_w
、aconst_null
、iconst_m1
、iconst_<i>
、lconst_<l>
、fconst_<f>
、dconst_<d>
)
扩充局部变量表的访问索引的指令:
wide
:用于扩大对局部变量表的访问索引,通常与其他指令一起使用。一些指令的助记符以尖括号结尾,表示这是一组指令的特殊形式。例如,
iload_<n>
表示了一组特殊的iload
指令,其中<n>
可以是0到3的数字。这些特殊指令省略了显式的操作数,因为操作数隐含在指令中。这些指令的语义与原生的通用指令完全一致。
Java虚拟机的算术指令用于对两个操作数栈上的值进行特定运算,并将结果重新存入操作数栈顶。主要分为对整型数据和浮点型数据的运算,其中涵盖了加法、减法、乘法、除法、求余、取反、位移、按位或、按位与、按位异或、局部变量自增、比较等操作。
以下是具体的算术指令列表:
整数运算指令(对应不同数据类型,如int、long):
iadd
、ladd
isub
、lsub
imul
、lmul
idiv
、ldiv
irem
、lrem
ineg
、lneg
ishl
、ishr
、iushr
、lshl
、lshr
、lushr
ior
、lor
iand
、land
ixor
、lxor
iinc
dcmpg
、dcmpl
、fcmpg
、fcmpl
、lcmp
浮点数运算指令(对应不同数据类型,如float、double):
fadd
、dadd
fsub
、dsub
fmul
、dmul
fdiv
、ddiv
frem
、drem
fneg
、dneg
在整型数据溢出的情况下,虚拟机规范并未定义具体的结果,只有在除法和求余指令中当除数为零时会抛出
ArithmeticException
异常。对于浮点数运算,虚拟机要求遵循IEEE 754规范,包括对非正规浮点数值和逐级下溢的运算规则。在对long类型数值进行比较时,采用带符号的比较方式;而对浮点数值进行比较时,采用IEEE 754规范中的无信号比较方式。
如果某个操作结果没有明确的数学定义的话, 将会使用NaN(Not a Number)值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回NaN。
这些规定确保了在Java虚拟机中进行数值运算时,结果是符合预期并具有可靠性的。
类型转换指令用于将两种不同的数值类型相互转换,主要分为宽化类型转换(Widening Numeric Conversion)和窄化类型转换(Narrowing Numeric Conversion)两种。
Java虚拟机直接支持宽化类型转换,即将小范围类型向大范围类型进行安全转换。例如:
窄化类型转换必须显式地使用转换指令完成,包括:
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虚拟机中进行数值类型转换时,能够预期并具有可靠性的结果。
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
:检查对象是否可以强制转换为指定类型Java虚拟机提供了一些指令,用于直接操作操作数栈。这些指令包括:
将操作数栈的栈顶一个或两个元素出栈:
pop
:将栈顶一个元素弹出pop2
:将栈顶两个元素弹出复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:
dup
:复制栈顶一个元素并将复制值重新压入栈顶dup2
:复制栈顶两个元素并将复制值或双份的复制值重新压入栈顶dup_x1
:复制栈顶一个元素并将复制值与栈顶下面的元素互换位置,然后重新压入栈顶dup2_x1
:复制栈顶两个元素并将复制值或双份的复制值与栈顶下面的元素互换位置,然后重新压入栈顶dup_x2
:复制栈顶一个元素并将复制值与栈顶下面的两个元素互换位置,然后重新压入栈顶dup2_x2
:复制栈顶两个元素并将复制值或双份的复制值与栈顶下面的两个元素互换位置,然后重新压入栈顶将栈最顶端的两个数值互换:
swap
:将栈最顶端的两个元素互换位置控制转移指令在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个字节来表示跳转目标的偏移量。这使得这两个指令能够处理更大范围的代码偏移,允许跳转到更远的位置。
在Java虚拟机的指令集中,方法调用是通过一系列不同的指令完成的,这些指令涵盖了不同类型的方法调用。以下是五个主要的方法调用指令:
invokevirtual指令:
虚方法分派(Virtual Method Dispatch)是指在面向对象编程中,根据对象的实际类型(运行时类型)来确定调用哪个版本的方法。这种分派方式主要用于处理多态性,确保在运行时调用的是对象实际所属类的方法,而不是编译时所声明的类型。
是面向对象编程中实现多态的重要机制之一。
invokeinterface指令:
invokespecial指令:
invokestatic指令:
invokedynamic指令:
方法调用指令与数据类型无关。方法的返回操作则根据返回值的类型有不同的指令,包括:
ireturn
(用于返回boolean、byte、char、short和int类型的值),lreturn
(long类型的值),freturn
(float类型的值),dreturn
(double类型的值),areturn
(引用类型的值)。此外,还有一条return
指令,供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。
在Java虚拟机中,athrow
指令用于显式抛出异常。当在程序中使用 throw
语句时,编译器会将相应的异常对象推送到操作数栈顶,然后通过 athrow
指令将异常抛出。athrow
指令的使用类似于其他指令,只不过它专门用于抛出异常。
异常处理(catch语句)不是由特定的字节码指令来实现的,而是通过异常表(Exception Table)来完成。异常表是一种数据结构,用于在方法的字节码中记录异常处理器的信息,包括受监控的范围、捕获的异常类型以及对应的异常处理代码的起始位置等信息。
异常表的作用是在方法的字节码执行过程中,当发生异常时,虚拟机会根据异常表中的信息确定如何处理异常。以下是异常表的主要结构:
catch(Exception e)
)。异常表中的每一项都对应着一个异常处理器,Java虚拟机在发现异常时会遍历异常表,找到第一个匹配的异常处理器,然后跳转到相应的处理代码块。如果没有找到匹配的异常处理器,那么异常将会传递到上层调用栈。
字节码指令在Java虚拟机中的执行是原子性的。每个字节码指令都被视为一个原子操作,它们要么完全执行,要么不执行。这种原子性保证了在多线程环境中,一个线程执行的字节码指令不会被其他线程中断或插入。
但是代码指令则为非原子性,例如读取和写入共享变量。在多线程环境下,为了确保线程安全,可能需要使用额外的同步机制。
因此,Java虚拟机支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都使用管程(Monitor,通常称为“锁”)来实现。
方法级的同步(隐式同步): 方法级的同步是隐式的,无需通过字节码指令控制。虚拟机可以通过检查方法的访问标志(ACC_SYNCHRONIZED)来确定一个方法是否被声明为同步方法。
同步一段指令序列: 同步一段指令序列通常由Java语言中的synchronized
语句块表示。
monitorenter
和monitorexit
两条指令来支持synchronized
关键字的语义。void onlyMe(Foo f) { synchronized(f) { doSomething(); } }
编译后,这段代码生成的字节码序列如下:
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技术体系中具有稳定性和可扩展性。以下是一些重要的观点:
二十余年间,字节码的数量和语义只发生过屈指可数的几次变动,例如JDK1.0.2时改动过invokespecial指令的语义,JDK 7增加了invokedynamic指令,禁止了ret和jsr指令。