JVM规范系列第2章:Java虚拟机结构

Posted by 陈树义 on 2021-05-22

本规范描述的是一种抽象化的虚拟机的行为,而不是任何一种(译者注:包括 Oracle 公司自己的 HotSpot 和 JRockit 虚拟机)被广泛使用的虚拟机实现。

记住:JVM规范是一种高度抽象行为的描述,而不是具体虚拟机的实现。

所有在虚拟机规范之中没有明确描述的实现细节,都不应成为虚拟机设计者发挥创造性的牵绊,设计者可以完全自主决定所有规范中不曾描述的虚拟机内部细节,例如:运行时数据区的内存如何布局、选用哪种垃圾收集的算法、是否要对虚拟机字节码指令进行一些内部优化操作(如使用即时编译器把字节码编译为机器码)。

简单地说,就是《JVM规范》中提到的内容,你一定要遵守。但没有提到的内容,你就自由发挥。所以我们要知道,我们经常听到的老年代、年轻代、永久代,其实只是HotSpot虚拟机的实现而已。因为《JVM规范》中并没有规定这些东西。

编译后被 Java 虚拟机所执行的代码使用了一种平台中立(不依赖于特定硬件及操作系统的)的二进制格式来表示,并且经常(但并非绝对)以文件的形式存储,因此这种格式被称为 Class文件格式。

实际上字节码文件,即Class文件格式使用十六进制编码,使用二进制格式存储。

Class 文件格式中精确地定义了类与接口的表示形式, 包括在平台相关的目标文件格式中一些细节上的惯例①,例如字节序(Byte Ordering)等。

这里又一个计算机的基础概念:字节序。有时间可以百度一下搞清楚这个概念。当然了,这个并不会影响对于JVM的理解。可以参考这篇文章:https://www.cnblogs.com/broglie/p/5645200.html

JVM的数据类型

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

JVM中也有两种类型:原始类型、引用类型。

Java 虚拟机希望尽可能多的类型检查能在程序运行之前完成,换句话说,编译器应当在编译
期间尽最大努力完成可能的类型检查,使得虚拟机在运行期间无需进行这些操作。

从这句话我们知道:在编译期间是会及逆行类型检查的,通过在编译进行类型检查,减少JVM运行时的工作量,提高效率。

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

和Java语言不同,JVM的原始类型包括:数值类型、布尔类型、returnAddress类型。

  • 数值类型。又分为整数类型和浮点类型。整数类型包括:byet、short、int、long、char。浮点类型包括:float类型、double类型。
  • 布尔类型。包括:boolean类型,取值为true和false,默认为false。
  • returnAddress类型。表示一条字节码指令的操作码。

可以看到在所有的虚拟机支持的原始类型之中,只有 returnAddress 类型是不能直接 Java 语言的数据类型对应起来的。

整数类型以及整型值

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。

浮点类型的取值及范围

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

浮点类型包括了 float 类型和 double 类型,它们是在 IEEE 754 标准中定义的。

所有 Java 虚拟机的实现都必须支持两种标准的浮点数值集合:单精度浮点数集合和双精度浮点数集合。

所有虚拟机都必须支持单精度浮点数集合和双精度浮点数集合。

关于浮点数的内容,其实看起来会非常晕。所以我们暂时可以大致看一下就可以,毕竟关于浮点数如何定义又是另一个标准了。

returnAddress 类型

returnAddress 类型会被 Java 虚拟机的 jsr、ret 和 jsr_w 指令所使用。jsr、ret、jsr_w 这几条指令以前主要被使用来实现 finally 语句块,后来改为冗余 finally 块代码的方式来实
现,甚至到了 JDK7 时,虚拟机已不允许 Class 文件内出现这几条指令。那相应地, returnAddress 类型就处于名存实亡的状态。

简单地说,returnAddress类型现在已经算是被抛弃了,所以我们只需要大致了解一下有这个东西就好了。

boolean类型

虽然 Java 虚拟机定义了 boolean 这种数据类型,但是只对它提供了非常有限的支持。在Java 虚拟机中没有任何供 boolean 值专用的字节码指令,在 Java 语言之中涉及到 boolean类型值的运算,在编译之后都使用 Java 虚拟机中的 int 数据类型来代替。

可以说,我们学会了int类型,就学会了boolean类型。因为boolean类型就是简化版的int类型。boolean的虚拟机指令集都是使用int类型的指令集。

引用类型与值

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

不仅原始类型不同,JVM的引用类型与原始类型也是不同的。JVM的引用类型有三种:

  • 类类型。对应的值是类实例。
  • 数组类型。对应的值时数组实例。
  • 接口类型。对应的值时实现了某个接口的类实例或数组实例。

嗯,美滋滋啊。又学到新东西嘞。

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

这段话很绕,其中有几个关键词:数组类型、组件类型(Component Type)。其实这段话意思应该是说数组可以有多维的意思,而数组里的类型又可以是其他各种类型。大致意思应该是如此,但我也不是100%确定。这里MARK一下。TODO

在引用类型的值中还有一个特殊的值: null,当一个引用不指向任何对象的时候,它的值就用 null 来表示。Java 虚拟机规范并没有规定 null 在虚拟机实现中应当怎样编码表示。

关于null的定义,了解一下。

运行时数据区

终于到运行时数据区了,其实这块就是我们经常说的JVM内存模型这些东西。但实际上JVM规范中并没有这个术语。JVM规范只定义了「运行时数据区」这个术语,指的就是JVM运行时其内存的数据区是怎么样的,应该包含哪些东西。具体怎么实现,你们各个虚拟机自己打算去。

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

为什么我们经常用JVM内存模型来概括运行时数据区,就是因为运行时数据区说的概念太过于分散,没有联系,所以才会有JVM内存模型这个词,让我们把这些东西联系起来,方便记忆。

从上面这段话,我们可以进行一些概括。首先第一句说到:

Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。

意思是说有些东西会随着虚拟机启动而一直存在,而随着虚拟机退出而销毁。而另外一句:

另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。

上面这句意思是说,又有些数据是随着线程变化的。

其实总结一下就是:JVM运行时数据区有些数据是一直存在的,被所有线程共享的。而有些线程则是线程私有的,随着线程开始而创建,结束而销毁。所以,我们可以将运行时数据区的东西简单分为两类:一类是公有的,一类是私有的。

通过这样一归类,你对于运行时数据区的概念是不是清晰了许多呢!

可能你还没感觉到,那是因为到这里你还不知道运行时数据区到底有多少个东西。在这里我先列一下。运行时数据区包括下面几个部分:

  • PC寄存器
  • Java虚拟机栈
  • Java堆
  • 方法区
  • 运行时常量池
  • 本地方法栈

好了。记住这 6 个东西还真是困难。那我们用我们上面说的,加个公有私有的分类试试看。

  • 公有部分包括:Java堆、方法区、运行时常量池。
  • 私有部分包括:Java虚拟机栈、本地方法栈、PC寄存器。

怎么样,这样一归类,我相信更容易记住了。而且也更符合我们对JVM的理解。JVM的运行时数据区有哪些东西?

首先,有公有和私有两个部分,公有包括……私有包括……。

大脑天然喜欢结构化的数据,这种方法才是理解运行时数据区的正确姿势。

说得有点多了,我们接下来继续读JVM规范。

PC寄存器

Java 虚拟机可以支持多条线程同时执行(可参考《Java 语言规范》第 17 章),每一条 Java虚拟机线程都有自己的 PC(Program Counter)寄存器。在任意时刻,一条 Java 虚拟机线程只会执行一个方法的代码,这个正在被线程执行的方法称为该线程的当前方法。

从这段描述验证上面我的理解是对的。PC寄存器就是线程私有的,每个线程都有一个PC寄存器。而PC寄存器是用来存储当前线程所执行方法的地址。

Java虚拟机栈

每一条 Java 虚拟机线程都有自己私有的 Java 虚拟机栈(Java Virtual Machine Stack)①,这个栈与线程同时创建,用于存储栈帧(Frames, § 2.6)。

从这段描述我们同样可以看到,Java虚拟机栈同样也是线程私有的。Java虚拟机栈的作用就是用来存储「栈帧」。栈帧这个概念也非常重要,其存储了调用方法时,方法的局部变量等信息。后续会深入学习「栈帧」这个概念,这里不做深入介绍。

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

传说中的StackOverflowError就是因为这块区域出现了问题。

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

这里很有意思。意思是说OutOfMemoryError异常,有可能是因为Stack不够导致的。

我相信许多对于JVM理解不够深刻的朋友看到这里会很疑惑,因为他们对于JVM内存模型的理解就是:Java堆、Java栈。但实际上并不是这样的。

你应该这样理解:首先,你得知道「运行时数据区」,就是JVM运行时的这块内存。之后,你要知道这块区域分为两个部分:公有和私有。而Java虚拟机栈是分配在堆上的(注意,并不是Java堆),但分配出来的这块内存是线程私有的。

在创建线程是申请创建Java虚拟机栈,可能有两种情况。第一种,堆上的内存不够了,那么无法创建Java虚拟机栈。这就是上面说到的这种情况,会发生OutOfMemoryError异常。另一种情况,就是内存申请成功了,但是在线程创建后,调用方法的层次太深了,之前申请的这部分内存不够用了。这时候会发生StackOverflowError。

说到这儿,你会发现在《JVM规范》第28页末尾写了这么一段话:

译者注:请读者注意避免混淆 Stack、 Heap 和 Java (VM) Stack、 Java Heap 的概念, Java 虚拟机的实现本身是由其他语言编写的应用程序,在 Java 语言程序的角度上看分配在 Java Stack 中的数据,而在实现虚拟机的程序角度上看则可以是分配在 Heap 之中。

这段话真是不太好理解,但实际上没那么难。首先,我们的JVM是使用其他语言写的,那么这个要运行的时候是不是要像系统申请内存,那么申请的这一大块内存就是堆内存。

在这么大一块内存里,我们又分一部分作为Java堆内存、Java虚拟机栈的内存。也即是说,JVM中的堆内存是系统的堆内存中的堆内存。站在实现JVM的程序来说,是分配在堆上的。而站在Java程序的角度上看,是分配在Java堆上的。也就是说可以想象这样一个图表:

  • 系统内存有堆内存、栈内存之分。
  • JVM启动,向系统申请一块内存,系统会分配一块堆内存给它。当其他应用程序,例如微信启动,系统也会分配一块堆内存给它。
  • JVM获得了内存,于是加载类、运行程序。JVM会在获得的内存分出一部分内存作为Java堆,用于分配对象。
  • 当创建线程,那么JVM会在系统给它的、还未分配的内存划出一部分创建Java虚拟机栈,从而线程创建成功。

所以如果增加Java堆的大小,那么可以分配给Java虚拟机栈的内存就变少,那么可以创建的线程就减少。那么就更容易导致StackOverflowError。

Java堆

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

Java堆是所有线程共享的,存放类实例和数组对象。

方法区

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

可以看到方法区也是共享的,而且存放的是类的信息。在HotSpot虚拟机中,JDK1.7版本称其为永久代(Permanent Generation),而在JDK1.8则称之为元空间(Metaspace)。

运行时常量池

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

运行时常量池分配在JVM的方法区之中。

本地方法栈

Java 虚拟机实现可能会使用到传统的栈(通常称之为“C Stacks”)来支持 native 方法(指使用 Java 以外的其他语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)。当 Java 虚拟机使用其他语言(例如 C 语言)来实现指令集解释器时,也会使用到本地方法栈。

本地方法指的是使用Java以外的其他语言编写的代码,因为有些时候Java无法直接操作一些底层资源,只能通过C或汇编操作。因此需要通过本地方法来实现。

而本地方法栈就是设计用来调用这些非Java语言方法的,其作用与Java虚拟机栈类似。会存放对应的局部变量信息、返回结果等。

本地方法栈同样会发生StackOverFlowError和OutOfMemoryError异常。

栈帧

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

这里重点记住:存储数据和部分结果,存储的部分数据就包括了局部变量。

栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

栈帧随着方法调用而创建,是线程私有的。

栈帧的存储空间分配在 Java 虚拟机栈(§ 2.5.5)之中,每一个栈帧都有自己的局部变量表(Local Variables, § 2.6.1)、操作数栈(Operand Stack, § 2.6.2)和指向当前方法所属的类的运行时常量池(§ 2.5.5)的引用。

栈帧分配在Java虚拟机栈,而Java虚拟机栈是私有的,所以栈帧肯定也是私有的。可以这么说,栈帧就是Java虚拟机栈里的一个个元素,每次调用一个方法就push一个栈帧,调用完毕则poll一个栈帧。

这里还说到,每一个栈帧都有自己的:

  • 局部变量表
  • 操作数栈
  • 指向当前方法所属类的运行时常量池引用

在一条线程之中,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧就被称为是当前栈帧(Current Frame),这个栈帧对应的方法就被称为是当前方法(Current Method),定义这个方法的类就称作当前类(Current Class)。

了解下「当前栈帧」、「当前方法」、「当前类」的概念。

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

这里原文明确指出:栈帧是线程本地私有的数据!

局部变量表

栈帧中局部变量表的长度由编译期决定,并且存储于类和接口的二进制表示之中,既通过方法的Code 属性(§ 4.7.3)保存及提供给栈帧使用。

局部变量表由编译期决定,并存在方法的Code属性。

局部变量使用索引来进行定位访问,第一个局部变量的索引值为零,局部变量的索引值是从零至小于局部变量表最大容量的所有整数。

局部变量表的索引从零开始。

特别地,当一个实例方法被调用的时候,第 0 个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即 Java 语言中的“this”关键字)。

局部变量第一个局部变量,一定是当前对象的引用,即this关键字。

操作数栈

每一个栈帧(§ 2.6)内部都包含一个称为操作数栈(Operand Stack)的后进先出(Last-In-First-Out, LIFO)栈。

这里要注意一下,Java虚拟机栈包含了栈帧,栈帧包含了操作数栈,就像下面这样的关系:

Java虚拟机栈 -> 栈帧 -> 操作数栈

它们都是栈的数据结构,操作数栈也是如此。

只不过Java虚拟机栈是存储一个个线程的信息,栈帧存储的事一个个方法的信息,操作数栈是存储方法调用中一个个操作数的信息。

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

操作数栈的成员可以是任意数据类型。

这章节接下来的关于浮点数、浮点算法部分,太过于复杂,而且实用性太差。这里不深入了解,直接简单了解就可以了。

在 Java 虚拟机层面上, Java 语言中的构造函数在《Java 语言规范 (第三版)》(下文简称JLS3, § 8.8)是以一个名为的特殊实例初始化方法的形式出现的, 这个方法名称是由编译器命名的,因为它并非一个合法的 Java 方法名字,不可能通过程序编码的方式实现。

<init>这个特殊的初始化方法,是在虚拟机层面存在的,而不是程序编码层面的。可以通过虚拟机的invokespecial指令调用。

一个类或者接口最多可以包含不超过一个类或接口的初始化方法,类或者接口就是通过这个方法完成初始化的(§ 5.5)。这个方法是一个不包含参数的静态方法,名为①。这个名字也是由编译器命名的,因为它并非一个合法的 Java 方法名字,不可能通过程序编码的方式实现。

与init类似clinit也是一个虚拟机层面的方法,不是程序编码层面的。但类或接口的clinit方法只能由虚拟机自身隐式调用,没有任何虚拟机字节码指令可以调用这个方法。

字节码指令

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

字节码指令组成为:操作码+操作数。其中操作码为一个字节长度,操作数情况由操作码决定。

大部分的指令都没有支持整数类型 byte、 char 和 short,甚至
没有任何指令支持 boolean 类型。编译器会在编译期或运行期会将 byte 和 short 类型的数据带符号扩展(Sign-Extend)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据。与之类似的,在处理 boolean、 byte、 short 和char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。

在虚拟机中,byte/short/boolean/char 都是用 int 类型来存储的。规范的下一句也直接指明了。

因此,大多数对于boolean、 byte、 short 和 char 类型数据的操作,实际上都是使用相应的对 int 类型作为运算类型(Computational Type)。

虚拟机指令集大致可以分为下面几类:

  • 加载和存储指令
  • 运算指令
  • 类型转换指令
  • 对象创建于操作
  • 操作数栈管理指令
  • 控制转移指令
  • 方法调用和返回指令
  • 抛出异常
  • 同步

因为虚拟机指令集很枯燥,就像linux命令一样,所以这里不深入讲。有需要的时候再一个个查就可以了。