Java的JVM介绍

介绍Java的虚拟机的基本知识

Posted by qin4zhang on August 24, 2019

注意

想法及时记录,实现可以待做。

要说明什么是JVM,无疑看官方文档是最好的方式,看看JVM规范。官方文档

JVM介绍

Java虚拟机是Java平台的基石。它是负责其硬件和操作系统独立性、编译代码的小尺寸以及保护用户免受恶意程序的能力的技术组成部分。

Java虚拟机对Java编程语言一无所知,只知道一种特殊的二进制格式,即类文件格式。类文件包含Java虚拟机指令(或字节码)和符号表以及其他辅助信息。

为了安全起见,Java虚拟机对类文件中的代码施加了很强的语法和结构约束。但是,任何具有可以以有效类文件表达的功能的语言都可以由Java虚拟机托管。

对于JDK、JRE、JVM的关系,也说明下。JRE包括了JVM和Java所需的一些类库等。JDK除了包含JRE之外,还包括了一些开发、诊断工具。

一份Java源码,编译成字节码文件(class文件)后,通过JVM即可在各个操作系统中运行,由JVM负责与操作系统交互,而不是由具体的语言,这也是”Write Once, Run Anywhere”。

其实不仅对于Java这一种语言,只要能通过编译器编译成符合规范的字节码文件,任何语言都是可以运行在JVM上,常见的语言还有Scala、Groovy等等。

JVM结构

  1. 数据类型

数据类型包括基本类型和引用类型,基本类型有整型、布尔型和浮点型,引用类型有类类型、数组类型和接口类型

  1. 运行时数据区

JVM定义了一些运行时数据区,有些是在JVM启动时创建,在JVM退出时销毁,有些是与线程的创建和销毁有关。

2.1 pc计数器

Java虚拟机可以支持同时执行多个线程。每个Java虚拟机线程都有自己的pc(程序计数器)寄存器。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即当前方法。

如果该方法不是native的,pc寄存器中包含当前正在执行的Java虚拟机指令的地址。如果线程当前执行的方法是native的,那么Java虚拟机的pc寄存器的值为空(Undefined)。

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2.2 栈

每个Java虚拟机线程都有一个私有的Java虚拟机堆栈,它与线程同时创建。Java虚拟机堆栈存储帧。Java虚拟机堆栈类似于C等传统语言的堆栈:它保存局部变量和部分结果,并在方法调用和返回中起作用。

因为Java虚拟机堆栈从来没有被直接操作过,除了推送和弹出帧,帧可以被堆分配。Java虚拟机堆栈的内存不需要是连续的。

本规范允许Java虚拟机堆栈具有固定的大小,或者根据计算的需要动态地展开和收缩。如果Java虚拟机堆栈的大小是固定的,那么在创建堆栈时可以独立地选择每个Java虚拟机堆栈的大小。

以下异常条件与Java虚拟机堆栈相关:

  • 如果线程中的计算需要比允许的更大的Java虚拟机堆栈,Java虚拟机抛出StackOverflowError。
  • 如果Java虚拟机栈可以动态地扩展,和扩张是未遂但可以可用内存不足影响扩张,或者内存不足可以创建一个新线程的初始Java虚拟机栈,Java虚拟机抛出一个OutOfMemoryError。

2.3 堆

Java虚拟机有一个堆,供所有Java虚拟机线程共享。堆是为所有类实例和数组分配内存的运行时数据区域。

堆在虚拟机启动时创建。对象的堆存储被自动存储管理系统(称为垃圾收集器)回收;对象永远不会被显式释放。

Java虚拟机没有特定类型的自动存储管理系统,可以根据实现者的系统需求选择存储管理技术。

堆可以是固定大小的,也可以根据计算的需要进行扩展,如果不需要更大的堆,则可以收缩。堆的内存不需要是连续的。

以下异常条件与堆相关联:

  • 如果计算需要比自动存储管理系统提供的堆更多的堆,那么Java虚拟机将抛出一个OutOfMemoryError。

2.4 方法区

Java虚拟机有一个方法区域,在所有Java虚拟机线程之间共享。方法区类似于传统语言的编译代码的存储区,或类似于操作系统进程中的”文本”段。

它存储每个类的结构,如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括在类和实例初始化和接口初始化中使用的特殊方法。

方法区域是在虚拟机启动时创建的。虽然方法区域在逻辑上是堆的一部分,但是简单的实现可能选择不进行垃圾收集或压缩。该规范没有规定用于管理已编译代码的方法区域或策略的位置。

方法区域可以是固定的大小,也可以根据计算的需要扩大,如果不需要更大的方法区域,可以缩小。方法区域的内存不需要是连续的。

以下异常条件与方法区域相关联:

  • 如果方法区域中的内存不能满足分配请求,Java虚拟机将抛出OutOfMemoryError。

2.5 运行时常量池

运行时常量池是类文件中常量池表的每个类或每个接口的运行时表示。它包含几种常量,从编译时已知的数值常量到必须在运行时解析的方法和字段引用。

运行时常量池的功能类似于传统编程语言的符号表,尽管它包含比典型符号表更广泛的数据范围。

每个运行时常量池都是从Java虚拟机的方法区域分配的。类或接口的运行时常量池是在Java虚拟机创建类或接口时构造的。

以下异常条件与类或接口的运行时常量池的构造相关联:

  • 在创建类或接口时,如果运行时常量池的构造需要比Java虚拟机的方法区域可用的内存更多的内存,那么Java虚拟机将抛出一个OutOfMemoryError。

2.6 本地方法栈

Java虚拟机的实现可以使用传统堆栈(俗称“C堆栈”)来支持native方法(用Java编程语言以外的语言编写的方法)。本机方法栈也可以用于Java虚拟机指令集的解释器的实现,如C语言。

Java虚拟机实现不能加载本机方法,本身也不依赖于传统堆栈,因此不需要提供本机方法堆栈。如果提供了本机方法堆栈,通常在创建每个线程时为每个线程分配。

该规范允许本地方法堆栈具有固定的大小,或者根据计算的需要动态地展开和收缩。如果本机方法堆栈的大小是固定的,则在创建该堆栈时可以独立地选择每个本机方法堆栈的大小。

以下异常条件与本机方法堆栈相关联:

  • 如果线程中的计算需要比允许的更大的本机方法堆栈,Java虚拟机将抛出StackOverflowError。
  • 如果本机方法堆栈可以动态扩容,并且尝试了本机方法堆栈扩容,但是内存可用性不足,或者内存可用性不足,无法为新线程创建初始的本机方法堆栈,那么Java虚拟机将抛出OutOfMemoryError。

帧用于存储数据和部分结果,以及执行动态链接、方法返回值和分派异常。

每次调用方法都会创建一个新的帧。当其方法调用完成时,帧被销毁,无论该完成是正常的还是突然的(它抛出一个未捕获的异常)。帧是从创建帧的线程的Java虚拟机堆栈中分配的。

每一帧都有自己的局部变量数组、自己的操作数堆栈和对当前方法类的运行时常量池的引用。

局部变量数组和操作数堆栈的大小在编译时确定,并与与帧相关的方法的代码一起提供。因此,帧数据结构的大小只取决于Java虚拟机的实现,并且这些结构的内存可以在方法调用时同时分配。

在一个给定的控制线程中,只有一个帧(执行方法的帧)是活动的。此帧称为当前帧,其方法称为当前方法。当前方法在其中定义的类就是当前类。对局部变量和操作数堆栈的操作通常引用当前帧。

如果一个帧的方法调用了另一个方法,或者它的方法完成了,它就不再是当前帧。当一个方法被调用时,一个新的帧将被创建,并在控制转移到新方法时成为当前的帧。在方法返回时,当前帧将其方法调用的结果(如果有的话)传回前一个帧。当前一帧成为当前帧时,当前帧被丢弃。

请注意,一个线程创建的帧是该线程的本地帧,不能被任何其他线程引用。

3.1 局部变量

每个帧包含一个变量数组,称为局部变量。帧的局部变量数组的长度在编译时确定,并以类或接口的二进制表示形式以及与帧相关联的方法的代码提供。

单个局部变量可以保存boolean、byte、char、short、int、float、reference或returnAddress类型的值。一对局部变量可以保存long或double类型的值。

局部变量通过索引来寻址。第一个局部变量的索引为零。当且仅当该整数小于局部变量数组的大小在0到1之间时,该整数被认为是局部变量数组的索引。

ong类型或double类型的值占用两个连续的局部变量。这样的值只能使用较小的索引来处理。

例如,一个double类型的值存储在索引为n的局部变量数组中,它实际上占用了索引为n和n+1的局部变量;然而,索引为n+1的局部变量不能从。它可以存储到。但是,这样做会使局部变量n的内容失效。

Java虚拟机使用局部变量在方法调用中传递参数。在类方法调用时,任何参数都从局部变量0开始以连续的局部变量传递。在实例方法调用时,总是使用局部变量0传递对调用实例方法的对象的引用(在Java编程语言中是这样的)。随后,任何参数都从局部变量1开始以连续的局部变量传递。

3.2 操作数栈

每个帧包含一个后进先出(LIFO)堆栈,称为其操作数堆栈。帧的操作数堆栈的最大深度是在编译时确定的,并与与帧相关联的方法的代码一起提供。

在上下文明确的地方,我们有时将当前帧的操作数堆栈简单地称为操作数堆栈。

当包含该操作数堆栈的帧被创建时,该操作数堆栈为空。Java虚拟机提供了将常量或值从本地变量或字段加载到操作数堆栈的指令。

其他Java虚拟机指令从操作数堆栈中获取操作数,对其进行操作,然后将结果推回到操作数堆栈中。操作数堆栈还用于准备传递给方法的参数和接收方法结果。

操作数堆栈上的每个条目都可以保存任何Java虚拟机类型的值,包括long类型或double类型的值。

操作数堆栈中的值必须以适合其类型的方式进行操作。例如,不可能push两个int值并随后将它们视为long值,也不可能push两个float值并随后使用iadd指令将它们相加。

少量的Java虚拟机指令(dup指令和swap指令)以原始值的形式操作运行时数据区域,而不考虑它们的特定类型;这些指令的定义方式不能用于修改或分解单个值。这些对操作数堆栈操作的限制是通过类文件验证强制执行的。

在任何时间点,操作数堆栈都有一个相关的深度,其中long或double类型的值提供两个单位的深度,任何其他类型的值提供一个单位的深度。

3.3 动态链接

每个帧包含对当前方法类型的运行时常量池的引用,以支持方法代码的动态链接。方法的类文件代码引用要调用的方法和要通过符号引用访问的变量。

动态链接将这些符号方法引用转换为具体的方法引用,根据需要加载类来解析尚未定义的符号,并将变量访问转换为与这些变量的运行时位置相关的存储结构中的适当偏移量。

这种方法和变量的后期绑定会在方法使用的其他类中进行更改,而这些更改不太可能破坏此代码。

3.4 正常方法调用完成

如果方法调用没有引发异常,则该方法调用正常完成,该异常可以直接从Java虚拟机抛出,也可以作为执行显式throw语句的结果。

如果当前方法的调用正常完成,则可能向调用方法返回一个值。当被调用的方法执行一个返回指令时就会发生这种情况,返回指令的选择必须适合于返回值的类型(如果有的话)。

在这种情况下,使用当前帧来恢复调用者的状态,包括其局部变量和操作数堆栈,调用者的程序计数器适当地增加以跳过方法调用指令。然后在调用方法的帧中继续正常执行,将返回值(如果有的话)压入该帧的操作数堆栈。

3.5 Abrupt方法调用完成

如果在方法中执行Java虚拟机指令导致Java虚拟机抛出一个异常,并且该异常没有在方法中处理,那么方法调用就会突然(Abrupt)完成。

抛出指令(throw)的执行也会导致显式抛出异常,如果当前方法没有捕获异常,则会导致方法调用突然完成。突然完成的方法调用永远不会向调用者返回值。

  1. 特殊方法

在Java虚拟机级别,每个用Java编程语言编写的构造函数都作为实例初始化方法出现,该方法具有特殊的名称

此名称由编译器提供。因为名称不是有效的标识符,所以不能在Java编程语言编写的程序中直接使用它。

实例初始化方法只能在Java虚拟机中通过invokspecial指令调用,并且只能在未初始化的类实例上调用。实例初始化方法具有派生它的构造函数的访问权限。

一个类或接口最多只有一个类或接口初始化方法,并通过调用该方法进行初始化。类或接口的初始化方法具有特殊的名称,不带参数,为void。

类文件中名为的其他方法没有任何意义。它们不是类或接口初始化方法。它们不能被任何Java虚拟机指令调用,也永远不会被Java虚拟机本身调用。

在版本号为51.0或以上的类文件中,该方法必须另外设置其ACC STATIC标志,以便成为类或接口初始化方法。

这个要求是在Java SE 7中引入的。在版本号为50.0或更低的类文件中,一个名为的方法被认为是类或接口初始化方法,该方法为空且不带参数,不管它的ACC STATIC标志的设置如何。

名称是由编译器提供的。因为名称不是一个有效的标识符,所以不能在Java编程语言编写的程序中直接使用它。类和接口初始化方法由Java虚拟机隐式调用;它们永远不会从任何Java虚拟机指令直接调用,而只是作为类初始化过程的一部分间接调用。

如果以下所有条件都为真,则方法为签名多态:

  • 它在java.lang.invoke.MethodHandle类中声明。
  • 它只有一个Object[]类型的形式参数。
  • 它的返回类型是Object。
  • 它具有ACC_VARARGS和ACC_NATIVE标志集。

在Java SE 8中,唯一的签名多态方法是类java.lang.invoke.MethodHandle的invoke和invokeExact方法。

Java虚拟机在invokvirtual指令中对签名多态方法进行了特殊处理,以实现方法句柄的调用。方法句柄是对底层方法、构造函数、字段或类似的低级操作的强类型的直接可执行引用,带有参数或返回值的可选转换。这些转换非常通用,包括转换、插入、删除和替换等模式。有关更多信息,请参阅Java SE平台API中的java.lang.invoke 包

  1. 异常

Java虚拟机中的异常由Throwable类的实例或其子类之一表示。抛出异常将导致从抛出异常的点立即进行非局部控制转移。

大多数异常都是由于发生异常的线程的操作而同步发生的。相反,异步异常可能在程序执行的任何时候发生。Java虚拟机抛出异常的原因有三个:

  • 执行了Athrow指令
  • Java虚拟机同步检测到执行状态异常。这些异常不是在程序中的任意点被抛出,而是在执行一个指令后同步抛出:
    • 将异常指定为可能的结果,例如:
      • 当指令包含违背Java编程语言语义的操作时,例如在数组边界之外建立索引。
      • 在加载或链接程序的一部分时发生错误。
    • 导致超出对资源的某些限制,例如使用了太多内存。
  • 异步异常发生的原因:
    • 调用了类Thread或ThreadGroup的stop方法,或
    • Java虚拟机实现中发生内部错误。

一个线程可以调用stop方法来影响另一个线程或指定线程组中的所有线程。它们是异步的,因为它们可能在其他一个或多个线程执行的任何时候发生。内部错误被认为是异步。

Java虚拟机可能允许在抛出异步异常之前进行少量但有限的执行。允许这种延迟使优化的代码能够在符合Java编程语言语义的情况下实际处理这些异常时检测并抛出这些异常。

Java虚拟机抛出的异常是精确的:当发生控制转移时,在抛出异常之前执行的指令的所有效果必须看起来已经发生。在抛出异常的点之后出现的指令可能看起来没有被求值。如果优化的代码投机地执行了异常发生点之后的一些指令,那么这些代码必须做好准备,对用户可见的程序状态隐藏这种投机执行。

Java虚拟机中的每个方法可能与零个或多个异常处理程序相关联。异常处理程序指定Java虚拟机代码中实现异常处理程序活动的方法的偏移量范围,描述异常处理程序能够处理的异常类型,并指定要处理该异常的代码的位置。如果导致异常的指令的偏移量在异常处理程序的偏移量范围内,且异常类型与或相同,则异常与异常处理程序匹配

如果在当前方法中没有发现这样的异常处理程序,则当前方法调用会突然完成。在突然完成时,丢弃当前方法调用的操作数堆栈和局部变量,弹出它的帧,恢复调用方法的帧。然后在调用者的框架上下文中重新抛出异常,以此类推,并继续在方法调用链中向上抛出异常。如果在到达方法调用链的顶部之前没有找到合适的异常处理程序,则终止抛出异常的线程的执行。

方法的异常处理程序搜索匹配的顺序很重要。在类文件中,每个方法的异常处理程序都存储在一个表中。在运行时,当抛出异常时,Java虚拟机将按照当前方法出现在类文件中相应异常处理程序表中的顺序,从表的开头开始搜索当前方法的异常处理程序。

注意,Java虚拟机并不强制方法的异常表项的嵌套或任何排序。Java编程语言的异常处理语义只能通过与编译器的协作来实现。当类文件通过其他方式生成时,所定义的搜索过程确保所有Java虚拟机实现的行为一致。

参考

  1. 官方文档