Java 虚拟机规范 (Java SE 7 版)Ch1_Ch3
The Java Virtual Machine Specification SE7
第 1 章 引言
Java 语言是一门通用的、面向对象的、支持并发的程序语言。
Java 虚拟机是整个 Java 平台的基石,是 Java 技术用以实现硬件无关与操作系统无关的关键部分,是 Java 语言生成出极小体积的编译代码的运行平台,是保障用户机器免于恶意代码损害 的保护屏障。
Java 虚拟机与 Java 语言并没有必然的联系,它只与特定的二进制文件格式——Class 文件 格式所关联,Class 文件中包含了 Java 虚拟机指令集(或者称为字节码、Bytecodes)和符号 表,还有一些其他辅助信息。
基于安全方面的考虑,Java 虚拟机要求在 Class 文件中使用了许多强制性的语法和结构化 约束,但任一门功能性语言都可以表示为一个能被 Java 虚拟机接收的有效的 Class 文件。作为 一个通用的、机器无关的执行平台,任何其他语言的实现者都可以将 Java 虚拟机作为他们语言的 产品交付媒介。
第 2 章 Java 虚拟机结构
编译后被 Java 虚拟机所执行的代码使用了一种平台中立(不依赖于特定硬件及操作系统的) 的二进制格式来表示,并且经常(但并非绝对)以文件的形式存储,因此这种格式被称为 Class 文件格式。Class 文件格式中精确地定义了类与接口的表示形式,包括在平台相关的目标文件格 式中一些细节上的惯例1,例如字节序(Byte Ordering)等。
Java 虚拟机可以操作的数据类型可分为两类:原始类 型(Primitive Types,也经常翻译为原生类型或者基本类型)和引用类型(Reference Types)。
Java 虚拟机是直接支持对象的
虚拟机中使用 reference 类型来表示对某个对象的引用,reference 类型的 值读者可以想象成类似于一个指向对象的指针。每一个对象都可能存在多个指向它的引用,对象的 操作、传递和检查都通过引用它的 reference 类型的数据进行操作。
Java 虚拟机所支持的原始数据类型包括了数值类型(Numeric Types)、布尔类型(Boolean Type §2.3.4)和 returnAddress 类型(§2.3.3)三类。其中数值类型又分为整型类型 (Integral Types,§2.3.1)和浮点类型(Floating-Point Types,§2.3.2)两种
int 类型:值为 32 位有符号二进制补码整数,默认值为零。
double 类型:取值范围是双精度浮点数集合中的元素,或者(如果虚拟机支持的话)是 双精度扩展指数(Double-Extended-Exponent)集合中的元素。默认值为正数零。
boolean 类型:取值范围为布尔值 true 和 false,默认值为 false。
returnAddress 类型:表示一条字节码指令的操作码(Opcode)。在所有的虚拟机支持的原始类型之中,只有 returnAddress 类型是不能直接 Java 语言的数据类型对应 起来的。
对于一个非零的、可数的任意浮点值,都可以表示为 s×m×2(e-N+1)的形式,其中 s 可以是 +1 或者-1,m 是一个小于 2N 的正整数,e 是一个介于 Emin=-(2K-1-2)和 Emax=2K-1-1 之间的整 数(包括 Emin 和 Emax)。这里的 N 和 K 两个参数的取值范围决定于当前采用的浮点数值集合。
所有 Java 虚拟机的实现都必须支持两种标准的浮点数值集合:单精度浮点数集合和双精度浮 点数集合。另外,Java 虚拟机实现可以自由选择是否要支持单精度扩展指数集合和双精度扩展指 数集合,也可以选择支持其中的一种或全部。这些扩展指数集合可能在某些特定情况下代替标准浮 点数集合来表示 float 和 double 类型的数值。
上述四种数值集合都不仅包含可数的非零值,还包括五个特殊的数值:正数零、负数零、正无 穷大、负无穷大和 NaN。
returnAddress 类型会被 Java 虚拟机的 jsr、ret 和 jsr_w 指令所使用。
这几条指令以前主要被使用来实现 finally 语句块,后来改为冗余 finally 块代码的方式来实 现,甚至到了 JDK7 时,虚拟机已不允许 Class 文件内出现这几条指令。那相应地,returnAddress 类型就处 于名存实亡的状态。
Java 虚拟机定义了 boolean 这种数据类型,但是只对它提供了非常有限的支持。在 Java 虚拟机中没有任何供 boolean 值专用的字节码指令,在 Java 语言之中涉及到 boolean 类型值的运算,在编译之后都使用 Java 虚拟机中的 int 数据类型来代替。
Java 虚拟机直接支持 boolean 类型的数组,虚拟机的 newarray 指令可以创建这种数组。boolean 的数组类型的访问与修改共用 byte 类型数组的 baload 和 bastore 指令。
在 Oracle 公司的虚拟机实现里,Java 语言里面的 boolean 数组将会被编码成 Java 虚拟机的 byte 数 组,每个 boolean 元素占 8 位长度。
Java虚拟机中有三种引用类型:类类型(Class Types)、数组类型(Array Types)和 接口类型(Interface Types)。
2.5 运行时数据区
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机 启动而创建,随着虚拟机退出而销毁。
2.5.1 PC 寄存器
Java 虚拟机可以支持多条线程同时执行,每一条 Java 虚拟机线程都有自己的PC(Program Counter)寄存器。
2.5.2 Java 虚拟机栈
这个栈与线程同时创建,用于存储栈帧
栈帧可以在堆中分配,Java 虚拟机栈所使用的内存不需要保证是连 续的。
如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量时,Java 虚拟机将会抛出一个 StackOverflowError 异常。
如果 Java 虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。
2.5.3 Java 堆
在 Java 虚拟机中,堆(Heap)是可供各条线程共享的运行时内存区域,也是供所有类实例 和数组对象分配内存的区域。
Java 堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统(Automatic Storage Management System,也即是常说的“Garbage Collector(垃圾收集器)”)所管理的各种 对象,这些受管理的对象无需,也无法显式地被销毁。
本规范中所描述的 Java 虚拟机并未假设采 用什么具体的技术去实现自动内存管理系统。虚拟机实现者可以根据系统的实际需要来选择自动内 存管理技术。
Java 堆所使用的内存不需要保证是连续的。
如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那Java虚拟机将会抛出一个OutOfMemoryError 异常。
2.5.4 方法区
方法区(Method Area)是可供各条线程共享的运行时内存区域
方法区与传统语言中的编译代码储存区(Storage Area Of Compiled Code)或者操作系统进程 的正文段(Text Segment)的作用非常类似
它存储了每一个类的结构信息,例如运行时常量 池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容、还包 括一些在类、实例、接口初始化时用到的特殊方法
方法区在虚拟机启动的时候被创建,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现 可以选择在这个区域不实现垃圾收集。
方法区在实际内存空间中可以是不连续的。
如果方法区的内存空间不能满足内存分配请求,那Java虚拟机将抛出一个OutOfMemoryError 异常。
2.5.5 运行时常量池
运行时常量池扮演了类似传统语言中符号表(Symbol Table)的角色,不过它存储数据范围比通常意义上的符号表要更为广泛
2.5.6 本地方法栈
Java虚拟机实现可能会使用到传统的栈(通常称之为“C Stacks”)来支持native方法 (指使用 Java 以外的其他语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)。
当 Java 虚拟机使用其他语言(例如 C 语言)来实现指令集解释器时,也会使用到本地 方法栈。
如果 Java 虚拟机不支持 natvie 方法,并且自己也不依赖传统栈的话,可以无需支持本 地方法栈,如果支持本地方法栈,那这个栈一般会在线程创建的时候按线程分配。
2.6 栈帧
栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。
栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出 了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在 Java 虚拟机栈(§2.5.5) 之中,每一个栈帧都有自己的局部变量表(Local Variables,§2.6.1)、操作数栈(Operand Stack,§2.6.2)和指向当前方法所属的类的运行时常量池(§2.5.5)的引用。
在一条线程之中,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧就被称为是当前栈 帧(Current Frame)
当方法返回的之际,当前栈帧会传回此方法的执行结果给前一个栈帧,在方法返回之后,当前栈帧就随之被丢弃,前一个栈帧就重新成为当前栈帧了。
栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一条线程的栈帧。
2.6.1 局部变量表
局部变量使用索引来进行定位访问,第一个局部变量的索引值为零,局部变量的索引值是从零 至小于局部变量表最大容量的所有整数。
Java 虚拟机也不要求 double 和 long 类型数据采用 64 位对齐的方式存放在连续的局部变量中
2.6.2 操作数栈
每一个栈帧(§2.6)内部都包含一个称为操作数栈(Operand Stack)的后进先出 (Last-In-First-Out,LIFO)栈。
在操作数栈中的数据必须被正确地操作,这里正确操作是指对操作数栈的操作必须与操作数栈 栈顶的数据类型相匹配
有一小部分Java虚拟机指令(例如 dup 和 swap 指令)可以不关注操作数的具体数据类型,把所有在运行时数据区中的数据当作裸类型(Raw Type)数据来操作,这些指令不可以用来修改数据,也不可以拆散那些原本不可拆分的数据,这些操作的正确性将会通过 Class 文件的校验过程(§4.10)来强制保障。
在任意时刻,操作数栈都会有一个确定的栈深度,一个 long 或者 double 类型的数据会占用 两个单位的栈深度,其他数据类型则会占用一个单位深度。
2.6.3 动态链接
每一个栈帧(§2.6)内部都包含一个指向运行时常量池(§2.5.5)的引用来支持当前方法 的代码实现动态链接(Dynamic Linking)。
动态链接的作用就是 将这些符号引用所表示的方法转换为实际方法的直接引用
由于动态链接的存在,通过晚期绑定(Late Binding)使用的其他类的方法和变量在发生 变化时,将不会对调用它们的方法构成影响。
2.6.4 方法正常调用完成
当前栈帧(§2.6)承担着回复调用者状态的责任
调用者的代 码能在被调用的方法返回并且返回值被推入调用者栈帧的操作数栈后继续正常地执行。
2.6.5 方法异常调用完成
如果方法异常调用完成,那一 定不会有方法返回值返回给它的调用者。
2.7 对象的表示
Java 虚拟机规范不强制规定对象的内部结构应当如何表示。
2.8 浮点算法
2.8.1 Java 虚拟机和 IEEE 754 中的浮点算法
在 Java 虚拟机中的浮点操作在遇到非法操作,如被零除(Divison By Zero)、上限溢出(Overflow)、下限溢出(Underflow)和非精确(Inexact)时,不会抛出exception、trap 或者其他 IEEE 754 异常情况中定义的信号。
在Java虚拟机中不支持IEEE754中的信号浮点比较(SignalingFloating-PointComparisons)。
在 Java 虚拟机中,舍入操作永远使用 IEEE 754 规范中定义的向最接近数舍入模式(Round To Nearest Mode),无法精确表示的结果将会舍入为最接近的可表示值来 保证此值的最低有效位为零(A Zero Least-Significant Bit),这种模式也是 IEEE 754 中的默认模式。
不过在 Java 虚拟机里面,将浮点数转化为整型数是使用向零舍入(Round Toward Zero),这点属于特别定义,并不意味着 Java 虚拟机要改变浮点运算的舍入模式。
在Java虚拟机中不支持IEEE754的单精度扩展和双精度扩展格式(SingleExtended Or Double Extended Format),但是在双精度浮点数集合和双精度扩展指数集合 (Double And Double Extended-Exponent Value Sets,§2.3.2)范围与单 精度扩展指数格式的表示方位会有重叠。
2.8.2 浮点模式
每一个方法都有一项属性称为浮点模式(Floating-Point Mode),取值有两种,要么是 FP-strict 模式要么是非 FP-strict 模式。
2.8.3 数值集合转换
在一些特定场景下,支持扩展指数集合的 Java 虚拟机实现数值在标准浮点数集合与扩展指数 集合之间的映射关系是允许和必要的,这种映射操作就称为数值集合转换。数值集合转换并非数据 类型转换,而是在同一种数据类型之中不同数值集合的映射操作。
在数值集合转换发生的位置,虚拟机实现允许对数值执行下面操作之一:
如果一个数值是float类型,并且不是单精度浮点数集合中的元素,允许将其映射到单精度浮点数集合中数值最接近的元素。
如果一个数值是double类型,并且不是双精度浮点数集合中的元素,允许将其映射到双精度浮点数集合中数值最接近的元素。
此外,在数值集合转换发生的位置,下面操作是必须的:
假设正在执行的Java虚拟机字节码指令是非FP-strict模式的,但这条指令导致了一个 float 类型的值推入到一个 FP-strict 模式的操作数栈中。如果这个数值不是单精度浮点数集合中的元素,需要将其映射到单精度浮点数集合中数值最接近的元素。
假设正在执行的Java虚拟机字节码指令是非FP-strict模式的,但这条指令导致了一个 double 类型的值推入到一个 FP-strict 模式的操作数栈中。如果这个数值不是双精度浮点数集合中的元素,需要将其映射到双精度浮点数集合中数值最接近的元素。
在方法调用中的参数传递(包括 native 方法的调用)、一个非 FP-strict 模式的方法返回浮点型的结果到 FP-strict 模式的调用者栈帧中或者在非 FP-strict 模式的方法中存储浮点型 数值到局部变量、字段或者数组元素之中都可能会导致上述的数值集合转换发生。
并非所有扩展指数集合中的数值都可以精确映射到标准浮点数值集合的元素之中。
如果进行映 射的数值过大(扩展指数集合的指数可能比标准数值集合的允许最大值要大),无法在标准数值集 合之中精确表示的话,这个数字将会被转化称对应类型的(正或负)无穷大。如果进行映射的数值过大(扩展指数集合的指数可能比标准数值集合的允许最小值要小),无法在标准数值集合之中精确表示的话,这个数字将会被转化成最接近的可以表示非正规值(Denormalized Value,§2.3.2)或者相同正负符号零。
2.9 初始化方法的特殊命名
在 Java 虚拟机层面上,Java 语言中的构造函数在《Java 语言规范 (第三版)》(下文简称 JLS3,§8.8)是以一个名为的特殊实例初始化方法的形式出现的,这个方法名称是由编译器命名的,因为它并非一个合法的 Java 方法名字,不可能通过程序编码的方式实现。
实例初始化方法只能在实例的初始化期间,通过 Java 虚拟机的 invokespecial 指令来调用, 只有在实例正在构造的时候,实例初始化方法才可以被调用访问(JLS3,§6.6)。
一个类或者接口最多可以包含不超过一个类或接口的初始化方法,类或者接口就是通过这个方法完成初始化的(§5.5)。这个方法是一个不包含参数的静态方法,名为。这个名字也是由编译器命名的,因为它并非一个合法的 Java 方法名字,不可能通过程序编码的方式实现。
类或接口的初始化方法由 Java 虚拟机自身隐式调用,没有任何虚拟机字节码指令可以调用这个方法,只有在类的初始化阶段中会被虚拟机自身调用。
2.10 异常
Java 虚拟机里面的异常使用 Throwable 或其子类的实例来表示,抛异常的本质实际上是程序控制权的一种即时的、非局部(Nonlocal)的转换——从异常抛出的地方转换至处理异常的地方。
绝大多数的异常的产生都是由于当前线程执行的某个操作所导致的,这种可以称为是同步异常。
与之相对的,异步异常是指在程序的其他任意地方进行的动作而导致的异常。
Java 虚拟机中异常的出现总是由下面三种原因之一导致的:
虚拟机同步检测到程序发生了非正常的执行情况,这时异常将会紧接着在发生非正常执行情况的字节码指令之后抛出。例如:
字节码指令所蕴含的操作违反了Java语言的语义,如访问一个超出数组边界范围的元素。
类在加载或者链接时出现错误。
使用某些资源的时候产生资源限制,例如使用了太多的内存。
athrow字节码指令被执行。
由于以下原因,导致了异步异常的出现:
调用了Thread或者ThreadGroup的stop方法。
Java虚拟机实现的内部程序错误。
当某条线程调用了 stop 方法时,将会影响到其他的线程,或者在线程组中的所有线程。 这时候其他线程中出现的异常就是异步异常,因为这些异常可能出现在程序执行过程的任 何位置。
虚拟机的内部异常也被认为是一种异步异常(§6.3)
抛出异常的动作在 Java 虚拟机之中是一种被精确定义的程序控制权转移过程,当异常抛出、 程序控制权发生转移的那一刻,所有在异常抛出的位置之前的字节码指令所产生的影响都应当是 可以被观察到的,而在异常抛出的位置之后的字节码指令,则应当是没有被执行过的。
如果虚拟机 执行的代码是被优化后的代码,有一些在异常出现位置之后的代码可能已经被执行了,那这些优 化过的代码必须保证被它们提前执行所产生的影响对用户程序来说都是不可见的。
由 Java 虚拟机执行的每一个方法都会配有零至多个异常处理器(Exception Handlers), 异常处理器描述了其在方法代码中的有效作用范围(通过字节码偏移量范围来描述)、能处理的异常类型以及处理异常的代码所在的位置。要判断某个异常处理器是否可以处理某个具体的异常,需要同时检查异常出现的位置是否在异常处理的有效作用范围内并且出现的异常是否异常处理器声明可以处理的异常类型或其子类型两个条件。
当有异常被抛出时,Java 虚拟机搜索当前方法的包含的各个异常处理器,如果能找到可以处理该异常的异常处理器,则将代码控制权转向到异常处理器中描述的处理异常的分支之中。
搜索异常处理器时的搜索顺序是很关键的,在 Class 文件里面,每个方法的异常处理器都存储在一个表中(§4.7.3)。在运行时,当有异常出现之后,Java 虚拟机就按照 Class 文件中的异常处理器表描述异常处理器的先后顺序,从前至后进行搜索。
需要注意,Java 虚拟机身不会对方法的对异常处理器表做排序或者其他方式的强制处理, 所以 Java 语言中对异常处理的语义,实际上是通过编译器适当安排异常处理器在表中的顺序来协助完成的。在 Class 文件中定义了明确的异常处理器查找顺序,才能保证无论 Class 文件是通过 何种途径产生的,Java 虚拟机执行时都能有一致的行为表现。
2.11 字节码指令集简介
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作 义的操作码(Opcode)以及 跟随其后的零至多个代表此操作所需参数的操作数(Operands)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。
操作数的数量以及长度取决于操作码,如果一个操作数的长度超过了一个字节,那它将会以 Big-Endian顺序存储——即高位在前的字节序。举个例子,如果要将一个16位长度的无符号整 数使用两个无符号字节存储起来(将它们命名为 byte1 和 byte2),那它们的值应该是这样的:
(byte1 << 8) | byte2
2.11.1 数据类型与 Java 虚拟机
由于 Java 虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码对指令集的设计带来了很大的压力(字节码无法超过256条的限制就来源于此)
有一些单独的指令可以在必 要的时候用来将一些不支持的类型转换为可被支持的类型。
大部分的指令都没有支持整数类型 byte、char 和 short,甚至 没有任何指令支持 boolean 类型。
编译器会在编译期或运行期会将 byte 和 short 类型的数据 带符号扩展(Sign-Extend)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展 (Zero-Extend)为相应的 int 类型数据。与之类似的,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。
因此,大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的对 int 类型作为运 算类型(Computational Type)。
2.11.2 加载和存储指令
加载和存储指令用于将数据从栈帧(§2.6)的局部变量表(§2.6.1)和操作数栈之间来回 传输(§2.6.2)
将一个局部变量加载到操作栈的指令包括有:
iload、iload_<n>
等将一个数值从操作数栈存储到局部变量表的指令包括有:
istore、istore_<n>
等将一个常量加载到操作数栈的指令包括有:
bipush、sipush、ldc、ldc_w、ldc2_w、 aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
等扩充局部变量表的访问索引的指令:wide
上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如
iload_<n>
),这些指令助记符实际上是代表了一组指令(例如
iload_<n>
,它代表了iload_0、iload_1、iload_2 和 iload_3
这几条指令)。在尖括号之间的字母制定了指令隐 操作数的 数据类型,
<i>
代表是 int 形数据,<l>
代表 long 型,<f>
代表 float 型,<d>
代表 double 型。
2.11.3 运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
大体 上运算指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令,无论是那 种算术指令,都是使用 Java 虚拟机的数字类型的。
数据没有直接支持 byte、short、char 和 boolean 类型(§2.11.1)的算术指令,对于这些数据的运算,都是使用操作 int 类型的指令。
整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为,
所有的算术指令包括:
加法指令:iadd、ladd、fadd、dadd
减法指令:isub、lsub、fsub、dsub
乘法指令:imul、lmul、fmul、dmul
除法指令:idiv、ldiv、fdiv、ddiv
求余指令:irem、lrem、frem、drem
取反指令:ineg、lneg、fneg、dneg
位移指令:ishl、ishr、iushr、lshl、lshr、lushr
按位或指令:ior、lor
按位与指令:iand、land
按位异或指令:ixor、lxor
局部变量自增指令:iinc
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
Java 虚拟机没有明确规定整型数据溢出的情况,但是规定了在处理整型数据时,只有除法指令(idiv 和 ldiv)以及求余指令(irem 和 lrem)出现除数为零时会导致虚拟机抛出异常,如 果发生了这种情况,虚拟机将会抛出 ArithmeitcException 异常。
Java 虚拟机在处理浮点数时,必须遵循 IEEE 754 规范中所规定行为限制。也就是说 Java 虚拟机要求完全支持 IEEE 754 中定义的非正规浮点数值(Denormalized Floating-Point Numbers,§2.3.2)和逐级下溢(Gradual Underflow)。
Java 虚拟机要求在进行浮点数运算时,所有的运算结果都必须舍入到适当的进度,非精确的结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,那将优先 选择最低有效位为零的。这种舍入模式也是 IEEE 754 规范中的默认舍入模式,称为向最接近数 舍入模式(§2.8.1)。
在把浮点数转换为整数时,Java 虚拟机使用 IEEE 754 标准中的向零舍入模式(§2.8.1), 这种模式的舍入结果会导致数字被截断,所有小数部分的有效字节都会被丢弃掉。向零舍入模式将 在目标数值类型中选择一个最接近,但是不大于原值的数字来作为最精确的舍入结果。
Java 虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是 Java 的异常, 请勿与 IEEE 754 规范中的浮点异常互相混淆),当一个操作产生溢出时,将会使用有符号的无穷 大来表示,如果某个操作结果没有明确的数学定义的话,将会时候 NaN 值来表示。所有使用 NaN 值作为操作数的算术操作,结果都会返回 NaN。
在对 long 类型数值进行比较时,虚拟机采用带符号的比较方式,而对浮点数值进行比较时 (dcmpg、dcmpl、fcmpg、fcmpl),虚拟机采用 IEEE 754 规范说定义的无信号比较 (Nonsignaling Comparisons)方式。
2.11.4 类型转换指令
类型转换指令可以将两种 Java 虚拟机数值类型进行相互转换,这些转换操作一般用于实现用 户代码的显式类型转换操作,或者用来处理 Java 虚拟机字节码指令集中指令非完全独立独立的问 题(§2.11.1)。
Java 虚拟机直接支持(译者注:“直接支持”意味着转换时无需显式的转换指令)以下数值 的宽化类型转换(Widening Numeric Conversions,小范围类型向大范围类型的安全转换):
int类型到long、float或者double类型
long类型到float、double类型
float类型到double类型
窄化类型转换(Narrowing Numeric Conversions)指令包括有:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数 量级,转换过程很可能会导致数值丢失精度。
在将 int 或 long 类型窄化转换为整数类型 T 的时候,转换过程仅仅是简单的丢弃除最低位N 个字节以外的内容,N 是类型 T 的数据类型长度,这将可能导致转换结果与输入值有不同的正负号(译者注:在高位字节符号位被丢弃了)。
在将一个浮点值转窄化转换为整数类型 T(T 限于 int 或 long 类型之一)的时候,将遵循以下转换规则:
如果浮点值是NaN,那转换结果就是int或long类型的0
否则,如果浮点值不是无穷大的话,浮点值使用IEEE754的向零舍入模式(§2.8.1)取整,获得整数值 v,这时候可能有两种情况:
如果T是long类型,并且转换结果在long类型的表示范围之内,那就转换为long类型数值 v
如果T是int类型,并且转换结果在int类型的表示范围之内,那就转换为int类型数值 v
否则:
如果转换结果v的值太小(包括足够小的负数以及负无穷大的情况),无法使用T类 型表示的话,那转换结果取 int 或 long 类型所能表示的最小数字。
如果转换结果v的值太大(包括足够大的正数以及正无穷大的情况),无法使用T类 型表示的话,那转换结果取 int 或 long 类型所能表示的最大数字。
从 double 类型到 float 类型做窄化转换的过程与 IEEE 754 中定义的一致,通过 IEEE 754 向最接近数舍入模式(§2.8.1)舍入得到一个可以使用 float 类型表示的数字。
如果转换结果 的绝对值太小无法使用 float 来表示的话,将返回 float 类型的正负零。
如果转换结果的绝对值 太大无法使用 float 来表示的话,将返回 float 类型的正负无穷大
对于 double 类型的 NaN 值将就规定转换为 float 类型的 NaN 值。
尽管可能发生上限溢出、下限溢出和精度丢失等情况,但是 Java 虚拟机中数值类型的窄化转 换永远不可能导致虚拟机抛出运行时异常(此处的异常是指《Java 虚拟机规范》中定义的异常, 请读者不要与 IEEE 754 中定义的浮点异常信号产生混淆)。
2.11.5 对象创建与操作
虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节 码指令:
创建类实例的指令:new
创建数组的指令:newarray,anewarray,multianewarray
访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者成为实例变量)的指令:getfield、putfield、getstatic、putstatic
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
取数组长度的指令:arraylength
检查类实例类型的指令:instanceof、checkcast
2.11.6 操作数栈管理指令
Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:pop、pop2、dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2 和 swap。
2.11.7 控制转移指令
控制转移指令可以让 Java 虚拟机有条件或无条件地从指定指令而不是控制转移指令的下一 条指令继续执行程序。
控制转移指令包括有:
条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、 if_icmpne、if_icmplt, if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。
复合条件分支:tableswitch、lookupswitch
无条件分支:goto、goto_w、jsr、jsr_w、ret
在 Java 虚拟机中有专门的指令集用来处理 int 和 reference 类型的条件分支比较操作,为了可以无需明显标识一个实体值是否 null,也有专门的指令用来检测 null 值(§2.4)。
boolean 类型、byte 类型、char 类型和 short 类型的条件分支比较操作,都使用 int 类型的比较指令来完成
对于 long 类型、float 类型和 double 类型的条件分支比较操作,则会先执行相应类型的比较运算指令(§2.11.3),运算指令会返回一个整形值到操作数栈中,随 后再执行 int 类型的条件分支比较操作来完成整个分支跳转。
由于各种类型的比较最终都会转化 为 int 类型的比较操作,基于 int 类型比较的这种重要性,Java 虚拟机提供了非常丰富的 int 类型的条件分支指令。
所有 int 类型的条件分支转移指令进行的都是有符号的比较操作。
2.11.8 方法调用和返回指令
以下四条指令用于方法调用:
invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分 派),这也是 Java 语言中最常见的方法分派方式。
invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的 对象,找出适合的方法进行调用。
invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(§ 2.9)、私有方法和父类方法。
invokestatic 指令用于调用类方法(static 方法)。
而方法返回指令则是根据返回值的类型区分的,包括有
ireturn(当返回值是 boolean、 byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,
另外还有一条 return 指令供声明为 void 的方法、实例初始化方法、类和接口的类初始化方法使用。
2.11.9 抛出异常
在程序中显式抛出异常的操作会由 athrow 指令实现,除了这种情况,还有别的异常会在其他 Java 虚拟机指令检测到异常状况时由虚拟机自动抛出。
2.11.10 同步
Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作(§2.11.8)之中。虚拟机可以从方法常量池中的方法表结构(method_info Structure,§4.6) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有管程, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放管程。
在方法执行期 间,执行线程持有了管程,其他任何线程都无法再获得同一个管程。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法 之外时自动释放。
同步一段指令集序列通常是由 Java 语言中的 synchronized 块来表示的,Java 虚拟机的 指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义, 正确实现 synchronized 关键字需要编译器与 Java 虚拟机两者协作支持(读者可参见§3.14 中关于同步的描述)。
结构化锁定(Structured Locking)是指在方法调用期间每一个管程退出都与前面的管程 进入相匹配的情形。
因为无法保证所有提交给 Java 虚拟机执行的代码都满足结构化锁定,所以 Java 虚拟机允许(但不强制要求)通过以下两条规则来保证结构化锁定成立。假设 T 代表一条线 程,M 代表一个管程的话:
T在方法执行时持有管程M的次数必须与T在方法完成(包括正常和非正常完成)时释 放管程 M 的次数相等。
找方法调用过程中,任何时刻都不会出现线程T释放管程M的次数比T持有管程M次数 多的情况。
请注意,在同步方法调用时自动持有和释放管程的过程也被认为是在方法调用期间发生。
2.12 类库
Java 虚拟机必须对不同平台下 Java 类库的实现提供充分的支持,因为其中有一些类库如果 没有 Java 虚拟机的支持的话是根 无法实现的。
可能需要 Java 虚拟机特殊支持的类库包括有:
反射,譬如在java.lang.reflect包中的各个类和java.lang.Class类
类和接口的加载和创建,最显而易见的例子就是java.lang.ClassLoader类
类和接口的链接和初始化,上一点的例子也适用于这点
安全,譬如在java.security包中的各个类和java.lang.SecurityManager等其他类
多线程,譬如java.lang.Thread类
弱引用,譬如在java.lang.ref包中的各个类
2.13 公有设计,私有实现
到此为止, 书简单描绘了 Java 虚拟机应有的共同外观:Class 文件格式以及字节码指令 集等。
这些内容与硬件、操作系统和 Java 虚拟机的独立实现都是密切相关的,虚拟机实现者可能 更愿意把它们看做是程序在各种 Java 平台实现之间互相安全地交互的手段,而多于一张需要精确 跟随的计划蓝图。
理解公有设计与私有实现之间的分界线是非常有必要的,Java 虚拟机实现必须能够读取 Class 文件并精确实现包含在其中的 Java 虚拟机代码的语义。
只要优化后 Class 文件依然可以被正确读取,并 且包 在其中的语义能得到保持,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何 处理 Class 文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可。
实现者可以使用这种伸缩性来让 Java 虚拟机获得更高的性能、更低的内存消耗或者更好的可 移植性,选择哪种特性取决于 Java 虚拟机实现的目标和关注点是什么,虚拟机实现的方式主要有 以下两种:
将输入的Java虚拟机代码在加载时或执行时翻译成另外一种虚拟机的指令集
将输入的Java虚拟机代码在加载时或执行时翻译成宿主机CPU的 地指令集(有时候被称 Just-In-Time 代码生成或 JIT 代码生成)
精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java 虚
拟机是被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的新的、有趣的解决方案。
第 3 章 为JAVA虚拟机编译
Java 虚拟机是为了支持 Java 语言而的设计的。Oracle 的 JDK 包括两部分内容: 一部分是将 Java 源代码编译成 Java 虚拟机的指令集的编译器,另一部分是用于 Java 虚拟机的运行时环境。
请注意:术语“编译器”在某些上下文场景中专指把 Java 虚拟机的指令集转换为特定 CPU 指令集的翻译器。譬如即时代码生成器(Just-In-Time/JIT CodeGenerator)就是一种在 Class 文件中的代码被 Java 虚拟机代码加载后,生成与平台相关的特定指令的编译器。但是在本章中讨论的编译器将不考虑这类代码生成的问题,只会涉及到从使用 Java 语言编写的源代码编 译为 Java 虚拟机指令集的编译器。
3.1 示例的格式说明
3.2 常量、局部变量的使用和控制结构
Java 虚拟机是基于栈架构设计的,它的大多数操作是从当前栈帧的操作数栈取出 1 个或多个 操作数,或将结果压入操作数栈中。
每调用一个方法,都会创建一个新的栈帧,并创建对应方法所 需的操作数栈和局部变量表(参见§2.6 “栈帧”)。
每条线程在运行时的任意时刻,都会包含若 干个由不同方法嵌套调用而产生的栈帧,当然也包括了若干个栈帧内部的操作数栈,但是只有当前 栈帧中的操作数栈才是活动的。
Java 虚拟机指令集合使用不同的字节码来区分不同的操作数类型,用于操作各种类型的变量。
因为大部分 Java 虚拟机指 令操作的值是来源于操作数栈中出栈的值,而不是操作局部变量 身,所以在Java虚拟机的已编 译代码中,在局部变量表和操作数栈之间传输值的指令很常见的,在指令集里,这类操作也有特殊 地支持。
Java 虚拟机中,操作码长度为 1 个字节,这使得编译后的代码显得很紧凑。但是同样意味着 Java 虚拟机指令集必须保持一个较小的数量(小于 256 条,1 字节所能表示的范围)。
3.3 算术运算
Java 虚拟机通常基于操作数栈来进行算术运算(只有 iinc 指令例外,它直接对局部变量进行自增操作)
下面的代码是将 int 值对齐到 2 的指定幂次(其实就是向2的指定幂进位取整
算术运算使用到的操作数都是从操作数栈中弹出的,运算结果被压回操作数栈中。在内部运算 时,中间运算(Arithmetic Subcomputations)的结果可以被当作操作数使用。
~x == −1^x。
整个方法的编译代码如下:
3.4 访问运行时常量池
很多数值常量,以及对象、字段和方法,都是通过当前类的运行时常量池进行访问。
类型为 int、long、float 和 double 的数据,以及表示 String实例的引用类型数据的访问将由 ldc、ldc_w 和 ldc2_w 指令实现。
ldc 和 ldc_w 指令用于访问运行时常量池中的对象,包括 String 实例,但不包括 double 和 long 类型的值。当使用的运行时常量池的项的数目过多时(多于 256 个,1 个字节能表示的 范围),需要使用 ldc_w 指令取代 ldc 指令来访问常量池。ldc2_w 指令用于访问类型为 double 和 long 的运行时常量池项,这条指令没有非宽索引的版
3.5 更多的控制结构示例
Java 语言中还有很多其他的控制结构 (if-then-else、do、while、break 以及 continue)也有特定的编译规则。
通常情况下,Java 虚拟机对 int 类型数据提供的支持最为完善
虚拟机对各种数据类型的控制结构采用了相似的方式编译,只是根据不同数据类型使用不同的指令来访问。这么做多少会使编译代码效率降低,因为这样可能需要更多的 Java 虚拟机指令来实现。
以下为对比
int情况
double情况
每个浮点型数据都有两条比较指令:对于 float 类型是 fcmpl 和 fcmpg 指令,对于 double 是 dcmpl 和 dcmpg 指令。这些指令语义相似,仅仅在对待 NaN 变量时有所区别。NaN 是无序的, 所以如果有其中一个操作数为 NaN,则所有浮点型的比较指令都失败1。无论是比较操作是否会因 为遇到 NaN 值而失败,编译器都会根据不同的操作数类型来选择不同的比较指令
“比较失败(ComparisonsFail)”的意思是比较指令返回“fail(对于fcmpl为-1, 而 fcmpg 为 1)”的结果到操作数栈,而不是抛出异常。在 Java 虚拟机指令集中,所有的算术比较指令都不会抛 出异常。
无论是否因参数 d 传入了 NaN 值而导致的比较失败,dcmpl 指令都会向操作 数栈压入一个 int 型值,使程序进入 ifle 指令的分支。
3.6 接收参数
如果传递了 n 个参数给某个实例方法,则当前栈帧会按照约定的顺序接收这些参数,将它们 保存为方法的第 1 个至第 n 个局部变量之中。
按照约定,实例方法需要传递一个自身实例的引用作为第 0 个局部变量。在 Java 语言中自身 实例可以通过 this 关键字来访问。
类(static)方法不需要传递实例引用,所以它们不需要使用第 0 个局部变量来保存 this 关键字。
两段代码唯一的区别是,后者方法保存参数到局部变量表时,是从编号为 0 的局部变量开始 而不是 1。
3.7 方法调用
对普通实例方法调用是在运行时根据对象类型进行分派的(相当于在 C++中所说的“虚方法”),这类方法通过调用 invokevirtual 指令实现,每条 invokevirtual 指令都会带有一个表示索 引的参数,运行时常量池在该索引处的项为某个方法的符号引用,这个符号引用可以提供方法所在 对象的类型的内部二进制名称、方法名称和方法描述符
下面这个例子中定义了一个 实例方法 add12and13()来调用前面的 addTwo()方法,如下:
方法调用过程的第一步是将当前实例的自身引用 “this” 压入到操作数栈中。传递给方法的参数随后入栈。
当调用方法时,Java 虚拟机会创建一个新的栈帧, 传递给方法的参数作为新的帧的对应局部变量的初始值。
最后,当 addTwo()方法执行结束、方法返回时,int 型的返回值被压入方法调用者的栈帧 的操作数栈,即 add12and13()方法的操作数栈中。
由于调用 的 addTwo()方法返回的 int 值被压入当前操作数栈的栈顶,ireturn 指令将会把当前操作数栈 的栈顶值(此处就是 addTwo()的返回值)压入调用 add12and13()方法的操作数栈。
类方法和实例方法的调用的编译代码很类似,两者的区别仅仅是实例方法需要调用者传递 this 参数而类方法则不用。所以在两种方法的局部变量表中,序号为 0(第一个)的局部变量会 有所区别(参见§3.6 “接收参数”)。invokestatic 指令用于调用类方法。invokespecial 指令用于调用实例初始化方法(参见§3.8 “使用类实例”)
所有使用 invokespecial 指令调用的方法都需要 this 作为第一个参数,保存在 第一个局部变量之中。
如果编译器要调用某个方法,必须先产生这个方法描述符,描述符中包含了方法实际参数和返 回类型。编译器在方法调用时不会处理参数的类型转换问题,只是简单地将参数的压入操作数栈, 且不改变其类型。
3.8 使用类实例
Java 虚拟机类实例通过 Java 虚拟机 new 指令创建。
在参数传递和方法返回时,类实例(作为 reference 类型)与普通的数值类型没有太大区别, reference 类型也有它自己类型专有的 Java 虚拟机指令
无论方法调用指令的操作数,还是 putfield、getfield 指令的操作数(上面例子中运行 时常量池索引#4)都并非类实例中的地址偏移量。编译器会为这些字段生成符号引用,保存在运 行时常量池之中。这些运行时常量池项会在解析阶段转换为引用对象中真实的字段位置。
3.9 数组
在 Java 虚拟机中,数组也使用对象来表示。数组由专门的指令集来创建和操作。newarray指令用于创建元素类型为数值类型的数组。
anewarray 指令也可以用于创建多维数组的第一维。不过我们也可以选择采用 multianewarray 指令一次性创建多维数组。
multianewarray 指令的第一个操作数是运行时常量池索引,它表示将要被创建的数组的成 员类型。第二个操作数是需要创建的数组的实际维数。multianewarray 指令可以用于创建所有 类型的多维数组,譬如 create3DArray 中展示的。注意,多维数组也只是一个对象,所以使用aload_1 指令加载,使用 areturn 指令返回,更多关于数组类的命名信息在§2.9 章节中讨论。
所有的数组都有一个与之关联的长度属性,通过 arraylength 指令访问。
3.10 编译 switch 语句
Java 虚拟机的 tableswitch 和 lookupswitch 指令都只能支持 int 类型的条件值。选择 支持 int 类型是因为 byte、char 和 short 类型的值都会被隐式展为 int 型。
当 switch 语句中的 case 分支的条件值比较稀疏时,tableswitch 指令的空间使用率偏低。 这种情况下将使用 lookupswitch 指令来替代。
当 switch 语句中的 case 分支的条件值比较稀疏时,tableswitch 指令的空间使用率偏低。 这种情况下将使用 lookupswitch 指令来替代。
Java 虚拟机规定的 lookupswitch 指令的索引表必须根据 key 值排序,这样使用(如采用 二分搜索)将会比直接使用线性扫描搜索来得更有效率。
在从索引表确定分支偏移量的过程中, lookupswitch 指令是把条件值与不同的 key 的进行比较,而 tableswitch 指令则只需要索引 值进行一次范围检查。因此,在如果不需要考虑空间效率时,tableswitch 指令相比 lookupswitch 指令有更高的执行效率。
3.11 使用操作数栈
Java 虚拟机为方便使用操作数栈,提供了的大量的不区分操作数栈数据类型的指令。
这些指 令都很常用,因为 Java 虚拟机是基于栈的虚拟机,大量操作是建立在操作数栈的基础之上的。
Java 虚拟机不允许作用于操作数栈的指令修改或者拆分那些不可拆分操作数(如存放 long 或 double 的操作数)。
3.12 抛出异常和处理异常
程序中使用 throw 关键字来抛出异常,它的编译过程很简单
try-catch 结构的编译也同样简单。try 语句块被编译后似乎没有生成任何指令,就像它没有出现过一样。
在 try 语句块之后,Java 虚拟机代码实现的一个 catch 语句
在 catch 语句块里,调用 handleExc()方法的指令和正常的方法调用完全一样。不过,每 个 catch 语句块的会使编译器在异常表中增加一个成员(即一个异常处理器,§2.10, § 4.7.3)。
catchOne()方法的异常表中有一个成员,这个成员对应 catchOne()方法 catch 语 句块的一个可捕获的异常参数( 例中为 TestExc 的实例)。
如果抛出的异常不是 TestExc 实例,那么 catchOne() 的 catch 语句块则不能捕获它,这个异常将被抛出给 catchOne()方法的调用者。
如果 try 语句包 有多个 catch 语句块,那么在编译代码中,多个 catch 语句块的内容将 连续排列,在异常表中也会有对应的连续排列的成员,它们的排列的顺序和源码中的 catch 语句 块出现的顺序一致。
try-catch 语句的嵌套关系只体现在异常表之中,Java 虚拟机 身并不要求异常表中成员 (§2.10)的顺序,但是编译器需要保证 try-catch 语句是有结构顺序的,编译器会根据 catch 语句在代码中的顺序对异常处理表进行排序,以保证在代码任何位置抛出的任何异常,都会被最接 近异常抛出位置的、可处理该异常的 catch 语句块所处理。
还有一个微妙之处需要注意,catch 语句块的处理范围包括 from 但不包括 to 所表示的偏移 量 身(§4.7.3)。
3.13 编译 finally 语句块
很早之前(JDK1.4.2之前)的SunJavac已经不再为finally语句生成jsr和ret指令了, 而是改为在每个分支之后冗余代码的形式来实现 finally 语句,所以在这节开头作者需要特别说明。在版 号为 51.0(JDK 7的Class文件)的Class文件中,甚至还明确禁止了指令流中出现jsr、jsr_w指令。
编译 try-finally 语句和编译 try-catch 语句基 相同。在代码执行完 try 语句之前(无 论有没有抛出异常),finally 语句块中的内容都会被执行
有四种方式可以让程序退出 try 语句:
1.语句块所有正常执行结束;
2.通过 return 语句退 出方法;
3.通过 break 或 continue 语句退出循环;(循环中嵌套的try语句会被终止)
4.抛出异常。
3.14 同步
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。
无论是显式同步(有明确的 monitorenter 和 monitorexit 指令)还是隐式同步(依赖方法调 用和返回指令实现的)都是如此。
在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时 常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的(参见§2.11.10 “同步”)。
monitorenter 和 monitorexit 指令用于实现同步语句块
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必 须有执行其对应 monitorexit 指令,而无论这个方法是正常结束(§2.6.4)还是异常结束(§ 2.6.5)。
为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对 执行,编译器会自动产生一个异常处理器(§2.10),这个异常处理器声明可处理所有的异常,它 的目的就是用来执行 monitorexit 指令。
3.15 注解
注解(Annotation)在 Class 文件中如何表示将在§4.7.16 和§4.7.17 中详细描述, 这两节明确描述了在 Class 文件格式中,如何描述修饰类型、字段和方法的注解。
Last updated