更新说明
| 日期 | 更新内容 |
|---|---|
| 2023-4-23 | 新增字节码指令 |
1. 类文件
Java 规范本身是包含了 Java 语言规范和 Java 虚拟机规范。两部分之间并没有必然的关系,Java 虚拟机支持其它语言运行在虚拟机之上。实现语言无关性的基础是虚拟机和字节码存储格式,Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与"Class文件"这种特定的二进制文件格式所关联。
任何一门功能性语言只要能将程序代码编译成 Class 文件,即可实现在 Java 虚拟机上的运行。
1.1. Class 类文件结构
Class 文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有任何分隔符。超过8位字节的数据项将会以高位在前的方式分割成若干个8位字节进行存储。
任何一个 Class 文件都对应着唯一一个类或接口的定义信息,但类和接口并不一定要定义在文件里,比如可以直接通过类加载器生成。——《深入理解 Java 虚拟机》
Java 虚拟机规范规定 Class 文件采用类似结构体的伪结构来存储数据,数据类型只有无符号数和表。
| Class 数据格式 | |
|---|---|
| 无符号数 | 基本数据类型,u1,u2,u4,u8分别代表1、2、4、8个字节的无符号数 |
| 表 | 表是由多个无符号数或其它表作为数据项构成的复合数据类型 |
Class 文件是没有分隔符的,所以是通过严格限定的字节序来定义某个字节代表什么含义,长度是多少等。在 Class 文件中,大致上会有类似以下的结构:
假设一个 Class 文件有16个字节,按字节顺序结构意义如下:
| 字节数 | 内容 |
|---|---|
| 4 | 魔数 |
| 2 | 次版本号 |
| 2 | 主版本号 |
| 2 | 常量池数量 |
| N | 常量池表结构(若干个) |
通过规范的字节顺序读取 Class 文件则可以解析到类中的信息。类文件中一些重要的组成部分如下:
1.1.1. [*]魔数与 Class 文件版本
| 类型 | 名称 | 数量 | 说明 |
|---|---|---|---|
| u4 | magic | 1 | 魔数 |
| u2 | minor_version | 1 | 次版本号 |
| u2 | major_version | 1 | 主版本号 |
Class 部分文件格式
魔数:Class 文件的头4个字节被称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。Class 文件的魔数为0xCAFEBABE。
版本号:Java 的初始版本号从45开始,JDK 1.1 之后每个大版本发布主版本号向上加1(JDK 1.0-1.1 版本号为45.0-45.3,1.2 开始支持到46,后续以此类推)。次版本号范围为0~65535。
1.1.2. [*]常量池
常量池中的常量数量是不固定的,所以会上述举例中看到的计数值。常量池中主要主两个常量:字面量和符号引用。
- 字面量:如文本字符串,声明为 final 的常量值等;
- 符号引用:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
Java 代码在编译时不会有像 C “连接”的步骤,而是在虚拟机加载 Class 文件时动态连接。所以不会在 Class 中保存各方法的内存布局信息等,而是在运行期将方法符号经过转换后使用真正的内存入口地址。
容量计数默认是从1开始的,第0项预留为某些指向常量池的索引值的数据在特定情况下需要表示“不引用任何一个常量池项目”的意义,这种情况下可以把索引置为0.
常量池表的结构有多种,不单独列出。
1.1.3. 访问标志
访问标志用于识别接口访问信息,如该 Class 是类还是接口,是否为 public,是否为 abstract、final、注解、枚举等。
1.1.4. 类索引、父类索引和接口索引集合
Class 文件中这部分数据用于确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名(由于 Java 不允许多继承,所以只会有一个父类索引,除java.lang.Object外都会有父类索引)。接口索引集合可能是一组索引,因为允许实现多个接口,没有时计数值为0.
1.1.5. [*]字段表集合
字段表用于描述接口和类中声明的变量,包含类级变量和实例级变量。字段表中将会通过标识位声明字段的修辞符等,包含静态性(static)、可变性(final),并发可见性(volatile),可序列化(transient)等。
字段表集合不会列出从超类或父类接口中继承来的字段,但可能会列出 Java 代码中不存在的字段。如在内部类中为了保持对外部类的访问性,会自动添加指向外部实例类的字段。
Java 语言中的字段是无法重载的,两个字段的数据类型、修饰符不管是否柚都必须使用不一样的名称;但是对于字节码来说,两个字段的描述符不一样则允许重名。
字段描述符用来描述字段的数据类型。描述符标识如下:
| 标识字符 | 表示含义 |
|---|---|
| B | byte |
| C | char |
| D | double |
| F | float |
| I | int |
| J | long |
| S | short |
| Z | boolean |
| V | void |
| L | 对象类型,如Ljava/lang/Object |
对于数组,用前置的一个[来表示。如:
[[Ljava/lang/String,表示二维数组String[][]
[I,表示一维数组int[]
1.1.6. [*]方法表集合
方法表与字段表基本一致,只是方法的描述符不太一样,方法的描述符是按照先参数列表,后返回值的顺序描述。
方法:void inc()
描述:()V
方法:java.lang.String toString()
描述:()Ljava/lang/String
方法:int indexOf(char[] src,int srcOffset,int srcCount,char[] desc,int descOffset,int descCount,int index)
描述:([CII[CIII)I
上述只是说明了方法的信息,但是并没有包含方法的执行代码。方法的执行代码被编译成字节码指令后,存储在方法属性表集中一个Code属性。
与字段表相对应,如果父类方法在子类中没有被重写,则方法表集中中不会出现来自父类的方法信息,但可能会出现编译器自动添加的方法,如类构建器<clinit>和实例构建器<init>方法。
1.1.6.1. 方法特征签名
在 Java 中重载一个方法需要方法名相同,但与原方法不同的特征签名。Java 代码的方法特征签名只包含了方法名称、参数顺序和参数类型。但字节码特征签名还包含了方法返回值和受查异常表。
所以在 Java 语言中无法通过返回值不同来对方法重载,但是在 Class 文件中仅返回值不同的方法,是可以可以合法共存于同一个类文件的。
1.1.7. 属性表集合
在类文件、字段表、方法表中都可以携带自己的属性表集合。理论上只要不同已有属性名重复时,编译器就可以向属性中表写入自己定义的属性信息。Java 虚拟机运行时会忽略掉他不认识的属性。
虚拟机规范中有很多属性,以下是例举部分属性:
| 属性名称 | 存在位置 | 说明 |
|---|---|---|
| Code | 方法表 | Java代码编译成的字节码指令 |
| Exceptions | 方法表 | 方法抛出的异常 |
| InnerClass | 类文件 | 内部类列表 |
| LineNumberTable | Code属性 | Java源码的行号和字节码指令的对应关系 |
1.1.7.1. [*]Code 属性
用于存储编译器方法体中的代码,处理成字节码指令存储在该属性中。不是所有类文件都必须有该属性,如接口没有方法体则不需要。
属性表中会有记录一些方法执行时必须的信息,如max_stack操作数栈深度最大值、max_locals局部变量表所需的存储空间等。
-
max_locals:单位是
Slot,是虚拟机为局部变量分配内存所使用的最小单位,对于不超过32位数据占用1个Slot,64位数据则需要2个; 局部变量中的Slot是可以重用的,当代码执行超出一个局部变量作用域时,该变量所占用的Slot可以被其它局部变量使用。 -
code_length:用于存储生成的字节码指令。每个指令是1个8位类型的单字节,最多有256条指令。
code_length的长度为4个字节,但是虚拟机中限制了一个方法不能超过65535条字节码指令,所以实际只使用了2个字节的长度。
Code属性是类文件中最重要的一个属性,用于描述代码,其它的数据项目(类、字段、方法定义等)用于描述元数据。
1.1.7.1.1. [*]this 参数
对于一个如下的方法,字节码指令如下:
//源代码
public int inc(){
return m+1;
}
//字节码指令
public int inc();
Code:
//栈深度最大为2,局部变量空间为2,方法参数为1
Stack=2,Locals=1,Args_size=1
0: aload_0
//读取字段 m
1: getfiled #18;
4: iconst_1
5: iadd
//返回int类型数据
6: ireturn
LineNumberTable:
line 8: o
字节码指令中i前缀表示操作的数据类型是整数。在上述方法中,方法入参是0,但是编译后的字节码信息中其实是有一个方法参数Args_size=1。在任何实例方法里,都可以通过this来访问当前的实例对象,在编译器编译时,把对this关键字的访问转换为一个普通的方法参数的访问,虚拟机在调用实例方法时会自动传入实例对象作为参数。如果方法声明为static则参数为会是0。
1.1.7.1.2. [*]异常表
异常表会记录字段码在第S行到第E行出现了类型为捕获类型的异常时,将会跳转到指定的H处理。
异常表是 Java 代码中的一部分,编译器使用异常表而不是简单的跳转命令来实现 Java 异常和 finally 的处理。
对于以下代码,字节码如下:
//源代码
public int inc(){
int x;
try{
x=1;
return x;
}catch(Exception e){
x=2;
return x;
}finally{
x=3;
}
}
//编译后的字节码(部分)
//try流程,执行 x=1 赋值
0: iconst_1
1: istore_1
//保存 x 到返回值中,x值为1
2: iload_1
3: istore 4
//执行finally中的 x=3
5: iconst_3
6: istore_1
//将返回值(第2步)加载到栈顶,准备返回
7: iload
9: ireturn
//catch流程,执行 x=2,其它逻辑同上
10: astore_2
11: istore_1
13: iload_1
14: istore 4
16: iconst_3
17: istore_1
18: iload
20: ireturn
//异常流程,如果不属于 Exception 的异常发生则会执行这里
21: astore_3
22: iconst_3
23: istore_1
//将异常加载到栈顶,执行 athrow 抛出异常
24: aload_3
25: athrow
Exception table:
from to target type
0 5 10 Class java/lang/Exception
0 5 21 any
10 16 21 any
……
在上述的代码中出现了三条执行路径:
-
如果
try语句块中出现了属于Exception和其子类的异常,则转到catch语句块处理 -
如果
try语句块中出现了不属于Exception和其子类的异常,则转到finally语句块处理 -
如果
catch语句块中出现任何异常,则转到finally语句块处理
首先我们可以很明显地看出来:finally 语句块中的代码,实际上是被复制到了 try 和 catch 流程中,在他们的流程执行之后继续执行。
因此这里也是一个很重要的知识点,就是当发生异常之后,finally里的代码会不会被执行?很明显是会的。
在上述代码执行结果后,如果没有异常,将返回1;如果出现可捕获异常,则返回2。有可能会很容易被 finally 中的x=3误导,不知道这种情况下是否会改变返回值的结果?但是从字节码中可以很明确地了解到:返回数据时,并不是简单地把局部变量的值返回而已,实际上会把局部变量的值暂存后再返回;
在编码过程中我们也会遇到return之后其实代码无用,也是因为返回的字节码已经执行了,不管后面的数据如何变更,都不会影响到返回的暂存值。
1.1.7.2. Exceptions 属性
这里的异常属性是在方法中与Code属性平级的属性,并不是Code属性中的异常表。此属性用于列举出方法中可能抛出的受查异常,也就是在throws后列举的异常。
1.1.7.3. LineNumberTable 属性
用于描述源码鹄与字节码行号(这里是字节码相对于方法体开始的偏移量)之间的对应关系,不是运行时必须的属性。
默认会生成,在javac中可以使用-g:none或-g:lines来取消或生成该信息。如果不存在则堆栈不会显示错误行号,也不能在调试时按源码设置断点。
1.1.7.4. LocalVariableTable 属性
用于描述栈帧中局部变量表中的变量和源码中定义的变量之间的关系,不是运行时必须的属性。
默认会生成,在javac中可以使用-g:none或-g:vars选项来取消或要求生成该信息。如果不存在则会导致其它人引用此方法时,参数名称将会丢失,使用类似var0 var1之类的占位符代替原有的参数名,并且在调试时也无法获取到参数的名称。
1.1.7.5. Signature 属性
在 JDK1.5 之后添加到类文件规范中,可以出现在类、属性表和方法表结构中的属性表中。用于表示泛型签名的类型变量或参数化类型。
Java 语言是采用擦除法实现的伪泛型,在字节码中泛型信息会被擦除掉,所以无法像真泛型支持的语言在运行时反射获取到泛型信息。 通过使用该属性记录泛型信息,可以通过一些反射的 API 从该属性中获取到相关的泛型信息。
1.1.7.6. 其它属性
| 属性 | 说明 |
|---|---|
| SourceFile 属性 | 用于记录生成这个 Class 文件的源码文件名称,这个属性也可是可选的。可以分别使用javac的-g:none或-g:source来关闭或生成该信息。一般情况下类名和文件名是一致的,但是如果对于内部类或其它特殊情况,不生成这项属性时,抛出异常不会有出错代码所属的文件名。 |
| ConstantValue 属性 | 用于通知虚拟机自动为静态变量赋值,只有被static关键字修饰的类变量才能使用这项属性。对于非静态变量如 int x=123和静态变量static int x=123在虚拟中赋值方式和时机是有所不同的。● 非静态变量:在实例构造器 <init>方法中进行● 静态变量:有两种方式,在类构造器 <clinit>方法中或使用 ConstantValue 属性 |
| InnerClass 属性 | 用于记录内部类和宿主类之间的关联。 |
| Deprecated 属性 | 用于表示某个类、字段、方法被定为不再推荐使用 |
| Synthetic 属性 | 用于表示此字段或方法不是由 Java 源码直接产生的,而是编译器自行添加的。但是实例构造器<init>方法和类构造器<clinit>方法不受此影响。 |
1.2. 字节码指令
Java 虚拟机中有定义自己的指令集,这些指令集一般是表示了某种操作。在具体的虚拟机内部实现时,可能不同的指令集会使用相同的代码实现,但是在 JVM 的指令集中是独立的操作码。
在大多数的操作码助记符都会有特殊的字符来表示为某种类型的数据服务,默认情况下操作码长茺为一个字节,而上面有提到操作码的数量最多只有256条,所以虚拟机只提供有限类型的指令集,指令集被故意设计成非完全独立(Not Orthogonal),并非每种数据类型和每个操作都有对应的指令。
| 指令前缀标识 | 示例 | 说明 |
|---|---|---|
| i | iload | i 表示对 int 类型数据操作,如从局部变量表中加载 int 类型数据到操作栈 |
| l | lload | l 表示对 long 类型数据操作 |
| s | sload | s 表示对 short 类型数据操作 |
| b | bload | b 表示对 byte 类型数据操作 |
| c | cload | c 表示对 char 类型数据操作 |
| f | fload | f 表示对 float 类型数据操作 |
| d | dload | d 表示对 double 类型数据操作 |
| a | aload | a 表示对 reference 类型数据操作 |
大部分的指令都没有支持整数类型 byte char short 的操作(如iadd表示整数相加,但是没有badd),甚至没有 boolean 类型的指令。编译器会在编译期或运行期将 byte short 类型数据带符号扩展为相应的 int 类型数据,将 boolean 和 char 数据零位扩展为相应的 int 类型数据。
带符号扩展指保留符号位,如-1的 byte 二进制是1111 1111,扩展成 int 后二进制是1111 ... 1111(32位),保留了最高位的符号。零位扩展是指最高位保持为0。
关于指令,类似iload_<n>格式的指令是代表了一组指令,表示了iload_0,iload_1,iload_2等,指令语义是相同的,只是省略掉了显式的操作数,不需要进行取操作数的动作。无必要的情况下,下述指令只会使用iload形式代替同一类型指令。
1.2.1. 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
以下所有的x可能表示i f d a等操作符标识
- 将一个局部变量加载到操作栈:xload
- 将一个数值从操作数栈存储到局部变量表:xstore
- 将一个常量加载到操作数栈:xconst, bipush ,sipush 等
1.2.2. 运算指令
运算指令也是指算术指令,用于对两个操作数栈上的值进行某种特定的运算,并将结果重新存入到操作数顶。一般分为两种,对整数数据进行运算和对浮点数据进行运算的指令。注意 byte short char boolean 的都没相应的算术指令,使用 int 类型指令代替。
- 加、减、乘、除指令:xadd,xsub,xmul,xdiv
- 求余,比较指令:xrem,xcmpg,xcmpl
- 位运算:xneg(取反),xshl,xushr(位移),xor(或),xand,ixor(异或)
- 局部变量自增指令:xinc
数据运算后可能出现溢出的情况,但是虚拟机规范是不要求整型数据运算时抛出运行时异常的。
在进行浮点数运算时,运算结果必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值。如果有两种可表示形式与该值一样接近,将优先选择最低有效位为0的形式。(最低有效位指二进制数据中的最低位)
1.2.3. [*]类型转换指令
类型转换指令可以将两种不同的数值类型进行互相转换,这种转换操作一般用于实现用户代码中的显式类型转换操作,或用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
Java 虚拟机支持宽化类型转换(即小范围向大范围类型数的安全转换),但是窄化类型转换必须显示使用转换指令。窄化转换可能会导致上下限溢出或精度丢失的情况,但是虚拟机规范永远不可能导致抛出运行时异常,也就是永远不会失败。
整型数据 int 和 long 窄化时,只是简单地保留低位字节的内容,所以意味着最终的数据符号取决于低位字节的首位 bit。
//二进制值为 0000 ... 1111 1111
int i=128;
//截断最后8位,1111 1111,表示的值为-1
byte b=(byte)i;
二进制知识中还有一个关于大小端的问题。
- 大端是指:高位字节排放在内存的低地址端,低位字节排放在内存的高地址端
- 小端是指:低位字节排放在内存的低地址端,高位字节排放在内存的高地址端,跟大端是相反的
对于0x01 0x10是大端的情况下,则小端是表示为0x10 0x01,字节顺序完全是相反的。注意仅仅是字节顺序相反,一个字节内的位顺序是不会变化的。
//对于大端的 1111 0000 1010 1010
//小端表示为 1010 1010 1111 0000
//注意不是所有的二进制位全部反过来,这是错误的: 0101 0101 0000 1111
1.2.4. 对像创建与访问指令
虚拟机中对类实例和数组的创建操作使用不同的指令。
- 创建类实例:new
- 创建数组指令:newarray,anewarray,multianewarray
- 访问类字段和实例字段:getfield,putfied,getstatic,putstatic
- 加载数组元素和存储指令:xaload,xastore(a 表示 array 数组,x是类型标识)
- 取数组长度指令:arraylength
- 检查类实例类型指令:instanceof,checkcast
1.2.5. 操作数栈管理指令
直接用于操作操作数栈的指令。
- 将操作数栈的栈顶元素出栈,pop,pop2
- 复制栈顶数值并将复制值重新压入栈顶:dup,dup2等
- 将栈最顶端的两个数值互换:swap
1.2.6. 控制转移指令
控制转移指令可以让虚拟机有条件或无条件地从指定位置指令而不是下一条指令继续执行程序。可以理解为在有条件或无条件情况下修改 PC 寄存器的值。
- 条件分支:ifeq,iflt等
- 复合条件分支:tableswitch,lookupswitch
- 无条件分支:goto等
因为 boolean,char,byte 等类型没有比较指令,所以都是使用 int 指令来比较完成的。而 double,float 等类型是通过运算指令返回一个整型数据后,再使用 int 指令比较(可以联想一下 Java 中比较的接口Comparable就是返回的整型数据)。
1.2.7. 方法调用和返回指令
方法调用会有分派、执行过程,指令就是用于方法调用的。
- 调用对象的实例方法,根据对象实际类型进行分派:invokevirtual
- 调用接口方法,用于搜索实现了这个接口的方法调用:invokeinterface
- 调用类方法:invokestatic 以及其它指令。
方法调用指令与数据类型无关,方法返回指令是根据返回值类型区分的,包含有整型(ireturn),浮点型(freturn),对象(areturn)等。如果是void的无返回值则是 return。
1.2.8. 异常处理指令
Java 中显式抛出异常操作(throw 语句)都是由 athrow 指令实现的。运行时异常会自动抛出。处理异常的语句(catch)不是由字节码实现的,采用异常表来完成的。
1.2.9. [*]同步指令
虚拟机中的同步是使用管程(Monitor)来支持的,可以支持方法级的同步和方法内部一段指令序列的同步。
方法级的同步可以通过方法常量池中的标志来判断;同步一段指令序列通过是用monitorenter和monitorexit来实现的。
如果有一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,则同步方法所持有的管程将会在异常抛到同步方法之外时自动释放。
由于monitorenter和monitorexit是成对出现的,所以编译器会自动产生一个异常处理器,这个异常处理器会声明为可处理所有的异常,以确保执行释放管程的指令。
//源码
void test(T t){
synchronized(t){
...
}
}
//字节码
0 aload_1
1 dup
2 astore_2
//开始同步,以栈顶元素为锁,前面的指令将t压入到了栈顶
3 monitorenter
4 aload_0
5 invokevirtual #5
8 aload_2
//退出同步
9 monitorexit
//正常结束,跳到18返回
10 goto 18
//以下是异常流程,出现异常的情况
13 astore_3
14 aload_2
//退出同步
15 monitorexit
16 aload_3
//抛出产生的异常
17 athrow
18 return
Exception table:
FromTo Target Type
//这里的异常类型是 any 表示可以处理任何类型的异常
//即在同步方法的过程中产生的任何异常都会进入异常处理流程
4 10 13 any
13 16 13 any
1.3. 公有设计和私有实现
虚拟机规范了程序应该有的共同存储格式:Class 文件格式和字节码指令集。只要 Class 文件能正常被读取,那么无论如何进行优化等操作,都是允许的,同时虚拟机具体的实现也并不强制限制。
虚拟机的实现方式主要有两种
- 将输入的 Java 虚拟机代码在加载或执行时翻译成另一种虚拟机的指令集
- 将输入的 Java 虚拟机代码在加载或执行时翻译成宿主机 CPU 的本地指令集(即 JIT 代码生成技术)