Java 虚拟机结构

一 数据类型

与 Java 程序语言中的数据类型相似,Java 虚拟机可以操作的数据类型可分为两类:原始类型(Primitive Types,也经常翻译为原生类型或者基本类型)和引用类型(Reference Types)。 与之对应,也存在有原始值(Primitive Values)和引用值(Reference Values)两种类型的数值可用于变量赋值、参数传递、方法返回和运算操作。

二 原始类型与值

Java 虚拟机所支持的原始数据类型包括了数值类型(Numeric Types)、布尔类型(Boolean Type)和 returnAddress 类型三类。其中数值类型又分为整型类型(Integral Types)和浮点类型(Floating-Point Types)两种,

其中整数类型包括:

  • byte 类型:值为 8 位有符号二进制补码整数,默认值为零。

  • short 类型:值为 16 位有符号二进制补码整数,默认值为零。

  • int 类型:值为 32 位有符号二进制补码整数,默认值为零。

  • long 类型:值为 64 位有符号二进制补码整数,默认值为零。

  • char 类型:值为使用 16 位无符号整数表示的、指向基本多文本平面(Basic Multilingual Plane,BMP)的 Unicode 值,以 UTF-16 编码,默认值为 Unicode 的 null 值('\u0000')。

浮点类型包括:

  • float 类型:值为单精度浮点数集合②中的元素,或者(如果虚拟机支持的话)是单精度 扩展指数(Float-Extended-Exponent)集合中的元素。默认值为正数零。

  • double 类型:取值范围是双精度浮点数集合中的元素,或者(如果虚拟机支持的话)是 双精度扩展指数(Double-Extended-Exponent)集合中的元素。默认值为正数零。 布尔类型:

  • boolean 类型:取值范围为布尔值 true 和 false,默认值为 false。 returnAddress 类型:

  • returnAddress 类型:表示一条字节码指令的操作码(Opcode)。在所有的虚拟机支 持的原始类型之中,只有 returnAddress 类型是不能直接 Java 语言的数据类型对应 起来的。

2.1 整型类型与整型值

Java 虚拟机中的整型类型的取值范围如下:

  • 对于 byte 类型,取值范围是从 -128 至 127(-27至 27-1),包括 -128 和 127。

  • 对于 short 类型,取值范围是从 −32768 至 32767(-215至 215-1),包括 −32768 和 32767。

  • 对于 int 类型,取值范围是从 −2147483648 至 2147483647(-231至 231-1),包括 −2147483648 和 2147483647。

  • 对于 long 类型,取值范围是从−9223372036854775808 至 9223372036854775807 (-263 至 263-1),包括 −9223372036854775808 和 9223372036854775807。 

  • 对于 char 类型,取值范围是从 0 至 65535,包括 0 和 65535。

 

2.2 浮点类型、取值集合及浮点值

浮点类型包含 float 类型和 double 类型两种,它们在概念上与《IEEE Standard for Binary Floating-Point Arithmetic》ANSI/IEEE Std. 754-1985(IEEE, New York) 标准中定义的 32 位单精度和 64 位双精度 IEEE 754 格式取值和操作都是一致的。

IEEE 754 标准的内容不仅包括了正负带符号可数的数值(Sign-Magnitude Numbers), 还包括了正负零、正负无穷大和一个特殊的“非数字”标识(Not-a-Number,下文用 NaN 表示)。 NaN 值用于表示某些无效的运算操作,例如除数为零等情况。

所有 Java 虚拟机的实现都必须支持两种标准的浮点数值集合:单精度浮点数集合和双精度浮 点数集合。另外,Java 虚拟机实现可以自由选择是否要支持单精度扩展指数集合和双精度扩展指 数集合,也可以选择支持其中的一种或全部。这些扩展指数集合可能在某些特定情况下代替标准浮 点数集合来表示 float 和 double 类型的数值。

2.3 returnAddress 类型和值

returnAddress 类型会被 Java 虚拟机的 jsr、ret 和 jsr_w 指令所使用。 returnAddress 类型的值指向一条虚拟机指令的操作码。与前面介绍的那些数值类的原始类型 不同,returnAddress 类型在 Java 语言之中并不存在相应的类型,也无法在程序运行期间更改 returnAddress 类型的值。

2.4 boolean 类型

虽然 Java 虚拟机定义了 boolean 这种数据类型,但是只对它提供了非常有限的支持。在 Java 虚拟机中没有任何供 boolean 值专用的字节码指令,在 Java 语言之中涉及到 boolean 类型值的运算,在编译之后都使用 Java 虚拟机中的 int 数据类型来代替。 Java 虚拟机直接支持 boolean 类型的数组,虚拟机的 newarray 指令可以创建这种数组。boolean 的数组类型的访问与修改共用 byte 类型数组的 baload 和 bastore 指令。

三 引用类型与值 Java

虚拟机中有三种引用类型:类类型(Class Types)、数组类型(Array Types)和 接口类型(Interface Types)。这些引用类型的值分别由类实例、数组实例和实现了某个接口 的类实例或数组实例动态创建。

其中,数组类型还包含一个单一维度(即长度不由其类型决定)的组件类型(Component Type),一个数组的组件类型也可以是数组。但从任意一个数组开始,如果发现其组件类型也是数 组类型的话,继续重复取这个数组的组件类型,这样操作不断执行,最终一定可以遇到组件类型不 是数组的情况,这时就把这种类型成为数组类型的元素类型(Element Type)。数组的元素类型 必须是原始类型、类类型或者接口类型之中的一种。

在引用类型的值中还有一个特殊的值:null,当一个引用不指向任何对象的时候,它的值就 用 null 来表示。一个为 null 的引用,在没有上下文的情况下不具备任何实际的类型,但是有具 体上下文时它可转型为任意的引用类型。引用类型的默认值就是 null。

Java 虚拟机规范并没有规定 null 在虚拟机实现中应当怎样编码表示。

四 运行时数据区 Java

虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机 启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区 域会随着线程开始和结束而创建和销毁。

4.1 PC 寄存器

Java 虚拟机可以支持多条线程同时执行(可参考《Java 语言规范》第 17 章),每一条 Java 虚拟机线程都有自己的 PC(Program Counter)寄存器。在任意时刻,一条 Java 虚拟机线程 只会执行一个方法的代码,这个正在被线程执行的方法称为该线程的当前方法(Current Method)。如果这个方法不是 native 的,那 PC 寄存器就保存 Java 虚拟机正在执行的 字节码指令的地址,如果该方法是 native 的,那 PC 寄存器的值是 undefined。PC 寄存器的容 量至少应当能保存一个 returnAddress 类型的数据或者一个与平台相关的本地指针的值。 

4.2 Java 虚拟机栈

每一条 Java 虚拟机线程都有自己私有的 Java 虚拟机栈(Java Virtual Machine Stack),这个栈与线程同时创建,用于存储栈帧(Frames)。Java 虚拟机栈的作用与传统语 言(例如 C 语言)中的栈非常类似,就是用于存储局部变量与一些过程结果的地方。另外,它在 方法调用和返回中也扮演了很重要的角色。因为除了栈帧的出栈和入栈之外,Java 虚拟机栈不会 再受其他因素的影响,所以栈帧可以在堆中分配,Java 虚拟机栈所使用的内存不需要保证是连 续的。

Java 虚拟机规范允许 Java 虚拟机栈被实现成固定大小的或者是根据计算动态扩展和收缩的。如果采用固定大小的 Java 虚拟机栈设计,那每一条线程的 Java 虚拟机栈容量应当在线程创 建的时候独立地选定。Java 虚拟机实现应当提供给程序员或者最终用户调节虚拟机栈初始容量的 手段,对于可以动态扩展和收缩 Java 虚拟机栈来说,则应当提供调节其最大、最小容量的手段。 Java 虚拟机栈可能发生如下异常情况:

  • 如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量时,Java 虚拟机将会抛出一 个 StackOverflowError 异常。

  • 如果 Java 虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚 拟机将会抛出一个 OutOfMemoryError 异常。 

4.3 Java 堆

在 Java 虚拟机中,堆(Heap)是可供各条线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。

Java 堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统(Automatic Storage Management System,也即是常说的“Garbage Collector(垃圾收集器)”)所管理的各种 对象,这些受管理的对象无需,也无法显式地被销毁。本规范中所描述的 Java 虚拟机并未假设采用什么具体的技术去实现自动内存管理系统。虚拟机实现者可以根据系统的实际需要来选择自动内存管理技术。Java 堆的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需 要过多空间时自动收缩。Java 堆所使用的内存不需要保证是连续的。

Java 虚拟机实现应当提供给程序员或者最终用户调节 Java 堆初始容量的手段,对于可以动态扩展和收缩 Java 堆来说,则应当提供调节其最大、最小容量的手段。

Java 堆可能发生如下异常情况:

  • 如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。 

4.4 方法区

在 Java 虚拟机中,方法区(Method Area)是可供各条线程共享的运行时内存区域。方法区与传统语言中的编译代码储存区(Storage Area Of Compiled Code)或者操作系统进程 的正文段(Text Segment)的作用非常类似,它存储了每一个类的结构信息,例如运行时常量 池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容、还包 括一些在类、实例、接口初始化时用到的特殊方法。

方法区在虚拟机启动的时候被创建,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集。这个版本的 Java 虚拟机规范也不限定实现方法区的内存位置和编译代码的管理策略。方法区的容量可以是固定大小的,也可以随着程序执行的需求动态扩展, 并在不需要过多空间时自动收缩。方法区在实际内存空间中可以是不连续的。 Java 虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段,对于可以动态 扩展和收缩方法区来说,则应当提供调节其最大、最小容量的手段。 方法区可能发生如下异常情况:

  • 如果方法区的内存空间不能满足内存分配请求,那 Java 虚拟机将抛出一个 OutOfMemoryError 异常。

4.5 运行时常量池

运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行 期解析后才能获得的方法或字段引用。运行时常量池扮演了类似传统语言中符号表(Symbol Table)的角色,不过它存储数据范围比通常意义上的符号表要更为广泛。

每一个运行时常量池都分配在 Java 虚拟机的方法区之中,在类和接口被加载到虚拟机后,对应的运行时常量池就被创建出来。

在创建类和接口的运行时常量池时,可能会发生如下异常情况:

  • 当创建类或接口的时候,如果构造运行时常量池所需要的内存空间超过了方法区所能提供的最 大值,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。 

4.6 本地方法栈

Java 虚拟机实现可能会使用到传统的栈(通常称之为“C Stacks”)来支持 native 方法 (指使用 Java 以外的其他语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)。当 Java 虚拟机使用其他语言(例如 C 语言)来实现指令集解释器时,也会使用到本地 方法栈。如果 Java 虚拟机不支持 natvie 方法,并且自己也不依赖传统栈的话,可以无需支持本 地方法栈,如果支持本地方法栈,那这个栈一般会在线程创建的时候按线程分配。

Java 虚拟机规范允许本地方法栈被实现成固定大小的或者是根据计算动态扩展和收缩的。如 果采用固定大小的本地方法栈,那每一条线程的本地方法栈容量应当在栈创建的时候独立地选定。 一般情况下,Java 虚拟机实现应当提供给程序员或者最终用户调节虚拟机栈初始容量的手段,对 于长度可动态变化的本地方法栈来说,则应当提供调节其最大、最小容量的手段。 本地方法栈可能发生如下异常情况:

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量时,Java 虚拟机将会抛出一个 StackOverflowError 异常。

  • 如果本地方法栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存 去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的本地方法栈,那 Java 虚拟 机将会抛出一个 OutOfMemoryError 异常。

五 栈帧

栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。

栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出 了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在 Java 虚拟机栈之中,每一个栈帧都有自己的局部变量表(Local Variables,§2.6.1)、操作数栈(Operand Stack)和指向当前方法所属的类的运行时常量池的引用。

局部变量表和操作数栈的容量是在编译期确定,并通过方法的 Code 属性保存及 提供给栈帧使用。因此,栈帧容量的大小仅仅取决于 Java 虚拟机的实现和方法调用时可被分配的 内存。

在一条线程之中,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧就被称为是当前栈 帧(Current Frame),这个栈帧对应的方法就被称为是当前方法(Current Method),定义 这个方法的类就称作当前类(Current Class)。对局部变量表和操作数栈的各种操作,通常都 指的是对当前栈帧的对局部变量表和操作数栈进行的操作。

如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧 了。当一个新的方法被调用,一个新的栈帧也会随之而创建,并且随着程序控制权移交到新的方法 而成为新的当前栈帧。当方法返回的之际,当前栈帧会传回此方法的执行结果给前一个栈帧,在方法返回之后,当前栈帧就随之被丢弃,前一个栈帧就重新成为当前栈帧了。

请读者特别注意,栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一条线程的栈帧。 

5.1 局部变量表

每个栈帧内部都包含一组称为局部变量表(Local Variables)的变量列表。栈帧中局部变量表的长度由编译期决定,并且存储于类和接口的二进制表示之中,既通过方法的 Code 属性保存及提供给栈帧使用。 一个局部变量可以保存一个类型为 boolean、byte、char、short、float、reference 和 returnAddress 的数据,两个局部变量可以保存一个类型为 long 和 double 的数据。 局部变量使用索引来进行定位访问,第一个局部变量的索引值为零,局部变量的索引值是从零 至小于局部变量表最大容量的所有整数。 long 和 double 类型的数据占用两个连续的局部变量,这两种类型的数据值采用两个局部变 量之中较小的索引值来定位。例如我们讲一个 double 类型的值存储在索引值为 n 的局部变量中, 实际上的意思是索引值为 n 和 n+1 的两个局部变量都用来存储这个值。索引值为 n+1 的局部变量 是无法直接读取的,但是可能会被写入,不过如果进行了这种操作,就将会导致局部变量 n 的内 容失效掉。

上文中提及的局部变量 n 的 n 值并不要求一定是偶数,Java 虚拟机也不要求 double 和 long 类型数据采用 64 位对其的方式存放在连续的局部变量中。虚拟机实现者可以自由地选择适当的方 式,通过两个局部变量来存储一个 double 或 long 类型的值。

Java 虚拟机使用局部变量表来完成方法调用时的参数传递,当一个方法被调用的时候,它的 参数将会传递至从 0 开始的连续的局部变量表位置上。特别地,当一个实例方法被调用的时候, 第 0 个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即 Java 语言中的“this” 关键字)。后续的其他参数将会传递至从 1 开始的连续的局部变量表位置上。

5.2 操作数栈

每一个栈帧(§2.6)内部都包含一个称为操作数栈(Operand Stack)的后进先出 (Last-In-First-Out,LIFO)栈。栈帧中操作数栈的长度由编译期决定,并且存储于类和接 口的二进制表示之中,既通过方法的 Code 属性(§4.7.3)保存及提供给栈帧使用。 在上下文明确,不会产生误解的前提下,我们经常把“当前栈帧的操作数栈”直接简称为“操 作数栈”。 操作数栈所属的栈帧在刚刚被创建的时候,操作数栈是空的。Java 虚拟机提供一些字节码指 令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于 从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用的时候,操作数栈也用来准备 调用方法的参数以及接收方法返回结果。

举个例子,iadd 字节码指令的作用是将两个 int 类型的数值相加,它要求在执行的之前操作 数栈的栈顶已经存在两个由前面其他指令放入的 int 型数值。在 iadd 指令执行时,2 个 int 值 从操作栈中出栈,相加求和,然后将求和结果重新入栈。在操作数栈中,一项运算常由多个子运算 (Subcomputations)嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。

每一个操作数栈的成员(Entry)可以保存一个 Java 虚拟机中定义的任意数据类型的值,包 括 long 和 double 类型。

在操作数栈中的数据必须被正确地操作,这里正确操作是指对操作数栈的操作必须与操作数栈 栈顶的数据类型相匹配,例如不可以入栈两个 int 类型的数据,然后当作 long 类型去操作他们, 或者入栈两个 float 类型的数据,然后使用 iadd 指令去对它们进行求和。有一小部分 Java 虚拟机指令(例如 dup 和 swap 指令)可以不关注操作数的具体数据类型,把所有在运行时数据区 中的数据当作裸类型(Raw Type)数据来操作,这些指令不可以用来修改数据,也不可以拆散那 些原本不可拆分的数据,这些操作的正确性将会通过 Class 文件的校验过程来强制保 障。

在任意时刻,操作数栈都会有一个确定的栈深度,一个 long 或者 double 类型的数据会占用 两个单位的栈深度,其他数据类型则会占用一个单位深度。

六 动态链接

每一个栈帧内部都包含一个指向运行时常量池的引用来支持当前方法的代码实现动态链接(Dynamic Linking)。在 Class 文件里面,描述一个方法调用了其他方法, 或者访问其成员变量是通过符号引用(Symbolic Reference)来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用。类加载的过程中将要解析掉尚未被解析的符号引用,并且将变量访问转化为访问这些变量的存储结构所在的运行时内存位置的正确偏移 量。

由于动态链接的存在,通过晚期绑定(Late Binding)使用的其他类的方法和变量在发生变化时,将不会对调用它们的方法构成影响。

七 初始化方法的特殊命名

在 Java 虚拟机层面上,Java 语言中的构造函数在《Java 语言规范 (第三版)》(下文简称 JLS3)是以一个名为的特殊实例初始化方法的形式出现的,这个方法名 称是由编译器命名的,因为它并非一个合法的 Java 方法名字,不可能通过程序编码的方式实现。 实例初始化方法只能在实例的初始化期间,通过 Java 虚拟机的 invokespecial 指令来调用, 只有在实例正在构造的时候,实例初始化方法才可以被调用访问(JLS3)。

一个类或者接口最多可以包含不超过一个类或接口的初始化方法,类或者接口就是通过这个方 法完成初始化的。这个方法是一个不包含参数的静态方法,名为 <clinit> 。这个名字也是由编译器命名的,因为它并非一个合法的 Java 方法名字,不可能通过程序编码的方式实现。 类或接口的初始化方法由 Java 虚拟机自身隐式调用,没有任何虚拟机字节码指令可以调用这个方 法,只有在类的初始化阶段中会被虚拟机自身调用。

八 字节码指令集简介

Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(Opcode)以及 跟随其后的零至多个代表此操作所需参数的操作数(Operands)所构成。虚拟机中许多指令并不 包含操作数,只有一个操作码。

如果忽略异常处理,那 Java 虚拟机的解释器使用下面这个伪代码的循环即可有效地工作:

do {
  自动计算 PC 寄存器以及从 PC 寄存器的位置取出操作码; 
  if (存在操作数) 取出操作数;
  执行操作码所定义的操作 }
while (处理下一次循环);

操作数的数量以及长度取决于操作码,如果一个操作数的长度超过了一个字节,那它将会以 Big-Endian 顺序存储——即高位在前的字节序。举个例子,如果要将一个 16 位长度的无符号整 数使用两个无符号字节存储起来(将它们命名为 byte1 和 byte2),那它们的值应该是这样的:

   (byte1 << 8) | byte2

字节码指令流应当都是单字节对齐的,只有“tableswitch”和“lookupswitch”两条指 令例外,由于它们的操作数比较特殊,都是以 4 字节为界划分开的,所以这两条指令那个也需要 预留出相应的空位来实现对齐。

限制 Java 虚拟机操作码的长度为一个字节,并且放弃了编译后代码的参数长度对齐,是为了

8.1 数据类型与Java 虚拟机

在 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,即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必 要的时候用来将一些不支持的类型转换为可被支持的类型。

大部分的指令都没有支持整数类型 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)。

在 Java 虚拟机中,实际类型与运算类型之间的映射关系,如表 2.3 所示。

 

8.2 加载和存储指令

加载和存储指令用于将数据从栈帧(§2.6)的局部变量表(§2.6.1)和操作数栈之间来回 传输(§2.6.2):

  • 将一个局部变量加载到操作栈的指令包括有:iload、iload_、lload、lload_、 fload、fload_、dload、dload_、aload、aload_

  • 将一个数值从操作数栈存储到局部变量表的指令包括有:istore、istore_、 lstore、lstore_、fstore、fstore_、dstore、dstore_、astore、 astore_

  • 将一个常量加载到操作数栈的指令包括有:bipush、sipush、ldc、ldc_w、ldc2_w、 aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_

  • 扩充局部变量表的访问索引的指令:wide

访问对象的字段或数组元素的指令也同样会与操作数栈传输数据。

上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如 iload_),这些指令助 记符实际上是代表了一组指令(例如 iload_,它代表了 iload_0、iload_1、iload_2 和 iload_3 这几条指令)。这几组指令都是某个带有一个操作数的通用指令(例如 iload)的特殊 形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作 数都是在指令中隐含的。除此之外,他们的语义与原生的通用指令完全一致(例如 iload_0 的语 义与操作数为 0 时的 iload 指令语义完全一致)。在尖括号之间的字母制定了指令隐含操作数的 数据类型,代表是 int 形数据,代表 long 型,代表 float 型,代表 double 型。在操作 byte、char 和 short 类型数据时,也用 int 类型表示。

8.2 类型转换指令

类型转换指令可以将两种 Java 虚拟机数值类型进行相互转换,这些转换操作一般用于实现用 户代码的显式类型转换操作,或者用来处理 Java 虚拟机字节码指令集中指令非完全独立独立的问题。

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

  • 否则,如果浮点值不是无穷大的话,浮点值使用 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 类型的正负零。如果转换结果的绝对值太大无法使用 float 来表示的话,将返回 float 类型的正负无穷大,对于 double 类型的 NaN 值将就规定转换为 float 类型的 NaN 值。

尽管可能发生上限溢出、下限溢出和精度丢失等情况,但是 Java 虚拟机中数值类型的窄化转换永远不可能导致虚拟机抛出运行时异常(此处的异常是指《Java 虚拟机规范》中定义的异常, 请读者不要与IEEE 754中定义的浮点异常信号产生混淆)。

8.3 同步

Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。 方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。

虚拟机可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有管程, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期 间,执行线程持有了管程,其他任何线程都无法再获得同一个管程。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法 之外时自动释放。

同步一段指令集序列通常是由 Java 语言中的 synchronized 块来表示的,Java 虚拟机的 指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义, 正确实现 synchronized 关键字需要编译器与 Java 虚拟机两者协作支持。

结构化锁定(Structured Locking)是指在方法调用期间每一个管程退出都与前面的管程 进入相匹配的情形。因为无法保证所有提交给 Java 虚拟机执行的代码都满足结构化锁定,所以 Java 虚拟机允许(但不强制要求)通过以下两条规则来保证结构化锁定成立。假设 T 代表一条线 程,M 代表一个管程的话:

  1. T 在方法执行时持有管程 M 的次数必须与 T 在方法完成(包括正常和非正常完成)时释 放管程 M 的次数相等。

  2. 找方法调用过程中,任何时刻都不会出现线程 T 释放管程 M 的次数比 T 持有管程 M 次数 多的情况。

请注意,在同步方法调用时自动持有和释放管程的过程也被认为是在方法调用期间发生。

 

posted @ 2019-12-05 20:23  huansky  阅读(799)  评论(2编辑  收藏  举报