为正确运行JVM,你只需要能够读取class文件并正确的执行文件中指定的操作指令即可。JVM标准规范不指定JVM的实现细节,比如说运行时数据区域的内存结构、垃圾回收算法以及任何其它JVM的指令优化等都可以留给实现者自己去实现!
一、class文件
运行在JVM上的编译代码是不依赖于硬件和操作系统的二进制格式的文件,通常(但不是必须)是class格式。class文件准确的定义了一个类或接口的表现形式(representation),包括在平台相关的目标文件格式中一些细节上的惯例,例如字节序等。
二、数据类型
正如Java语言,JVM运行两种数据类型:原始类型和引用类型。因此对应有两种数据类型的值用于存储在变量中、作为参数传递、作为方法返回值,即原始值(primitive value)和引用值(reference value)。
JVM希望所有的类型检查都在运行前进行,通常由编译器完成,而不需要由JVM自己来完成。原始数据类型的值可以在运行时确定其类型(
Values of primitive types need not be tagged or otherwise be inspectable to determine their types at run time, or to be distinguished from values of reference types.),JVM指令集通过使用操作特定数据类型的值的指令来区分其操作数类型,比如,idd、ladd、fadd和dadd都是JVM用于两个数值相加并得到数值结果的指令,但是各个指令的操作数类型不同,分别是int、long、float、double。
JVM支持对象类型。一个对象可以动态分配的类实例或者是一个数组。一个对象的引用具有JVM的“引用”数据类型,引用数据类型的值可以被认为是指向对象的指针。同一个对象可以存在多个引用。对象的操作、传递等都是通过“引用”数据类型的值来进行的。
三、原始数据类型与值
Java虚拟机所支持的原始数据类型包括数值类型(numeric type)、布尔类型和returnAddress类型。其中,数值类型又分为整数类型(integral type)和浮点类型(floating-point type)两种。
1. 整数类型包括:
byte类型:值为8位有符号二进制补码整数,默认值为零。
short类型:值为16位有符号二进制补码整数,默认值为零。
int类型:值为32位有符号二进制补码整数,默认值为零。
long类型:值为64位有符号二进制补码整数,默认值为零。
char类型:值为使用16位无符号整数表示的、指向基本多文种平面(Basic Multilingual Plane,BMP)的Unicode码点,以UTF-16编码,默认值为Unicode的null码点('\u0000')。
2. 浮点类型包括:
float类型:值为单精度浮点数集合中的元素,或者(如果虚拟机支持的话)是单精度扩展指数(float-extended-exponent)集合中的元素,默认值为正数0。
double类型:值为双精度浮点数集合中的元素,或者(如果虚拟机支持的话)是双精度扩展指数(double-extended-exponent)集合中的元素,默认值为正数0。
3. boolean类型:值为布尔值true和false,默认值为false。在《Java虚拟机规范(第1版)》中,boolean类型并没有作为虚拟机的原始类型进行定义,当时的Java虚拟机只对boolean类型和值进行非常有限的支持,这导致Java虚拟机的后续发展出现了许多不必要的问题和麻烦。直到《Java虚拟机规范(第2版)》时,boolean类型才以虚拟机原始类型的形式定义。在JVM中没有任何供boolean值专用的字节码指令,在java语言中凡是涉及到boolean类型的值运算,在编译之后都使用JVM中的int数据类型来代替。Java虚拟机直接支持boolean类型的数组,虚拟机的newarray指令可以创建这种数组。boolean的数组类型的访问与修改共用byte类型数组的baload和bastore指令。在Oracle公司的虚拟机实现里,Java语言里面的boolean数组将会被编码成Java虚拟机的byte数组,每个boolean元素占8位。Java虚拟机会把boolean数组元素中的true值采用数值1进行表示,false值采用数值0进行表示,当Java编译器把Java语言中的boolean类型值映射为Java虚拟机的int类型值时,也会采用上述表示方式。
4. returnAddress类型:表示一条字节码指令的操作码(opcode)。在虚拟机支持的所有原始类型中,只有returnAddress类型是不能直接与Java语言的数据类型相对应的。
四、引用类型与值
Java虚拟机中有三种引用类型:类类型(class type)、数组类型(array type)和接口类型(interface type)。这些引用类型的值分别由类实例、数组实例和实现了某个接口的类实例或数组实例动态创建。在引用类型的值中还有一个特殊的值:null,当一个引用不指向任何对象的时候,它的值就用null来表示。
一个为null的引用,在没有上下文的情况下不具备任何实际的类型,但是有具体上下文时它可转型为任意的引用类型。引用类型的默认值就是null。
Java虚拟机规范并没有规定null在虚拟机实现中应当怎样编码表示。
五、运行时数据区域
JVM定义了多种程序运行期间会用到的运行时数据区域,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
1. 程序计数寄存器(Program Counter Register)
JVM支持同一个时刻有多个线程运行,每一个JVM线程都有自己的PC寄存器。在任意时刻,每一个JVM线程只会执行一个方法代码,即所谓的当前方法。如果这个方法不是native的,那PC寄存器就保存Java虚拟机正在执行的字节码指令的地址,如果该方法是native的,那pc寄存器的值是undefined。PC寄存器的容量至少应当能保存一个returnAddress类型的数据或者一个与平台相关的本地指针的值。
2. Java虚拟机栈
每一条JVM线程都有自己私有的Java虚拟机栈,这个栈与线程同时创建,用于存储栈帧(关于栈帧,请参考下文中
第六节)。Java虚拟机栈的作用与传统语言(例如C语言)中的栈非常类似,就是用于存储局部变量与一些过程结果的地方。另外,它在方法调用和返回中也扮演了很重要的角色。因为除了栈帧的出栈和入栈之外,Java虚拟机栈不会被直接操作,所以栈帧可以在堆中分配,Java虚拟机栈所使用的内存不需要保证是连续的。
在《Java虚拟机规范》第1版中,Java虚拟机栈也称为“Java栈”。Java虚拟机规范允许Java虚拟机栈被实现成固定大小的或者根据计算动态扩展和收缩的。如果采用固定大小的Java虚拟机栈设计,那每一个线程的Java虚拟机栈容量应当在线程创建的时候独立选定。
Java虚拟机实现应当提供给程序员或者最终用户调节虚拟机栈初始容量的手段,对于可以动态扩展和收缩Java虚拟机栈来说,则应当提供调节其最大、最小容量的手段。
Java虚拟机栈可能发生如下异常情况:
如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个
StackOverflowError异常。
如果Java虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个
OutOfMemoryError异常。
3. Java堆
在Java虚拟机中,堆(heap)是可供各个线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。
Java堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统(automatic storage management system,也就是常说的garbage collector(垃圾收集器))所管理的各种对象,这些受管理的对象无需也无法显式地销毁。JVM规范并未假设采用什么具体的技术去实现自动内存管理系统。虚拟机实现者可以根据系统的实际需要来选择自动内存管理技术。Java堆的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。Java堆所使用的内存不需要保证是连续的。
Java虚拟机实现应当提供给程序员或者最终用户调节Java堆初始容量的手段,对于可以动态扩展和收缩Java堆来说,则应当提供调节其最大、最小容量的手段。
Java堆可能发生如下异常情况:
如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那Java虚拟机将会抛出一个
OutOfMemoryError异常。
4. 方法区
在Java虚拟机中,方法区(method area)是可供各个线程共享的运行时内存区域。方法区与传统语言中的编译代码存储区(storage area for compiled code)或者操作系统进程的正文段(text segment)的作用非常类似,它存储了每一个类的结构信息,例如,运行时常量池(runtime constant pool)、字段域和方法数据、构造函数和普通方法的字节码内容,包括一些在类、实例、接口初始化时用到的特殊方法(参考第九节)。
方法区在虚拟机启动的时候创建,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集。这个版本的Java虚拟机规范也不限定实现方法区的内存位置和编译代码的管理策略。方法区的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。方法区在实际内存空间中可以是不连续的。
Java虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段,对于可以动态扩展和收缩方法区来说,则应当提供调节其最大、最小容量的手段。
方法区可能发生如下异常情况:
如果方法区的内存空间不能满足内存分配请求,那么Java虚拟机将抛出一个OutOfMemoryError异常。
5. 运行时常量池
运行时常量池(runtime constant pool)是class文件中每一个类或接口的常量池表(constant_pool table)的运行时表示形式,它包括了若干种不同的常量,从编译期可知的
数值字面量到必须在运行期解析后才能获得的
方法或字段引用。运行时常量池类似于传统语言中的符号表(symbol table),不过它存储数据的范围比通常意义上的符号表要更为广泛。
每一个运行时常量池都在Java虚拟机的
方法区中分配(正如第4小节所述),在加载类和接口到虚拟机后,就创建对应的运行时常量池(参考“JVM加载、链接、初始化”的内容)。
在创建类和接口的运行时常量池时,可能会发生如下异常情况:
当创建类或接口时,如果构造运行时常量池所需要的内存空间超过了
方法区所能提供的最大值,那么Java虚拟机将会抛出一个OutOfMemoryError异常。
关于构造运行时常量池的详细信息,可以参考“JVM加载、链接、初始化”的内容。
jdk1.6及之前的版本,对于String字符串常量池是分配在方法区,jdk1.7把字符串常量池移到了堆之中。因此,会导致String的intern方法在jdk 1.6及jdk1.7上有不同的表现】
6. 本地(native)方法栈
6. 本地(native)方法栈
JVM实现可能会使用到传统的栈(通常称为C stack)来支持native方法(指使用Java以外的其他语言编写的方法)的执行,这个栈就是本地方法栈(native method stack)。当Java虚拟机使用其他语言(例如C语言)来实现指令集解释器时,也会使用到本地方法栈。如果Java虚拟机不支持native方法,并且自己也不依赖传统栈,可以无需支持本地方法栈,如果支持本地方法栈,那这个栈一般会在线程创建的时候按线程分配。
Java虚拟机规范允许本地方法栈实现成固定大小或者根据计算动态扩展和收缩。如果采用固定大小的本地方法栈,那么每一个线程的本地方法栈容量应当在栈创建的时候独立选定。
Java虚拟机实现应当提供给程序员或者最终用户调节本地方法栈初始容量的手段,对于长度可动态变化的本地方法栈来说,则应当提供调节其最大、最小容量的手段。
本地方法栈可能发生如下异常情况:
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
如果本地方法栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。
六、栈帧(frame)
Java虚拟机规范允许本地方法栈实现成固定大小或者根据计算动态扩展和收缩。如果采用固定大小的本地方法栈,那么每一个线程的本地方法栈容量应当在栈创建的时候独立选定。
Java虚拟机实现应当提供给程序员或者最终用户调节本地方法栈初始容量的手段,对于长度可动态变化的本地方法栈来说,则应当提供调节其最大、最小容量的手段。
本地方法栈可能发生如下异常情况:
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
如果本地方法栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。
六、栈帧(frame)
栈帧(frame)是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接(dynamic linking)、方法返回值和异常分派(dispatch exception)。
栈帧随着方法调用而创建,随着方法结束而销毁,无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在Java虚拟机栈之中, 每一个栈帧都有自己的局部变量表(local variable,见下文1小节)、操作数栈(operand stack,见下文2小节)和指向当前方法所属的类的运行时常量池的引用。
栈帧随着方法调用而创建,随着方法结束而销毁,无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在Java虚拟机栈之中, 每一个栈帧都有自己的局部变量表(local variable,见下文1小节)、操作数栈(operand stack,见下文2小节)和指向当前方法所属的类的运行时常量池的引用。
栈帧中允许扩展携带与Java虚拟机实现相关的一些信息,例如,对程序调试提供支持的信息。
局部变量表和操作数栈的容量在编译期确定,并通过方法的code属性(见“JVM标准规范之class文件格式http://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.3”)保存及提供给栈帧使用。因此,栈帧数据结构容量的大小仅仅取决于Java虚拟机的实现和方法调用时可分配的内存。
在给定一条线程中,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧称为当前栈帧(current frame),这个栈帧对应的方法称为当前方法(current method),定义这个方法的类称作当前类(current class)。对局部变量表和操作数栈的各种操作,通常都指的是对当前栈帧的局部变量表和操作数栈进行的操作。
如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。当一个调用新的方法时,一个新的栈帧也会随之而创建,并且随着程序控制权移交到新的方法而成为新的当前栈帧。当方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,在方法返回之后,随之丢弃当前栈帧,前一个栈帧重新成为当前栈帧。
请特别注意,栈帧是线程本地私有的数据,在一个线程中创建的栈帧不能被另外一个线程引用。
1. 局部变量表(Local Variables)
如上文所述,每个栈帧内部都包含一组称为局部变量表的变量列表。栈帧中局部变量表的长度由编译期决定,并且存储于类或接口的二进制表示之中,即通过方法的code属性(见“JVM标准规范之class文件格式http://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.3”)
的max_locals数据项保存及提供给栈帧使用。
局部变量表的容量以变量槽(Variable Slot)为最小单位,JVM规范并不明确指明一个slot占用的空间大小,只是导向性说明每个slot都应该能够保存一个类型为boolean、byte、char、short、int、float、
reference或returnAddress的数据。两个局部变量可以保存一个类型为long和double的数据。
局部变量使用索引来进行定位访问。第一个局部变量的索引值为0。局部变量的索引值是从0至小于局部变量表最大容量的所有整数。
对于64位的long和double类型的数据占用两个连续的局部变量,这两种类型的数据值采用两个局部变量中较小的索引值来定位。例如,将一个double类型的值存储在索引值为n的局部变量中,实际上的意思是索引值为n和n+1的两个局部变量都用来存储这个值。然而,索引值为n+1的局部变量是无法直接读取的,但是可能会被写入。不过,如果进行了这种操作,将会导致局部变量n的内容失效。
n的值并不要求一定是偶数,Java虚拟机也不要求double和long类型数据采用64位连续地存储在局部变量表中。(?)虚拟机实现者可以自由地选择适当的方式,通过两个局部变量来存储一个double或long类型的值。
Java虚拟机使用局部变量表来完成方法调用时的参数传递。当调用一个类方法(static方法)时,它的参数将会传递至从0开始的连续的局部变量表位置上。特别地,当调用一个实例方法(非static方法)时,
第0个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即Java语言中的this关键字)。后续的其他参数将会传递至从1开始的连续的局部变量表位置上。
当参数分配完,再根据方法体内定义的局部变量顺序和作用域分配其余的变量槽slot。
局部变量表中的变量槽是可以复用的,方法体中定义的局部变量,其作用域不一定是整个方法体,如果当前PC计数器的值已经超出某个变量的作用域,则这个变量占用的slot就可以拿给其它变量使用。如此设计不仅仅是为了节省栈空间,也影响到垃圾回收行为。当局部变量表中某个变量的slot被其它变量占用时,垃圾收集器便将该变量占用的内存回收。反之,即使当前 PC计数器值超出了某个变量的作用域,但是如果局部变量表中该变量的slot没有被占用,即局部变量表中还存在该变量的引用,则垃圾收集器不会回收该变量占用的内存。
局部变量不像类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔型变量默认为false之类的默认值。
2. 操作数栈
每个栈帧内部都包含一个称为操作数栈的后进先出(Last-In-First-Out,LIFO)栈。栈帧中操作数栈的长度由编译期决定,并且通过方法的code属性(见“JVM标准规范之class文件格式http://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.3”)
的max_stacks数据项保存及提供给栈帧使用。
在上下文明确不会产生误解的前提下,我们经常把“当前栈帧的操作数栈”直接简称为“操作数栈”。
栈帧在刚刚创建时,操作数栈是空的。Java虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据以及把操作结果重新入栈。在调用方法时,操作数栈也用来准备调用方法的参数以及接收方法返回结果。
例如,iadd字节码指令的作用是将两个int类型的数值相加,它要求在执行之前操作数栈的栈顶已经存在两个前面其他指令放入的int类型数值。在执行iadd指令时,两个int类型数值从操作栈中出栈,相加求和,然后将求和结果重新入栈。在操作数栈中,一项运算常由多个子运算(subcomputation)嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。
每一个操作数栈的位置可以保存一个Java虚拟机中定义的任意数据类型的值,包括long和double类型。
在操作数栈中的数据必须正确地操作。例如,不可以入栈两个int类型的数据,然后当做long类型去操作,或者入栈两个float类型的数据,然后使用iadd指令对它们求和。有一小部分Java虚拟机指令(例如dup和swap指令)可以不关注操作数的具体数据类型,把所有在运行时数据区中的数据当做裸类型(raw type)数据来操作,这些指令不可以用来修改数据,也不可以拆散那些原本不可拆分的数据,这些操作的正确性将会通过class文件的校验过程(见“JVM标准规范之class文件格式”http://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.10)来强制保障。
在任意时刻,操作数栈都会有一个确定的栈深度,一个long或者double类型的数据会占用两个单位的栈深度,其他数据类型则会占用一个单位的栈深度。
3. 动态链接(Dynamic Linking)
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
class文件通过符号引用(symbolic reference)来描述一个方法调用了其他方法或者成员变量,动态链接将这些符号引用所表示的方法转换为实际(concrete)方法的直接引用,
在需要时进行类加载来解析尚未被解析的符号引用(
?),并且将变量访问转化为访问这些变量的存储结构所在的运行时内存位置的正确偏移量。
由于动态链接的存在,通过后期绑定(late binding)使用的其他类的方法和变量在发生变化时,将不会对调用它们的方法构成影响。
4. 方法返回值
当一个方法被执行后,有两种方式退出该方法:正常退出和异常退出,分别称为正常调用完成(Normal Method Invocation Completion)和异常调用完成(Abrupt Method Invocation Completion)。
方法正常调用完成发生在方法的执行过程中,没有抛出任何异常,包括直接从Java虚拟机中抛出的异常以及在执行时通过throw语句显式抛出的异常的情况下。如果当前方法调用正常完成,它很可能会返回一个值给调用它的方法。方法正常完成发生在一个方法执行过程中遇到了方法返回的字节码指令时,使用哪种返回指令取决于方法返回值的数据类型(如果有返回值)。
在这种场景下,当前栈帧被用于恢复调用者的状态,包括调用者的局部变量表、操作数栈以及被正确增加用来表示执行了该方法调用指令的程序计数器等。使得调用者的代码能在被调用的方法返回并且返回值被压入调用者栈帧的操作数栈后继续正常执行。
方法异常调用完成指的是在方法中某个JVM指令执行造成JVM抛出异常,并且该异常没有在方法中处理,或者在执行过程中遇到athrow字节码指令并显式地抛出异常,同时在该方法内部没有捕获异常而导致方法退出。如果方法异常调用完成,那一定不会有方法返回值返回给其调用者。
七、对象的表示(Representation of Objects)
JVM规范不强制规定对象的任何特别的内部结构。
在一些Oracle实现的JVM中,一个类实例的引用是指向一个句柄(handle)的指针,这个句柄本身是一对指针:
其中一个指向一个包含对象方法和代表对象类型的Class对象的指针
的表(
one to a table containing the methods of the object and a pointer to the Class object that represents the type of the object,
)
,另一个指向堆中为该对象数据分配的内存。
【扩展阅读:当新建一个对象时,会在堆中为这个对象分配内存,并在栈中有一个对这个对象引用,除此之外,在Java堆中还要能通过这个对象找到它的类型信息(对象类型,父类,实现的接口,包含的字段与方法等)。Reference在Java虚拟机中定义为指向对象的引用,但没有定义这个Reference应该有怎么实现。一种实现是Reference直接存储对象在堆内的地址,对象的类型信息可以在对象在堆中的内存布局中存储,如存储在对象内存的开头等。另一种实现是Reference指向一个句柄表中的一个位置,句柄中保存了对象的实际位置及它对应的类型信息。使用句柄的好处是当在内存中移动对象的位置时,只需要更新句柄表中的内容,不需要改变引用值,但会多一次内存访问开销,直接引用的优缺点与此相反。】
八、 浮点算法
Java虚拟机采纳了《IEEE Standard for Binary Floating-Point Arithmetic》(ANSI/IEEE Std.754-1985,New York)浮点算法规范中的部分子集。
1. JVM浮点算法与IEEE 754(的浮点算法)
JVM与IEEE的浮点算法的主要区别有:
Java虚拟机中的浮点操作在遇到非法操作,如被零除(division by zero)、上限溢出(overflow)、下限溢出(underflow)和非精确(inexact)时,不会抛出exception、trap或者IEEE 754异常情况中定义的其他信号。JVM中没有信号NaN值(signaling NaN value)。
【
扩展阅读: https://en.wikipedia.org/wiki/NaN。IEEE标准中用32位bit来表示浮点数,1位符号位,8位指数,23位尾数。当指数部分全为1,尾数部分不为零时,表示这个值不是一个数值(Not a Number,NaN)。NaN永远不等于自己,所以说NaN == NaN永远为false,而NaN != NaN永远为true。这是一个相当特殊的情况:即使参与比较的两个NaN的内存表示是一模一样的,但它们仍然是不等的。对于NaN的实现有两种方式,即signaling NaN (SNAN)和 quiet NaN(QNAN)。QNAN与SNAN的不同之处在于,QNAN的尾数部分最高位定义为1,SNAN最高位定义为0。QNAN一般表示未定义的算术运算结果,比如除0运算、对负数开方、负数的对数、对大于正负1的值求反正弦/余弦等,当一个运算结果为QNAN时,并不会直接抛出任何异常,不过,软件也可以通过判断结果是否为QNAN来决定是否抛出异常;SNAN不是由算术运算结果得到的,它一般被用于标记未初始化的值,以便在使用SNAN时会立刻引发异常,然后SNAN变为QNAN。】
Java虚拟机不支持IEEE 754中的信号浮点比较(signaling floating-point comparison)。
2. 浮点模式
每个方法都有一项属性称为浮点模式(floating-point mode),取值有两种,要么是FP-strict模式要么是非FP-strict模式。方法的浮点模式决定于class文件中代表该方法的method_info结构的访问标志(access_flags)中的ACC_STRICT标志位。如果此标志位为真,则该方法的浮点模式就是FP-strict,否则就是非FP-strict模式。
如果方法所在的类是用JDK 1.1或早前版本的编译器来编译的,那么该方法的浮点数模式实际上就是非FP-strict模式。
我们说一个操作数栈具有某种给定浮点模式,所指的就是包含操作数栈的栈帧所
对应的方法具备的浮点模式,相类似,我们说一条Java虚拟机字节码指令具备某种浮点模式,所指的也是包含
这条指令的方法具备的浮点模式。
如果虚拟机实现支持单精度指数扩展集合,那么在非FP-strict模式的操作数栈上,除非数值集合转换(见3小节)明确禁止,否则float类型的值可能会超过单精度浮点数集合的取值范围。同样,如果虚拟机实现支持双精度指数扩展集合,那么在非FP-strict模式的操作数栈上,除非数值集合转换(见3小节)明确禁止,否则double类型的值可能会超过双精度浮点数集合的取值范围。
在所有其他的上下文中,无论是在操作数栈还是别的地方,
并且不论何种浮点模式,float和double两种浮点类型数值都分别限于单精度与双精度浮点数集合之中。尤其是,类和实例的字段、数组元素、本地变量和方法参数的取值范围都限于标准的数值集合之中。
【扩展阅读:
在FP-strict模式的表达式中,所有中间计算结果都必须是float值集合或者double值集合,这意味着所有FP-strict模式的表达式结果都必须是IEEE 754规范算术对操作数预期的结果,使用单精度和双精度格式表示。在某些情况下,FP-strict模式可能使得计算结果不够精确。
在非FP-strict模式的表达式中,允许实现使用扩展指数范围来表示中间结果,大体而言,实际结果就是在只使用float或double值集合可能导致上溢出overflow或下溢出underflow的情况下,使用非FP-strict模式可能产生更加精确的结果。究其原因是
因为现代计算机CPU使用80比特位(扩展指数)来进行浮点数运算,80比特位可以表示一些非标准化的数据,这些数据在32位或64位浮点数中会造成上溢出或下溢出。但是,80位可以保留更大的精度,当这样的数据出现在计算的中间结果中,同时最后的计算结果又可以使用32或64位的标准化浮点数表示时,将会等到一个比FP-strict模式更加精确的计算结果!
Java中,可以对类、接口或者方法使用(变量、构造方法不能使用)strictfp关键字来指定其遵守FP-strict模式。如果你想让你的浮点运算更加精确,而且不会因为不同的硬件平台所执行的结果不一致的话,可以用关键字strictfp。】
3. 数值集合转换
在一些特定场景下,支持扩展指数集合的Java虚拟机实现数值在标准浮点数集合与扩展指数集合之间的映射关系是允许或必要的,这种映射操作就称为数值集合转换。数值集合转换并非数据类型转换,而是在同一种数据类型的不同数值集合之间进行映射。
在数值集合转换发生的位置,虚拟机实现允许对数值执行下面操作之一:
如果一个数值是float类型,并且不是单精度浮点数集合中的元素,允许将其映射到单精度浮点数集合中数值最接近的元素。
如果一个数值是double类型,并且不是双精度浮点数集合中的元素,允许将其映射到双精度浮点数集合中数值最接近的元素。
此外,在数值集合转换发生的位置,下面的操作是必需的:
假设正在执行的Java虚拟机字节码指令是非FP-strict模式的,但这个指令导致一个float类型的值压入一个FP-strict模式的操作数栈中,或作为方法参数进行传递,或者存储进局部变量、字段或者数组元素之中。如果这个数值不是单精度浮点数集合中的元素,则必须将其映射到单精度浮点数集合中数值最接近的元素。
假设正在执行的Java虚拟机字节码指令是非FP-strict模式的,但这个指令导致一个double类型的值压入一个FP-strict模式的操作数栈中,或作为方法参数进行传递,或者存储进局部变量、字段或者数组元素之中。如果这个数值不是双精度浮点数集合中的元素,则必须将其映射到双精度浮点数集合中数值最接近的元素。
在方法调用中传递参数(包括native方法的调用),在非FP-strict模式的方法里返回浮点类型的结果到FP-strict模式的方法,或者在非FP-strict模式的方法中存储浮点类型数值到局部变量、字段或者数组元素之中时,都必须执行上述数值集合转换。
并非所有扩展指数集合中的数值都可以精确映射到标准浮点数值集合中的元素。如果进行映射的数值过大(扩展指数集合的指数可能比标准数值集合的允许最大值要大),无法在标准数值集合之中精确表示的话,这个数字将会被转化成对应类型的(正或负)无穷大。如果进行映射的数值过小(扩展指数集合的指数可能比标准数值集合的允许最小值要小),无法在标准数值集合之中精确表示,这个数字将会被转化成最接近的可以表示的非标准值或者相同正负符号的零。
数值集合转换不改变正负无穷和NaN,而且也不能改变待转换数值的符号,对于一个非浮点类型的数值,数值集合转换是无效的。
九、特殊方法
九、特殊方法
在Java虚拟机层面上,Java编程语言中的每一个构造器都以一个具有特殊名称
<init>的实例初始化方法的形式出现。该方法由编译器支持。
由于名称<init>不是一个合法的标识符,因此它不能直接在Java编程语言的程序当中。实例初始化方法只能在Java虚拟机中通过 invokespecial指令调用,并且只能在未初始化的类实例上调用。 构造器的访问权限也会约束由该构造器所衍生出来的实例初始化方法(An instance initialization method takes on the access permissions (JLS) of the constructor from which it was derived?)。
一个类或接口最多只有一个类或接口初始化方法,并且通过调用这个方法实现初始化。这个初始化方法具有特殊名称 <clinit>,没有任何参数,且返回值是void。(class文件中其它名称为<clinit>的方法是没有意义的。它们不是类或接口的初始化方法。它们既不能被java虚拟机字节码指令调用,也不能被Java虚拟机自身调用。)
在51.0及以上版本的class文件中,方法必须额外的设置 ACC_STATIC标识才能成为类或接口的初始化方法。(这个要求是在Java SE7中引入的。在50.0及以下版本的class文件中,名称为<clinit>、返回值void且没有任何参数的方法被认为是类或接口的初始化方法,不会管是否设置了ACC_STATIC标识。)
名称<clinit>是由编译器支持的。由于<clinit>不是一个合法的标识符,因此它不能直接在java编程语言的程序中使用。类和接口初始化方法是由java虚拟机隐式调用的,它们绝对不会通过Java虚拟机指令调用,是作为类初始化过程的一部分而被间接调用的。
如果一个方法满足如下条件,则该方法是签名多态的(signature polymorphic):
它由java.lang.invoke.MethodHandle类声明。
它只有一个类型Object[]的形参。
它的返回类型是Object。
它设置了ACC_VARARGS和ACC_NATIVE标识。
(在Java SE8中,只有java.lang.invoke.MethodHandle的invoke和invokeExact是签名多态性方法。)
由于名称<init>不是一个合法的标识符,因此它不能直接在Java编程语言的程序当中。实例初始化方法只能在Java虚拟机中通过 invokespecial指令调用,并且只能在未初始化的类实例上调用。 构造器的访问权限也会约束由该构造器所衍生出来的实例初始化方法(An instance initialization method takes on the access permissions (JLS) of the constructor from which it was derived?)。
一个类或接口最多只有一个类或接口初始化方法,并且通过调用这个方法实现初始化。这个初始化方法具有特殊名称 <clinit>,没有任何参数,且返回值是void。(class文件中其它名称为<clinit>的方法是没有意义的。它们不是类或接口的初始化方法。它们既不能被java虚拟机字节码指令调用,也不能被Java虚拟机自身调用。)
在51.0及以上版本的class文件中,方法必须额外的设置 ACC_STATIC标识才能成为类或接口的初始化方法。(这个要求是在Java SE7中引入的。在50.0及以下版本的class文件中,名称为<clinit>、返回值void且没有任何参数的方法被认为是类或接口的初始化方法,不会管是否设置了ACC_STATIC标识。)
名称<clinit>是由编译器支持的。由于<clinit>不是一个合法的标识符,因此它不能直接在java编程语言的程序中使用。类和接口初始化方法是由java虚拟机隐式调用的,它们绝对不会通过Java虚拟机指令调用,是作为类初始化过程的一部分而被间接调用的。
如果一个方法满足如下条件,则该方法是签名多态的(signature polymorphic):
它由java.lang.invoke.MethodHandle类声明。
它只有一个类型Object[]的形参。
它的返回类型是Object。
它设置了ACC_VARARGS和ACC_NATIVE标识。
(在Java SE8中,只有java.lang.invoke.MethodHandle的invoke和invokeExact是签名多态性方法。)
Java虚拟机在
invokevirtual指令中对签名多态性的方法进行特殊处理,以保证方法句柄能够正常调用。方法句柄是一种可以直接运行的强类型引用,它可以指向相关的方法、构造器、字段或者其他低级操作,并具有参数或返回值转换能力。这里所说的转换能力(transformation)是相当广泛的,它可以对原方法执行转化(conversion)、插入(insertion)、删除(deletion)及替换(substitution)等形式的变换,具体可参见Java SE平台API文档中java.lang.invoke包的相关信息。
十、异常
Java
虚拟机中的异常是通过
Throwable
类或者其子类的实例来表示的。抛出异常的结果是程序控制权即时的、非局部的(
nonlocal
)转换,从抛出异常的地方转移到处理异常的地方。
大多数异常是由所在线程中的某个操作同步引起的(同步异常)。相反,异步异常可能在一个程序执行的过程中随时发生。Java虚拟机抛出异常是以下三种原因之一:
l 执行了athrow指令。
l Java虚拟机同步检测到一个非正常的执行情况。这些异常不会在程序执行过程中随时抛出,而只会同步发生在如下情况的指令执行之后:
n 程序的一种可能结果是发生异常,例如:
u 当字节码指令所蕴含的操作违反了Java语言的语义,如访问一个超出数组边界范围的元素。
u 当程序在加载或者连接时出现错误。
n 在使用某个资源时超出限制,例如使用太多内存。
l 异步异常发生的原因:
n 类Thread或ThreadGroup的stop方法被调用了。
n JVM实现发生内部错误。
当某个线程调用了stop方法时,将会影响到其他的线程,或者在特定线程组中的所有线程。这时候其他线程中出现的异常就是异步异常,因为这些异常可能出现在线程执行过程的任何位置。虚拟机的内部错误也被认为是一种异步异常。
Java虚拟机规范允许在异步异常抛出之前额外执行一小段有限的代码,使得代码优化器能够检测并把这些异常在可处理它们的地方抛出,同时不违反Java语言语义。
简单的Java虚拟机实现,可以在程序执行控制权转移指令时,处理异步异常。因为程序终归是有限的,总会遇到控制权转移的指令,所以异步异常抛出的延迟时间也是有限的。由于在控制权转移指令之间(的代码)没有异步异常抛出(为什么?),那么代码生成器就可以获得一些灵活性来进行指令重排序优化,以便获取更好的性能。相关的资料推荐进一步阅读论文:“Polling Efficiently on Stock Hardware”,Marc Feeley,Proc.1993,《Conference on Functional Programming and Computer Architecture》,Copenhagen,Denmark,第179~187页。
抛出异常的动作在Java虚拟机之中是有精确的定义,当异常抛出、程序控制权发生转移的那一刻,所有在异常抛出的位置之前的字节码指令所产生的影响都应当是可以观察到的,而在异常抛出的位置之后的字节码指令,则不应当产生执行效果。如果虚拟机执行的代码是优化后的代码,有一些在异常出现位置之后的代码可能已经执行了,那这些优化过的代码必须保证被它们提前执行所产生的影响对用户程序来说都是不可见的。
由Java虚拟机执行的每个方法都会配有零至多个异常处理器(exception handler)。
异常处理器描述了其在方法代码中的有效作用范围(通过字节码偏移量范围来描述)、能处理的异常类型以及处理异常的代码所在的位置。要判断某个异常处理器是否可以处理某个具体的异常,需要同时检查异常出现的位置是否在异常处理的有效作用范围内,以及出现的异常是否是异常处理器声明可以处理的异常类型或其子类型。当抛出异常时,Java虚拟机搜索当前方法包含的各个异常处理器,如果能找到可以处理该异常的异常处理器,则将代码控制权转向异常处理器中描述的处理异常的分支之中。
如果当前方法中没有找到任何异常处理器,并且当前方法调用期间确实发生了异常,也即方法异常完成的情况,那当前方法的操作数栈和局部变量表都将被丢弃,随后它对应的栈帧出栈,并恢复到该方法调用者的栈帧中。未被处理的异常将在方法调用者的栈帧中重新被抛出,并在整个方法调用链里不断重复进行前面描述的处理过程。如果已经到达方法调用链的顶端,却还没有找到合适的异常处理器去处理这个异常,那整个执行线程都将被终止。
搜索异常处理器时的搜索顺序是很关键的,在class文件里面,每个方法的异常处理器都存储在一个表中。在运行时,当有异常抛出之后,Java虚拟机就按照class文件中的异常处理器表所描述的异常处理器的先后顺序,从前至后进行搜索。
需要注意,Java虚拟机本身不会对方法的异常处理器表进行排序或者其他方式的强制处理,所以Java语言中对异常处理的语义,实际上是通过编译器适当安排异常处理器在表中的顺序来协助完成的。只有在class文件中定义了明确的异常处理器查找顺序,才能保证无论class文件是通过何种途径产生的,Java虚拟机执行时都能有一致的行为表现。
十一、指令集总结
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。
如果忽略异常处理,那么Java虚拟机的解释器通过下面这个伪代码的循环即可有效工作:
do {
自动计算pc寄存器以及从pc寄存器的位置取出操作码;
if (存在操作数) 取出操作数;
执行操作码所定义的操作;
} while (存在更多的需要处理的指令);
字节码指令流应当都是单字节对齐的,只有
tableswitch和
lookupswitch两个指令例外,由于它们的操作数比较特殊,都是以4字节为界划分的,所以当这两个指令的参数位置不是4字节的倍数时,需要预留出相应的空位补全到4字节的倍数以实现对齐。
限制Java虚拟机操作码的长度为一个字节,并且放弃了编译后代码的参数长度对齐,是为了尽可能地获得短小精悍的编译代码,但这样做可能会使某些简单的虚拟机实现损失一些性能。由于每个操作码只能有一个字节长度,所以直接限制了整个指令集的最大数量,又由于没有假设数据是经过对齐的,所以意味着虚拟机处理那些超过一个字节的数据时,不得不在运行时从字节流中重建出具体数据的结构,这在某种程度上会损失一些性能。
1. 数据类型与JVM
在Java虚拟机的指令集中,大多数的指令都包含了其所操作的数据类型信息。例如,iload指令用于从局部变量表中加载int类型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两个指令的操作可能会是由同一段代码来实现的,但它们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令来说,它们的操作码助记符中都有特殊的字符来表明该指令为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助记符没有明确用字母指明数据类型,例如arraylength指令,它没有代表数据类型的特殊字符, 但操作数永远只能是一个数组类型的对象。还有另外一些指令,例如,无条件跳转指令goto则是与数据类型无关的。
因为Java虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码给指令集的设计带来了很大的压力。如果每一种与数据类型相关的指令都支持Java虚拟机的所有运行时数据类型,那恐怕就会超出一个字节所能表示的数量范围了。因此,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令,换句话说,指令集将会故意设计成非完全独立的(not orthogonal,即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可支持的类型。
下表1列举了Java虚拟机所支持的字节码指令集。用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令。
如果在表中指令模板与数据类型两列共同确定的单元格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如,load指令有操作int类型的iload,但是没有操作byte类型的同类指令。
表1. JVM指令集支持的数据类型
opcode | byte | short | int | long | float | double | char | reference |
---|---|---|---|---|---|---|---|---|
Tipush | bipush | sipush | ||||||
Tconst | iconst | lconst | fconst | dconst | aconst | |||
Tload | iload | lload | fload | dload | aload | |||
Tstore | istore | lstore | fstore | dstore | astore | |||
Tinc | iinc | |||||||
Taload | baload | saload | iaload | laload | faload | daload | caload | aaload |
Tastore | bastore | sastore | iastore | lastore | fastore | dastore | castore | aastore |
Tadd | iadd | ladd | fadd | dadd | ||||
Tsub | isub | lsub | fsub | dsub | ||||
Tmul | imul | lmul | fmul | dmul | ||||
Tdiv | idiv | ldiv | fdiv | ddiv | ||||
Trem | irem | lrem | frem | drem | ||||
Tneg | ineg | lneg | fneg | dneg | ||||
Tshl | ishl | lshl | ||||||
Tshr | ishr | lshr | ||||||
Tushr | iushr | lushr | ||||||
Tand | iand | land | ||||||
Tor | ior | lor | ||||||
Txor | ixor | lxor | ||||||
i2T | i2b | i2s | i2l | i2f | i2d | |||
l2T | l2i | l2f | l2d | |||||
f2T | f2i | f2l | f2d | |||||
d2T | d2i | d2l | d2f | |||||
Tcmp | lcmp | |||||||
Tcmpl | fcmpl | dcmpl | ||||||
Tcmpg | fcmpg | dcmpg | ||||||
if_TcmpOP | if_icmpOP | if_acmpOP | ||||||
Treturn | ireturn | lreturn | freturn | dreturn | areturn |
请注意,从表1中可以看出,
大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(sign-extend)为相应的int类型数据,将boolean和char类型数据零位扩展(zero-extend)为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,操作数的实际类型为boolean、byte、char及short的大多数操作,都可以用操作数的运算类型(computational type)为int的指令来完成。
在Java虚拟机中,实际类型与运算类型之间的映射关系如表2所示。
某些对操作数栈进行操作的Java虚拟机指令(例如pop和swap指令)是与具体类型无关的,不过,这些指令必须遵守运算类型分类的限制,这些分类也在表2中列出了(1类和2类)。
表2. JVM中的实际类型与运算类型
实际类型 | 运算类型 | 分类 |
---|---|---|
boolean | int | 1 |
byte | int | 1 |
char | int | 1 |
short | int | 1 |
int | int | 1 |
float | float | 1 |
reference | reference | 1 |
returnAddress | returnAddress | 1 |
long | long | 2 |
double | double | 2 |
2. 加载(load)和存储(store)指令
加载和存储指令用于将数据从栈帧的本地变量表和操作数栈之间来回传递:
将一个本地变量加载到操作数栈的指令包括:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
将一个数值从操作数栈存储到局部变量表的指令包括:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<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这几个指令)。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作数都隐含在指令中。除此之外,它们的语义与原生的通用指令完全一致(例如,iload_0的语义与操作数为0时的iload指令语义完全一致)。在尖括号之间的字母指定了指令隐含操作数的数据类型,<n>代表非负的整数,<i>代表是int类型数据,<l>代表long类型,<f>代表float类型,<d>代表double类型。操作byte、char和short类型数据时,经常用int类型的指令来表示。
这种指令表示方法在整个Java虚拟机规范之中都是通用的。
3. 算术指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结构重新压入操作数栈。大体上算术指令可以分为两种:对整型数据进行运算的指令与对浮点类型数据进行运算的指令。在每一大类中,都有针对Java虚拟机具体数据类型的专用算术指令。但没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都使用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虚拟机的指令集直接支持了在Java语言规范中描述的各种对整型及浮点类型数进行操作的语义。
Java虚拟机没有明确规定整型数据溢出的情况,只有整数除法指令(idiv和ldiv)及整数求余指令(irem和lrem)在除数为零时会导致虚拟机抛出异常。如果发生了这种情况,虚拟机将会抛出ArithmeticException异常。
Java虚拟机在处理浮点数时,必须遵循IEEE 754标准中所规定的行为限制。也就是说,Java虚拟机要求完全支持IEEE 754中定义的非标准浮点数值和逐级下溢(gradual underflow)。这使得开发者更容易判断出某些数值算法是否满足预期的特征。
Java虚拟机要求在进行浮点数运算时,所有的运算结果都必须舍入到适当的精度,非精确的结果必须舍入为可表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,那将优先选择最低有效位为0的。这种舍入模式也是IEEE 754标准中的默认舍入模式,称为
向最接近数舍入模式。
在把浮点类型数转换为整型数时,Java虚拟机使用IEEE 754标准中的向零舍入模式,这种模式的舍入结果会导致数字被截断,所有表示小数部分的有效位都会被丢弃。向零舍入模式将在目标数值类型中选择一个值最接近,但是在绝对值上不大于原值的数字来作为舍入结果。
Java虚拟机在处理浮点类型数运算时,不会抛出任何运行时异常(这里所讲的是Java的异常,请勿与IEEE 754标准中的浮点异常互相混淆),当一个操作向上溢出时,将会使用有符号的无穷大来表示,当一个操作向下溢出时,会产生非标准值,或带符号的0值。如果某个操作结果没有明确的数学定义,将会使用NaN值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回NaN。
在对long类型数进行比较(lcmp)时,虚拟机采用带符号的比较方式,而
对浮点类型数进行比较时(dcmpg、dcmpl、fcmpg、fcmpl),虚拟机采用IEEE 754标准所定义的无信号比较(nonsignaling comparison)方式(?)。
4. 类型转换指令
类型转换指令可以在两种Java虚拟机数值类型之间相互转换。这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来解决Java虚拟机字节码指令的不完备问题(见第1小节所述)。
Java虚拟机直接支持以下数值的宽化类型转换(widening numeric conversion,小范围类型向大范围类型的安全转换):
从int类型到long、float或者double类型
从long类型到float、double类型
从float类型到double类型
宽化类型转换指令包括:i2l、i2f、i2d、l2f、l2d和f2d。从这些操作码的助记符中可以很容易知道转换的源和目标类型的名字,两个类型名中间的“2”(two)表示“to”的意思。例如,i2d指令就代表从int转换到double。
宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从int转换到long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的。在FP-strict模式下,从float转换到double也是可以保证转换前后精确相等,但是在非FP-strict模式下,则不能保证这一点。
从int或者long类型数值转换到float,或者long类型数值转换到double时,将可能发生精度丢失——可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE 754最接近舍入模式所得到的正确整数值。
尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致Java虚拟机抛出运行时异常(注意,这里的异常不要与IEEE 754中的浮点异常信号混淆了)。
从int到long的宽化类型转换是一个简单的带符号扩展操作,即把int数值的二进制补码表示扩充至更宽的格式。从char到一个整数类型的宽化类型转换是零位扩展,即直接给char的二进制形式添上若干个0,以填充成更宽的格式。
需要注意,从byte、char和short类型到int类型的宽化类型转换实际上是不存在的,其中原因在第1小节提到过:byte、char和short类型值在虚拟机内部本来就是按更宽的int类型来存储的,所以这些类型的转换自然就完成了。
Java虚拟机也直接支持以下窄化类型转换:
从int类型到byte、short或者char类型
从long类型到int类型
从float类型到int或者long类型
从double类型到int、long或者float类型
窄化类型转换(narrowing numeric conversion)指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度。
在将int或long类型窄化转换为整数类型T时,转换过程仅仅是简单丢弃除最低N个二进制位以外的内容,其中N是表示类型T所需的二进制位个数。这将可能导致转换结果与输入值有不同的正负号。
在将一个浮点类型数值窄化转换为整数类型T(其中T限于int或long类型)时,将遵循以下转换规则:
如果浮点类型数值是NaN,那转换结果就是int或long类型的0。
否则,如果浮点类型数值不是无穷,那么浮点类型数值就依照IEEE 754标准的向零舍入模式取整,获得整型数值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向最接近数舍入模式舍入得到一个可以使用float类型表示的数值。如果转换结果的绝对值太小无法使用float来表示,将返回float类型的正负0。如果转换结果的绝对值太大无法使用float来表示,将返回float类型的正负无穷大,double类型的NaN值将转换为float类型的NaN值。
尽管可能发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机中数值类型的窄化转换永远不可能导致虚拟机抛出运行时异常(此处的异常是指Java虚拟机规范中定义的异常,请不要与IEEE 754中定义的浮点异常信号混淆)。
5. 对象的创建与操作
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:
创建类实例的指令:new。
创建数组的指令:newarray、anewarray、multianewarray。
访问类字段(static字段,或者称为类变量)和类实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic。
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore。
取数组长度的指令:arraylength。
检查类实例或数组类型的指令:
instanceof、checkcast。
【
扩展阅读:关于instanceof的实现原理,可阅读:
http://stackoverflow.com/questions/19199111/how-is-instanceof-implemented-in-the-jvm
http://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.instanceof】
6. 操作数栈管理指令
Java虚拟机提供了一些用于直接控制操作数栈的指令,包括:pop、pop2、dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2和swap。
7. 控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定指令而不是控制转移指令的下一条指令继续执行程序。控制转移指令包括:
条件分支:ifeq、ifne、iflt、ifle、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmple、if_icmpgt、if_icmpge、if_acmpeq和if_acmpne。
复合条件分支:tableswitch、lookupswitch。
无条件分支:goto、goto_w、jsr、jsr_w、ret。
Java虚拟机中有专门的条件分支指令集用来处理int和reference类型的比较操作,而且也有专门的指令用来检测null值,所以无需用某个具体的值来表示null(见第四节)。
boolean、byte、char和short类型的条件分支比较操作,都使用int类型的比较指令来完成,而对于long、float和double类型的条件分支比较操作,则会先执行相应类型的比较运算指令(见第3小节),运算指令会返回一个整型数值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。
由于各种类型的比较最终都会转化为int类型的比较操作,所以基于int类型比较的重要性,Java虚拟机提供了非常丰富的int类型的条件分支指令。
所有int类型的条件分支转移指令进行的都是有符号的比较操作。
8
. 方法调用和返回指令
以下5条指令用于方法调用:
invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)。这也是Java语言中最常见的方法分派方式。
invokeinterface指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。
invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(见第九节)、私有方法和父类方法。
invokestatic指令用于调用命名类中的类方法(static方法)。
invokedynamic指令用于调用绑定了该invokedynamic指令的调用点对象(call site object)的目标方法。调用点对象是一个特殊的语法结构,当一条invokedynamic指令首次被Java虚拟机执行前,Java虚拟机将会执行一个引导方法(bootstrap method)并以这个方法的运行结果作为调用点对象。因此,每条invokedynamic指令都有独一无二的链接状态,这是它与其他方法调用指令的一个差异。(?)
方法返回指令根据返回值的类型进行区分,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。
9. 抛出异常
在程序中显式抛出异常的操作由athrow指令实现,除了这种情况,还有别的异常会在其他Java虚拟机指令检测到异常状况时由虚拟机自动抛出。
10. 同步
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用监视器(monitor)来支持的。
方法级的同步是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作(见第.8小节)之中。虚拟机可以从方法常量池中的方法表结构(method_info structure)中的
ACC_SYNCHRONIZED标志来区分一个方法是否是同步方法,方法调用指令将会检查方法的
ACC_SYNCHRONIZED标志是否设置。如果设置了,执行线程将先进入监视器(monitor),然后执行方法,最后在方法完成(无论是正常完成还是非正常完成)时退出监视器。在方法执行期间,执行线程持有了监视器,其他任何线程都无法再进入该监视器。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法将在异常抛到同步方法之外前自动退出监视器。
指令序列的同步通常用来表示Java语言中的synchronized块,Java虚拟机的指令集中有monitorenter和monitorexit两个指令来支持这种synchronized关键字的语义。正确实现synchronized关键字需要编译器与Java虚拟机两者协作支持。
结构化锁定(structured locking)是指在方法调用期间每一个监视器的退出都能匹配到一个前面的监视器进入的情形。因为无法保证所有提交给Java虚拟机执行的代码都满足结构化锁定,所以Java虚拟机允许(但不强制要求)通过以下两条规则来保证结构化锁定成立。假设T代表一个线程,M代表一个监视器,那么:
1.T在一个方法执行期间进入监视器M的次数必须与T在该方法执行期间退出监视器M的次数相等,不管该方法是正常完成还是异常完成。
2.在方法调用过程中,任何时刻都不会出现线程T退出监视器M的次数比T持有进入监视器M次数多的情况。
请注意,当调用一个同步(synchronized)方法时,由JVM自动执行的监视器的进入和退出也被认为是在方法调用期间发生。(
Note that the monitor entry and exit automatically performed by the Java Virtual Machine when invoking a synchronized method are considered to occur during the calling method's invocation.?)
十二、类库
Java虚拟机必须对Java SE平台下的类库实现提供充分的支持,因为其中有一些类库如果没有Java虚拟机的支持是根本无法实现的。
可能需要Java虚拟机特殊支持的类包括:
l
反射,例如在java.lang.reflect包中的各个类和Class类。
l
加载和创建类或接口的类,最显而易见的例子就是ClassLoader类。
l
连接和初始化类或接口的类,刚才说的ClassLoader也属于这样的类。
l
安全,例如在java.security包中的各个类和SecurityManager等其他类。
l
多线程,譬如Thread类。
l
弱引用,譬如在java.lang.ref包中的各个类。
上面列举的几点旨在简单说明而不是详细介绍这些类库,详细列举这些类及其功能已经超出了本书的范围。如果读者想了解这些类库,请阅读Java平台的类库说明书。
十三、
公有设计、私有实现
到目前为止,JVM规范描绘了Java虚拟机的公有外观(public view):class文件格式以及字节码指令集等。这些内容与Java虚拟机的硬件独立性、操作系统独立性以及实现独立性都是密切相关的。虚拟机实现者可能更愿意把它们看做程序在各种Java平台实现之间安全交互的手段,而不是一张需要精确遵从的蓝图。
理解公有设计与私有实现之间的分界线是非常有必要的,Java虚拟机实现必须能够读取class文件并精确实现包含在其中的Java虚拟机代码的语义。根据本规范一成不变地逐字实现其中要求的内容当然是一种可行的途径,但实现者在本规范约束下对具体实现做出修改和优化也是完全可行的,并且也推荐这样做。只要优化后class文件依然可以正确读取,并且包含在其中的语义能得到保持,实现者就可以选择任何方式去实现这些语义,虚拟机内部如何处理class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可。
这里多少存在一些例外,例如,调试器(debugger)、性能监视器(profiler)和即时代码生成器(just-in-time code generator)等都可能需要访问一些通常被认为是虚拟机“内部”的元素。在适当的情况下,Oracle会与其他Java虚拟机实现者以及工具提供商一起开发这类Java虚拟机工具的通用接口,并推广这些接口,令其可以在整个行业中通用。
实现者可以使用这种伸缩性来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的可移植性,选择哪种改装方式取决于Java虚拟机实现的目标。虚拟机实现可以考虑的方式主要有以下两种:
l
将输入的Java虚拟机代码在加载时或执行时翻译成另外一种虚拟机的指令集。
l
将输入的Java虚拟机代码在加载时或执行时翻译成宿主机CPU的本地指令集(有时候称Just-In-Time代码生成或JIT代码生成)。
精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机支持众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的新的、有趣的解决方案。
参考:
http://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html
http://book.51cto.com/art/201312/421889.htm
http://www.360doc.com/content/14/0925/13/1073512_412236522.shtml
http://www.cppblog.com/sosi/archive/2010/10/16/130166.html
http://stackoverflow.com/questions/2506355/what-does-fpstrict-do-in-java
http://www.cnblogs.com/konlil/archive/2011/07/06/2099646.html
http://www.cnblogs.com/angeldevil/p/3801189.html
https://en.wikipedia.org/wiki/NaN