【JVM】执行子系统 —— Class 文件结构
无关性
平台无关性
Java 语言在刚刚诞生之时曾经提出过一个非常著名的宣传口号:
Write Once,Run Anywhere. ”一次编写,到处运行“
Java 语言通过 字节码 和 Java 虚拟机来实现跨平台。各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——字节码(Byte Code)是构成平台无关性的基石。
计算机只认识 0 和 1,我们编写的所有程序最终都需要被编译成由 0 和 1 构成二进制格式才能被计算机执行。
计算机运行程序其实就是有顺序的执行系统指令,而不同的计算机系统的指令集是不一样的,这就导致程序和操作系统指令集产生了依赖。
JVM 的作用就是消除程序与各种系统指令集之间的依赖性,我们编写的程序只需要通过编译器转换成既能让 JVM 认识,又与平台无关的字节码文件(class文件),把各个平台指令差异交给虚拟机去处理,这样我们的程序就和平台无关了。
JVM 中有自己的指令集合,字节码中使用的各种指令就是 JVM 指令集中的指令,各种 JVM 的指令集与各自对应平台的指令集是可以对应的。JVM 屏蔽了底层指令集的差异。
一个 JVM 指令可能需要多条机器指令配合来完成。
语言无关性
其他语言也可以编译成字节码在虚拟机中执行
Class 文件结构
Class文件是一组以8个字节为基础单位的二进制流,Class文件结构的稳定,具有非常良好的向后兼容性。各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符。
Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。
- 无符号数
以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值
- 表
是由多个无符号数或者其他表作为数据项构成的复合数据类型(便于区分,表的命名以 _info 结尾)。
这部分内容非常的枯燥,把他用图像表示可以更直观一些。
这里有一个疑问:各个字段或者方法是怎么确定自己对应的属性表的?
最开始的四个字节称为魔数,它的值是固定的:0xCAFEBABE
有翻译称之咖啡宝贝,这和Java的商标也有互应(挺有意思的,一杯咖啡,感觉还是热的):
字节码指令
综述
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。
JVM 采用面向操作数栈而不是面向寄存器的架构,所以操作数都在操作树栈中
劣势
- 操作码总数不能够超过256条,因为 JVM 操作码(即,指令)被限制只能由一个字节表示
- 编译后的操作数长度不对齐,对于超过一个字节的操作数需要额外的处理,会消耗运行时的性能
例如:操作市一个16位的无符号数,那么需要使用两个无符号记录高8位和底8位,使用时还需要对高8位做逻辑左移的操作
优势
- 放弃了操作数长度对齐,意味着可以省略掉大量的填充和间隔符号
- 用一个字节来代表操作码,尽可能获得短小精干的编译代码
字节码指令与数据类型
有的字节码指令与数据类型相关,即位某种数量类型服务,,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:
- i 代表:对 int 类型的数据操作
- l 代表:对 long 类型的数据操作
- s 代表:对 short 类型的数据操作
- b 代表:对 byte 类型的数据操作
- c 代表:对 char 类型的数据操作
- f 代表:对 float 类型的数据操作
- d 代表:对 double 类型的数据操作
- a 代表:对 reference类型的数据操作(引用类型)
也有一些指令的助记符中没有明确指明操作类型的字母,例如 arraylength 指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。
还有另外一些指令,例如无条件跳转指令goto则是与数据类型无关的指令。
并不是所有指令都与数据类型相关,并且与数据类型相关的指令也不一定和每种数据类型都做了匹配,如果都做匹配那么一个字节的指令范围(256个)应该不够用。
字节码指令分类
因为我只是要理解JVM的原理,对于具体的每一条指令以接怎么用并没有深入的探究,只是将其分类看看这些指令都是做什么的。
加载和存储指令
用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
关于局部变量表和操作数栈可以参考:
【JVM】自动内存管理 —— Java内存区域的理解
除了加载和存储指令外还有一些指令可以向操作数栈传输数据,例如:访问对象的字段或数组元素的指令。
运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
运算指令分为两类(算术类型分类):
- 整型数据运算
- 浮点型数据运算
所有运算指令都是针对 JVM 算术类型,即上面的两类。所有其他数据类型的运算都会转换成这两类算数类型。
比如,byte、short、char和boolean 类型的算术运算都将转换成整型数据运算。
所有的运算指令:
类型转换指令
类型转换指令可以将两种不同的数值类型相互转换。
转换分类:
- 宽化类型转换(小范围类型向大范围类型的安全转换)
这类转换是 Java 虚拟机直接支持的,不需要显示的转换指令这类转换主要包括:(这也是Java基础中的常识)
- int类型到long、float或者double类型
- ong类型到float、double类型
- float类型到double类型
- 窄化类型转换
必须显式地使用转换指令来完成:
这类转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
就是说窄化类型不安全。(这也算是 Java 基础中的常识)
对象创建与访问指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。
这个截图的目的是说明 JVM 对类实例和数组的创建和访问使用了不同的指令。只用这些指令没必要死记硬背。
操作数栈管理指令
JVM 提供了可以直接操作操作数栈的指令:
比如弹出栈顶数据的指令;复制栈顶的指令,将复制值再压入栈顶的指令;交换栈顶的两个数据的指令。
这些指令很好理解,对栈的操作无法就是对栈顶元素进行操作。
控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改PC寄存器的值。
说白了就是修改程序计数器的值,修改下一条要执行的指令位置。这个指令是 Java 程序控制的主要指令,比如if、break、switch等
方法调用和返回指令
用来完成方法调用(分派、执行过程)的指令。
异常处理指令
- 显式处理异常:程序中使用 (throw语句)
- 隐式:Java虚拟机指令检测到异常状况时自动抛出
处理异常不是有 JVM 的字节码实现的,采用异常表来完成。
同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。
-
方法级的同步:是隐式的,不需要字节码指令控制
虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。当有线程要进入(调用)这个同步方法时,会检测标志位是否被设置,如果被设置了,那么这个线程就需要先获得“Monitor锁”,然后才能执行方法,在执行完方法(或者执行过程中抛出了异常)时,释放“Monitor锁”,如果标志位没有被设置,那么这个进程要先设置标志位,并获得“Monitor锁”。
-
一段指令序列的同步
由 Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义。正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持:
- 编译器要保证么个 monitorenter 有对应的 monitorexit
- 为了保证这一点,编译器还会为之自动产生一个异常处理程序,来处理异常,并确保执行 monitorexit 。
共有设计和私有设计的理解
根据 Java标准规范中的约束来设计 JVM 指令集算是共有设计
在不改变字节码加载执行的前提小对 JVM 根据具体情况做出优化的设计算是私有设计。
官方时推荐私有设计的。
其实我们写出来的代码交到 JVM 中运行时的具体流程并不一定一直,JVM 有可能会认为我们写的代码影响性能,它会在不改变结果的情况下调整顺序。具体内容可以看看 JVM 内存屏障等相关内容。
总结
-
跨平台跨语言的基石:字节码文件 和 JVM
-
Class文件结构:8字节为单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中
魔数、版本、
常量池常量数、常量池、
访问标志、类索引、父类索引、
接口数、接口索引们、
字段数、字段表集合、
方法数、方法表集合、
属性数、属性表集合 -
字节码指令:一个字节的指令码,面向操作数栈。