深入 Java 虚拟机(一)Java虚拟机概述

Java 虚拟机之所以被称为是『虚拟』的,就是因为它仅仅是有一个规范来定义的抽象计算机。

想深入理解Java虚拟机的同学建议先学习下计算机组成原理会事半功倍哟。

Java 虚拟机是什么?

要理解 Java 虚拟机,首先必须意识到,当我们提到 Java 虚拟机时,可能指的是三种不同的东西:

  • 抽象规范
  • 一个具体的实现
  • 一个运行中的虚拟机实例

Java 虚拟机抽象规范仅仅是个概念。而该规范的具体实现可能来自多个供应商,并存在多个平台上。它或者完全用软件实现,或者以硬件和软件结合的方式来实现。当运行一个 Java 程序的同时,也就运行了一个 Java 虚拟机实例。

Java 虚拟机的生命周期

一个运行时的 Java 虚拟机的天职就是:负责运行一个 Java 程序。当启动一个 Java 程序时,一个虚拟机实例就诞生了。如果在同一台计算机上同时运行三个 Java 程序,将得到三个Java 虚拟机实例。

Java 程序初始类中的 main() 方法,将作为该程序初始线程的起点,任何其他线程都是由这个初始线程启动的。

Java 虚拟机内部存在两种线程:守护线程与非守护线程。守护线程通常是由虚拟机自己使用的,比如垃圾回收任务的线程。但是 Java 程序也可以把它创建的任何线程标记为守护线程。而 Java 程序中的初始线程-开始于 main() 的那个,是非守护线程。

只要还有任何非守护线程在运行,那么这个 Java程序也会继续运行(虚拟机仍在存活)。当程序中的所有非守护线程都终止时,虚拟机实例将自动退出。加入安全管理器允许,程序本身也可以通过exit()方法退出。

Java 虚拟机的体系结构

在 Java 虚拟机规范中,一个虚拟机实例的行为是分别按照子系统、内存区、数据类型以及指令这几个术语来描述的。这些组成部分一起展示了抽象的虚拟机的内部抽象体系结构。规范中对他们的定义并非是要强制规定 Java 虚拟机实现内部的体系结构,更多的是为了严格定义这些实现的外部特征。

如图所示,每个 Java 虚拟机都有一个类装载器子系统,它根据给定的全限定名来装入类型(类或接口)。同样,每个 Java 虚拟机都有一个执行引擎,它负责执行那些包含在被装载类的方法中的指令。


image

运行时数据区概述

当虚拟机运行一个程序时,它需要内存来储存许多东西,例如字节码、从已装载的 class 文件中得到的其他信息、程序创建的对象、传递给方法的参数、返回值、局部变量以及运算的中间结果等等,Java 虚拟机把这些东西都组织到『运行时数据区』中,便于管理。

尽管这些『运行时数据区』都会以某种形式存在于每一个 Java 虚拟机实现中,但是规范对它的描述是相当抽象的。运行时数据区结构上的细节,大多数都由具体实现的设计者决定。

不同的虚拟机实现可能具有不同的内存限制,有的实现可能有大量的内存可用,有的可能很少。有的实现可以利用虚拟内存,有的则不能。规范本身对『运行时数据区』只有抽象的描述,这就使得 Java 虚拟机可以很容易在各种计算机和设备上实现。

某些运行时数据区是由程序中所有线程共享的,还有一些只能由一个线程拥有。

每个 Java 虚拟机都有一个方法区以及一个堆,它们是由该虚拟机实例中所有线程共享的。当虚拟机装载一个 class 文件时,它会从这个 class 文件包含的二进制数据中解析类型信息。然后,它把这些信息类型信息放到方法区中。当程序运行时,虚拟机会把所有该程序在运行时创建的对象都放到堆中。

当一个新线程被创建时,它将得到它自己的 PC 寄存器(程序计数器)以及一个 Java 栈。如果当前线程执行的是一个 Java 方法(非本地方法),那么 PC 寄存器的值将总是指示下一条将被执行的指令,而它的 Java 栈总是储存该线程中 Java 方法的调用状态:包括它的局部变量、被调用时传进来的参数、它的返回值以及运算的中间结果等等。而本地方法调用的状态,则是以某种依赖于具体实现的方式储存在本地方法栈中,也可能是在寄存器或者其他某些与特定实现相关的内存区中。

Java 栈是由许多栈帧或帧组成的,一个栈帧包含一个Java方法调用的状态。当线程调用一个 Java 方法时,虚拟机压入一个新的栈帧到该线程的 Java 栈中:当该方法返回时,这个栈帧被从 Java 栈中弹出并抛弃。

Java 虚拟机没有寄存器,其指令集使用 Java 栈来储存中间数据。这样设计的原因是为了保持 Java 虚拟机的指令尽量紧凑,同时也便于Java 虚拟机在那些只有很少通用寄存器的平台上实现。另外 Java 虚拟机的这种基于栈的体系结构,也有助于运行时某些虚拟机实现的动态编译器和即时编译器的代码优化。

上面 Java体系结构图中有三个线程正在执行。线程1和线程2都在执行 Java 方法,而线程3正在执行本地方法。图中的 Java 栈都是自上而下生长的,栈顶在图片的底部。当前正在执行的方法的栈帧(栈顶)用黄色表示。对于一个正在运行Java 方法的线程而言,它的程序计数器总是指向下一条将被执行的指令(黄色标出的线程1和线程2)。由于线程3正在执行本地方法,因此程序计数器用蓝色表示,其值是不确定的。

数据类型

Java 虚拟机是通过某些数据类型来执行计算的,数据类型及其运算都是由 Java 虚拟机规范严格定义的。数据类型可以分为两种:基本类型和引用类型。基本类型的变量持有原始值,而引用类型的变量持有引用值(对某个对象的引用,而不是对象本身)

image

Java 语言中的所有基本类型同样也是 Java 虚拟机中的基本类型。但是 boolean 有点特别,虽然 Java 虚拟机把 boolean 看做基本类型,但是指令集对 boolean 只是很有限的支持,当编译器把 Java 源码编译为字节码时,它会用 int 或 byte 来表示 boolean。在 Java 虚拟机中,false 使用整数0来表示,所有非0整数表示 true。
Java 虚拟机中还有一个只在内部使用的基本类型:returnAddress。这个基本类型用来实现 Java 程序中的 finally 子句。

Java 虚拟机的引用类型被统称为『引用』,从思维导图可以看出,数组是个真正的对象。

Java 虚拟机规范定一个每一个数据类型的取值范围,但是却没有定义它们的位宽。储存这些类型的值所需的占位宽度是由虚拟机实现的设计者决定的。

类型 范围
byte 8 bit,带符号,二进制补码
short 16 bit,带符号,二进制补码
int 32 bit,带符号,二进制补码
long 64 bit,带符号,二进制补码
char 16 bit,不带符号,Unicode字符
float 32 bit,单精度浮点数
double 64 bit,双精度浮点数
returnAddress 同一方法中某操作码的地址
reference 堆中某对象的引用,或者为 null

字长的考量

Java 虚拟机中,最基本的数据单元是字(word),它的大小是由每个虚拟机实现的设计者来决定的。字长必须足够大,至少是一个字单元就足以持有 byte、short、int、char、float、returnAddress 或 reference 类型的值,而两个字单元就足以持有 long 或者 double 类型的值。因此虚拟机实现的设计者至少得选择32位作为字长。

在 Java 虚拟机规范中,关于运行时数据区的大部分内容都是基于word这个抽象概念。比如,关于栈帧的两个部分:局部变量和操作数栈,都是按照word来定义的。

在运行时,Java 程序无法侦测到底层虚拟机的字长大小;同样,虚拟机的字长大小也不会影响程序的行为,它仅仅时虚拟机实现的内部属性。

类装载子系统

在 Java 虚拟机中,负责查找并装载类型的那部分被称为类装载器子系统。Java 虚拟机有两种类装载器:启动类装载器和用户自定义类装载器。前者是Java 虚拟机实现的一部分,后者则是 Java 程序的一部分。由不同的类装载器装载的类将被放在虚拟机内部的不同命名空间中。

类加载器子系统涉及 Java 虚拟机的其他几个组成部分,以及几个来自 java.lang 库的类(ClassLoader、Class)。对于每一个被装载的类型,Java 虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。和所有其他对象一样,用户自定义的类装载器以及Class类的实例都放在内存中的堆区,而装载的类信息则都位于方法区。

装载、连接以及初始化

类装载器子系统除了要定位和导入二进制 class 文件外,还需负责验证被导入类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。这些动作必须严格按以下顺序进行:

  • 装载:查找并装载类型的二进制数据。
  • 连接:执行验证,准备以及解析(可选)
    • 验证:确保被导入类型的正确性。
    • 准备:为类变量分配内存,并将其初始化默认值。
    • 解析:把类型中的符号引用转化为直接引用。
  • 初始化:把类变量初始化为正确初始值。

启动类装载器

只要是符合 Java class文件格式的二进制文件,Java虚拟机实现都必须能够从中辨别并装载其中的类和接口。每个Java虚拟机实现都必须有一个启动类装载器,它知道怎么装载受信任的类。Java 虚拟机规范并未规定启动类装载器如何去寻找class文件,这交给具体的实现者去定义。

用户自定义类装载器

尽管『用户自定义类装载器』本身是 Java 程序的一部分,但ClassLoader提供的4个方法是通往 Java 虚拟机的通道

protected final Class defineClass(String name, byte[] b, int off, int len, ProtectionDomain protectionDomain);
protected final Class defineClass(String name, byte[] b, int off, int len);
protected final Class findSystemClass(String name);
protected final void resolveClass(Class c);

任何 Java 虚拟机实现都必须把这些方法连接到内部的类装载器子系统中。

命名空间

每个类装载器都有自己的命名空间,其中维护着由它装载的类型。所以一个 Java 程序可以多次装载具有同一个全限定名的类型。这样一个类型的全限定名就不足以确定在一个 Java 虚拟机的唯一性。因此当多个类装载器装载了同名的类型时,为了唯一的标识该类型,还需要在类型名称前加上该类型的类装载器标识。

Java 虚拟机中的命名空间,其实是解析过程的结果。对于每一个被装载的类型,Java 虚拟机都会记录装载它的类装载器。当虚拟机解析一个类到另一个类的符号引用时,它需要被引用类的类装载器。

方法区

在 Java 虚拟机中,关于被装载类型的信息储存在一个逻辑上被称为方法区的内存中。当虚拟机装载某个类型时,它使用类装载器定位相应的 class 文件,然后读入这个 class 文件(一个二进制数据流)。随后虚拟机提取其中的类型信息,并将这些信息储存到方法区。类型中的静态变量同样也是储存在方法区中。具体如何储存,是由设计者来决定。

由于所有线程都共享方法区,因此它们对方法区数据的访问必须设计为是线程安全的。

方法区的大小不必是固定的,虚拟机可以根据应用的需要动态调整,允许指定方法区的初始大小以及最小和最大尺寸。同样,方法区也不必是连续的。

方法区也可以被垃圾收集,因为虚拟机允许通过用户定义的类装载器来动态扩展 Java 程序,因此一些类也会成为程序『不再引用』的类。当某个类变为不再被引用的类时,Java虚拟机可以卸载这个类。

以下是加载类型是会被储存到方法区的相关数据:

1.类型信息

  • 这个类型的全限定名
  • 这个类型的直接超类的全限定名(除非是 Object,它没有超类)
  • 这个类型是类类型还是接口类型
  • 这个类型的访问修饰符(public、abstract、final 的某个子集)
  • 任何直接实现接口的全限定名的有序列表

在Java 虚拟机中,类型名总是以全限定名出现。全限定名由类所属包的名称加一个.,再加上类名组成。

2.常量池

虚拟机必须为每个装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合,包括直接常量(stirng、integer、floating 和 point 常量)和对其他类型、字段和方法的符号引用。池中的数据项就想数组一样是通过索引访问的。因为常量池储存了相应类型所用到的所有类型、字段和方法的符号引用,所以它在 Java 程序中的动态连接中起着核心作用。

3.字段信息

对于类型中声明的每一个字段,方法区中必须保存下面的信息。除此之外,这些字段在类或者接口中的声明顺序也必须保存。

  • 字段名
  • 字段的类型
  • 字段的修饰符(public、private、protected、static、final、volatile、transient 的某个子集)

4.方法信息

对于类型中声明的每一个方法,方法区中必须保存下面的信息,和字段一样,声明顺序也必须保存。

  • 方法名
  • 方法的返回类型(或 void)
  • 方法参数的数量和类型(按声明顺序)
  • 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract 的子集)
    如果某个方法不是抽象的和本地的,它还必须保存下列信息:
  • 方法的字节码
  • 操作数栈和该方法的栈帧中的局部变量区的大小
  • 异常表

5.除了常量以外的所有类(静态)变量

类变量是由所有实例共享的,即使没有任何该类型的实例,它也可以被访问。这些变量只于类有关,因此它们总是作为类型信息的一部分而储存到自己的常量池中。虚拟机在使用某个类之前,必须在方法区中为这些类变量分配空间。

在类中声明的编译时常量(用final声明以及编译时设置过初始化值得类变量)也会保存在方法区中。

6.一个到类 ClassLoader 的引用

每个类型被装载的时候,虚拟机必须跟踪它是由启动类装载器还是由用户自定义类装载器装载的。如果是用户自定义类装载器装载的,那么虚拟机必须在类型信息中储存对该装载器的引用。

7. 一个到 Class 类的引用

对于每一个被装载的类型(不管是类还是接口),虚拟机都会相应的为它创建一个java.lang.Class类的实例,而且虚拟机还必须以某种方式把这个实例和储存在方法区的类型数据关联起来。

8. 方法表

为了尽可能的提高访问效率,设计者必须仔细设计储存在方法区中的类型信息的数据结构,因此,除了上述7条原始类型信息,虚拟机实现中还可能包括其他数据结构以加快访问速度,比如方法表。

虚拟机对于对每个装载的非抽象类,都生成一个方法表,把它作为类信息的一部分保存到方法区。方法表是一个数组,它的元素是所有它的实例可能被调用的实例方法的直接引用,包括从超类继承过来的实例方法。(对于抽象类和接口,方法表没有什么帮助,因为程序绝不会生成它们的实例)

方法区使用实例

public class MethodTest {
    public static void main(String[] args) {
        Lava lava = new Lava();
        lava.flow();
    }
    
}
class Lava{
    private int speed = 5;
    void flow(){}
}

要运行MethodTest,首先得以某种方式告诉虚拟机MethodTest这个名字。之后,虚拟机将找到并读入相应的class文件MethodTest.class,然后它会从导入的class文件里提取类型信息并放到方法区中。通过执行保存在方法区的字节码,虚拟机开始main方法,在执行时,它会一直持有指向当前MethodTest类的常量池的指针。

main方法的第一条指令告知虚拟机为该类型常量池保存的第一项类分配足够的内存。所以虚拟机使用指向MethodTest常量池的指针找到第一项。发现它是对Lava类的符号引用(这个符号引用仅仅是一个给出了Lava类的全限定名字符串。),然后它就检查方法区,看Lava类是否已经被装载。

当虚拟机发现还没装载过名为Lava的类时,它就开始查找并装载Lava.class,并提取类型信息到方法区中。

紧接着,虚拟机以一个直接指向方法区Lava类数据的指针来替换常量池第一项(Lava类的全限定名字符串),以后就可以通过这个指针来快速访问Lava类。这个替换过程称为常量池解析,即把常量池中的符号引用替换为直接引用。

终于,虚拟机准备为一个新的Lava对象分配内存。此时它又需要方法区中的信息。还记得刚刚替换的MethodTest常量池第一项的指针吗?现在虚拟机用它来访问Lava类型信息,找到一个Lava对象需要分配多少堆空间。

当Java虚拟机确定了Lava对象的大小后,它就在堆上分配那么大的空间,并把这个实例对象的变量spped初始化为默认初始值:0。假如Lava的超类也有实例变量,则也会在此时初始化为相应的默认值。

当把新生成的Lava对象的引用压到栈中,main方法的第一条指令也完成了。接下来的指令通过这个引用调用Java代码(改代码把speed变量初始化为正确初始值:5)。另外一条指令将用这个引用调用Lava对象引用的flow方法。

Java虚拟机总能通过储存于方法区的类型信息来确定一个对象需要多少内存,但是对象实际分配多少内存跟虚拟机的特定实现有关。

Java程序在运行时创建的所有类实例或数组都放在同一个堆中。而一个Java虚拟机实例只存在一个堆空间,因此所有线程都共享这个堆,在这种情况下,就得考虑多线程下的线程安全问题了。

垃圾收集

Java虚拟机有一条在堆中为新对象分配内存的指令,却没有释放内存的指令。正如无法用Java代码明确释放一个对象一样,字节码指令也没有对应的功能。虚拟机自己决定如何以及何时释放不再被运行的程序引用的对象所占据的内存。程序本身不需要关心何时释放内存,通常,虚拟机把这个任务交给垃圾收集器

垃圾收集器的主要工作就是自动回收不再被运行程序引用的对象所占用的内存。此外,他也可能去移动那些还在使用的对象,以此减少堆碎片。

Java虚拟机规范并没有强制规定垃圾收集器,它只要求虚拟机实现必须以某种方式管理自己的堆空间。因为对象的引用可能很多地方都存在,如Java栈、堆、方法区等,所以垃圾回收技术的使用很大程度上会影响到运行时数据区的设计。

和方法区一样,堆空间也不必是连续的内存区。在程序运行时,它可以动态扩展或收缩。事实上,一个实现的方法区可以是堆顶实现。换句话说,就是当虚拟机需要为一个新装载的类分配内存时,类型信息和实际对象可以都在同一个堆上。因此垃圾回收器可能也要负责无用类的释放。另外某些实现允许指定堆的初始大小、最大最小值等。

对象的内部表示

Java 虚拟机规范并没有规定Java对象在堆中是如何表示的。对象的内部表示也影响着整个堆和垃圾收集器的设计。

Java 对象中包含的基本数据有它所属的类以及超类声明的所有实例变量组成。只要有一个对象引用,虚拟机就必须能够快速地定位对象实例的数据。另外,它也必须能够通过该对象引用访问相应的类数据(储存于方法区的类型信息)。

一种可能的堆空间设计就是,把堆分成2部分:一个句柄池、一个对象池。
[图片上传失败...(image-bc4872-1591607604213)]
一个对象引用就是一个指向句柄池的指针,句柄池每个条目有两部分:一个指向对象实例变量的指针、一个指向方法区类型数据的指针。这样设计的好处就是有利于堆碎片的整理,当移动对象池中的对象时,只需要修改句柄池中指向对象池的指针就可以。缺点是每次访问对象的实例变量都需要两次指针传递。

另一种设计方式是将对象指针直接指向一组数据,而该数据包括指向对象实例数据以及指向方法区中类数据的指针。

image

这样设计的优缺点于前面的方法正好相反,它只需要一个指针就可以访问对象的实例数据,但移动对象就变得更为复杂。

不管虚拟机实现使用什么样的对象表示法,很可能每个对象都有一个方法表,因为方法表加快了调用实例方法时的效率,从而对Java虚拟机实现的整体效率起着非常正面的作用。如果使用方法表,那么仅仅使用一个指向对象的引用,就可以很快的访问的对象的方法表。


image

每个对象的数据都包含一个指向特殊数据结构的指针,这个数据结构位于方法区,包含两个部分:

  • 一个指向方法区对应类数据的指针
  • 此对象的方法表

方法表是个指针数组,其中的每一项都是指向『实例方法数据』的指针,实例方法可以被那类的对象调用。方法表指向的实例方法数据包括以下信息:

  • 此方法的操作数栈和局部变量区的大小
  • 此方法的字节码
  • 异常表

这些信息足够虚拟机去调用一个方法了,方法表中包含有方法指针(指向类或超类声明的方法数据)。

对象与锁

堆上的数据还有一个逻辑部分,那就是对象锁,这是一个互斥对象。虚拟机中的每个对象都有一个对象锁,它被用于多个线程访问同一个对象时的同步。在任何时刻,只能有一个线程拥有这个对象锁。此时其他希望访问这个对象的线程只能等待,直到拥有对象锁的线程释放锁。当线程拥有一个对象锁后,可以对这个锁继续追加请求(可重入)。但是请求几次必须对应释放几次。

很多对象在整个生命周期都没有被任何线程加锁。所以在线程实际请求某个对象的锁之前,实现对象锁所需要的数据是不必要的。而且很多虚拟机实现并不在对象内部保存一个指向锁数据的指针。并且只有当第一次需要加锁的时候才分配对应的锁数据,这时就需要虚拟机用某种间接的方法来联系对象数据和对应的锁数据。例如把锁数据放到以对象地址为索引的搜索树中。

对象与等待集合(wait set)

除了实现锁所需要的数据外,每个 Java 对象逻辑上还与等待集合(wait set)的数据相关联。锁是用来实现多个线程对共享数据的互斥访问的,而等待集合是用来让多个线程为完成同一个目标而协调工作的。

等待集合由等待方法和通知方法联合使用。每个类都从 Object 里继承了三个等待方法(wait及其重载方法)和两个通知方法(notifynotifyAll)。

当某个线程在一个对象上调用等待方法时,虚拟机就阻塞这个线程,并把它放在了这个对象的等待集合中。直到另一个线程在同一个对象上调用通知方法,虚拟机才会在之后的某个时刻唤醒一个或多个在等待集合中被阻塞的线程。

正像锁数据一样,在实际调用对象的等待方法或通知方法之前,实现对象的等待集合的数据并不是必须的。因此许多虚拟机实现都把等待集合数据与实际对象数据分开,只有在需要时,才会为此对象创建同步数据。

对象与垃圾收集器

垃圾收集器必须跟踪程序引用的每个对象,这个任务不可避免的要附加一些数据给这些对象,数据的类型要视垃圾收集使用的算法类型而定。

除了标记对象的引用情况外,垃圾收集器还要区分对象是否调用了终结方法。对于在其类中声明了终结方法的对象,在回收它之前,垃圾收集器必须调用它的终结方法。Java 语言规范指出,垃圾收集器对每个对象只能调用一次终结方法,但是允许终结方法复活这个对象,即允许该对象再次被引用。这样当这个对象再次被回收时,就不用再调用终结方法了。这种用来标记终结方法的数据虽然逻辑上时对象的一部分,但通常实现上不随对象保存在堆中。大部分情况下,垃圾回收器会在一个单独的空间保存这个信息。

对象与数组

在Java中,数组是真正的对象。和其他对象一样,数组总是储存在堆中。同样,和普通对象一样,虚拟机实现的设计者决定数组在堆中的表示形式。

和其他所有对象一样,数组也拥有一个与他们的类相关联的Class实例,所有具有相同维度和类型的数组都是同一个类的实例,而不管数组的长度是多少。数组的长度只与实例数据有关。

数组类的名称由两部分组成:每一维用一个方括号[表示、用字符或字符串表示元素类型。例如,元素类型为 int 整数的一维数组的类名为[I;元素类型为 byte 的三维数组为[[[B。元素类型为 Object 的二维数组为[[Ljava/lang/Object

多维数组表示为数组的数组。比如 int 的二维数组。下图是一种可能的表示方式:


image

程序计数器

对于一个运行中的 Java 程序而言,其中的每一个线程都有它自己的PC(程序计数器)寄存器,它是在该线程启动时创建的,PC 寄存器的大小是一个字长word,因此它能够持有一个本地指针,也能够持有一个 returnAddress。

当线程执行某个 Java方法时,PC寄存器的内容总是下一条将被执行的指令的『地址』,这里的地址可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。

如果该线程正在执行一个本地方法,那么此时 PC 寄存器的值是『undefined』

Java 栈

每当启动一个新线程时,Java 虚拟机都会为它分配一个 Java 栈。Java 栈以栈帧为单位保存线程的运行状态。虚拟机只会直接对 Java栈执行两种操作:以栈帧为单位的压栈和出栈。

某个线程正在执行的方法被称为该线程的当前方法,当前方法使用的栈帧称为当前帧。还有对应的当前类、当前常量池。当线程执行一个方法时,它会跟踪当前类和当前常量池。

每当线程调用一个 Java 方法时,虚拟机都会在该线程的 Java 栈中压入一个新帧。这个新帧自然就成了当前帧。在执行这个方法时,它使用这个帧来存储参数、局部变量、中间运算结果等数据。

Java 方法可以以两种方式完成。˙一种通过 return 返回,称为正常返回;一种是通过抛出异常而异常中止的。不管以哪种方式返回,虚拟机都会将当前帧弹出Java 栈然后释放掉,这样上一个方法的帧就成为当前帧了。

Java 栈上的所有数据都是此线程私有的。任何线程都不能访问另一个线程的栈数据,因此不需要考虑线程安全问题。

当线程调用一个方法时,方法的局部变量保存在调用线程Java 栈的栈帧中。所以方法的局部变量在设计上就保证了线程安全。

栈帧

栈帧由三部分组成:局部变量区操作数栈帧数据区局部变量区操作数栈的大小要视对应的方法而定,它们是按字长(word)计算的。编译器在编译时就确定了这些值并发在 class 文件中。而帧数据区的大小依赖于具体的实现。

当虚拟机调用一个方法时,它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈内存,然后压入 Java 栈中。

局部变量区

Java栈帧的局部变量区被组织为一个以字长为单位、从0开始计数的数组。字节码指令通过从0开始的索引来使用其中的数据。

类型为int,float,reference,returnAddress的值在数组中只占据一项。byte,short,char的值在存入数组前将被转换为int值。但是类型long,double的值在数组中会占据连续的两项。

局部变量区包含对应方法的参数和局部变量。编译器会按照声明的顺序把这些参数放入局部变量数组。

class Example{
    public static int runClassMethod(int i,long l,float f,double d,Object o,byte b) {
        return 0;
    }

    public int runInstanceMethod(char c,double d,short s,boolean b){
        return 0;
    }
}

所以,对于上面的类,虚拟机会进行下面这样的内存分配

image

注意runInstanceMethod中,局部变量中第一个参数是一个reference引用类型。尽管在源代码中没有显式声明这个参数,但这个参数this对于任何一个实例方法都是隐含加入的,它用来表示调用该方法的对象本身。而runClassMethod中就没有隐含的this变量,因为它是一个类方法,类方法只于类有关,与具体的对象无关。

关于参数类型的存储,虚拟机并不直接支持boolean类型,因此Java编译器总是用int来表示。但Java虚拟机对byteshortchar是直接支持的,这些类型的值可以作为实例变量或者数组元素存储在局部变量区,也可以作为类变量储存在方法区,但是在局部变量区和操作数栈中都会被转为int类型的值,它们在栈帧中的时候都是当做int来处理的,只有被放回堆或方法区时,才会被转换回原来的类型。

同样注意的是runClassMethod中的Object类型参数。在Java中,所有的对象都按引用传递,并且都储存在堆中。

除了Java方法的参数,对于真正的局部变量,编译器可以任意决定其在局部变量区的放置顺序。

操作数栈

和局部变量区一样,操作数栈也被组织成以一个字长为单位的数组。但是和前者不同的时,它不是通过索引来访问,而是通过标准的栈操作(压栈和出栈)。

虚拟机在操作数栈中储存数据的方式和在局部变量区中是一样的。

不同于程序计数器,Java虚拟机没有寄存器,程序计数器也无法被程序指令直接访问。Java虚拟机的指令是从操作数栈中取得操作数的,因此它是基于栈的,而不是基于寄存器的。虽然指令也可以从其他地方取得操作数,但以操作数栈为主。

虚拟机把操作数栈作为它的工作区(大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈)。
示例:iadd指令需要从操作数栈中弹出两个整数,执行加法运算,其结果又压回操作数栈中。

iload_0     //push the int in local variable 0
iload_1     //push the int in local variable 1
iadd        //pop two ints,add them,push result
istore_2    //pop int,store into local variable 2

流程如下:

image

iload_0iload_1分别将局部变量里的索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出两个整数并相加,再将结果压入操作栈。istore_2则从操作栈中弹出结果,并储存到局部变量索引为2的位置。

帧数据区

除了局部变量区和操作数栈外,Java 栈帧还需要一些数据来支持常量池解析、正常方法返回以及异常派发机制。这些信息都保存在Java 栈帧的帧数据区中。

Java 虚拟机中绝大多数指令都涉及到常量池入口。有些指令仅仅是从常量池中取出数据然后压入 Java 栈(像int、lang、float、double和String);还有些指令使用常量池的数据来指示要实例化的类或数组、要访问的字段或者要调用的方法;还有些指令需要常量池中的数据才能确定某个对象是否属于某个类或实现了某个接口。

每当虚拟机要执行某个需要用到常量池数据的指令时,它都会通过帧数据区中指向常量池的指针来访问它。常量池中对类型、字段、方法的引用在开始时都是符号。当虚拟机在常量池中搜索时,如果遇到指向类、接口、字段或者方法的入口,如果仍然是符号,虚拟机那时才进行解析。

除了用于常量池的解析外,帧数据区还要帮助虚拟机处理Java方法的正常结束或异常中止。如果是 return 正常结束,虚拟机必须恢复发起调用方法的栈帧,包括设置PC 寄存器指向发起调用的方法中的指令。如果方法有返回值,虚拟机必须将它压入到发起调用的方法的操作数栈。

为了处理 Java 执行期间的异常情况,帧数据区还必须保存一个对此方法异常表的引用。异常表中的每一项都有一个被 catch子句保护的代码的起始和结束位置,可能被 catch 的异常类在常量池中的索引值。

当某个方法抛出异常时,虚拟机根据帧数据区对应的异常表来决定如何处理。如果异常表中找到了匹配的 catch 语句,就会把控制权交给 catch 子句内的代码。如果没有发现,方法会立即异常中止。然后虚拟机使用帧数据区的信息恢复发起调用方法的帧,然后在发起调用的方法的上下文中重新抛出同样的异常。

两种简单的Java栈实现方式

类描述如下

class Example {
    public static void addAndPrint() {
        double d = addTwoTypes(1,88.88);
    }
    public static double addTwoTypes(int i, double d) {
        return d + i;
    }
}

一种实现是每个帧都单独从堆上分配,彼此之间并不重叠。步骤如下

  • 为了调用方法 addTwoTypes(),方法 addAndPrint()首先把 int 1 和double 88.88压入操作数栈中,然后调用addTwoTypes()方法。
  • 调用addTwoTypes()的指令指向一项常量池的数据,因此虚拟机在常量池里查找这些数据,这期间如有必要还需要进行解析。
  • 虚拟机需要使用解析后的常量池数据来决定addTwoTypes()的局部变量区和操作数栈的大小。紧接着为这个方法分配足够大的栈帧。
  • 然后从addAndPrint()的操作栈中弹出double参数和int参数,并把它们放入addTwoTypes()的局部变量区中。
  • 当addTwoTypes()返回时,它首先会把返回值(89.88)压入自己的操作数栈中,紧接着虚拟机使用帧数据区中的信息找到调用者(addAndPrint())的栈帧,然后将返回值压入addAndPrint()的操作数栈中并释放addTwoTypes()的栈帧所占用的内存。
  • 最后虚拟机把addTwoTypes()作为当前帧,从调用执行的下一条指令开始继续执行方法addAndPrint()。

栈结构内存流程图:


image

另一种Java 栈帧不是从堆上单独分配,而是从一个连续的栈中分配,因而这种方式运行相邻方法的栈帧可以重叠。这样调用者的操作数栈部分就成了被调用者的局部变量的底层。


image

这种方式不仅节省了内存空间,也节省了拷贝的时间。

本地方法栈

前面提到的运行时数据区都是在Java虚拟机中明确定义的。除此之外,对于运行中的Java程序而言,它可能还会用到和本地方法相关的数据区。当某个线程调用一个本地方法时,它就进入了一个全新的且不受虚拟机限制的世界。

本地方法本质上是依赖于实现的。不过任何本地方法接口都会使用某种本地方法栈。当线程调用 Java 方法时虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的栈帧,虚拟机只是简单的动态链接并直接调用本地方法。

如果虚拟机实现的本地方法接口是使用 C 模型的话,那么它的本地方法栈就是 C 栈。当 C 程序调用一个C函数函数时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。

就像其他运行时内存一样,本地方法栈占用的内存区大小也不必是固定的,它可以根据需要动态扩展和收缩。

执行引擎

任何 Java 虚拟机实现的核心都是它的执行引擎。在Java虚拟机规范中,执行引擎的行为使用指令集来定义(有木有很熟悉,虚拟版CPU指令集)。对于每条指令,规范都详细的规定了当实现执行到该指令时应该处理什么,但是对于如何处理言之甚少。

『执行引擎』和『Java虚拟机』术语一样,可以有三个理解:

  • 一个抽象的规范
  • 一个具体的实现
  • 一个正在运行的执行引擎实例(就是一个线程)

运行中Java 程序的每一个线程都是一个独立的虚拟机执行引擎实例。从线程生命周期的开始到结束,它要么在执行字节码,要么在执行本地方法。一个线程可以通过解释或者使用芯片级别的指令直接执行字节码,或者间接通过即时编译器执行编译过的本地代码。像『垃圾收集器』这种对用户不可见的线程不需要是实现执行引擎的实例。

抽象的执行引擎每次执行一条字节码指令。Java虚拟机中运行的程序的每个线程(执行引擎实例)都执行这个操作:执行引擎取得操作码,如果操作码有操作数,取得它的操作数。它执行操作码和跟随操作数规定的动作,然后再取得下一个操作码。这个执行字节码的过程在线程完成前将一直持续,通过从它的初始方法返回,或者没有捕获抛出的异常都可以标志着线程的完成。

指令集

方法的字节码流是由 Java虚拟机的指令序列构成的,每一条指令包含一个单字节的操作码,后面跟随0个或多个操作数。

  • 操作码表明需要执行的操作。操作码本身已经规定了它是否需要跟随操作数,以及如果有操作数的话,它是什么形式。
  • 操作数向Java虚拟机提供执行操作码需要的额外信息。

Java虚拟机指令集关注的是操作数栈,一般是把将要使用的值会压入栈中。虽然 Java虚拟机没有保存任意值的寄存器,但每一个方法都有一个局部变量集合。指令集的实际工作方式就是把局部变量区当做寄存器,用索引来访问。不过要使用保存在局部变量区中的值之前,必须先将它压入操作数栈。

以栈为中心的指令集设计目的

  • 平台无关性
    • 指令集这种以栈为中心的设计方法,使得在那些只有很少寄存器或寄存器很没有规律的机器上很容易实现
    • 以栈为中心设计指令集的另一个目的是,编译器一般采用以栈为基础的结构向连接器或优化器传递编译中间结果。以栈为中心的体系结构可以将运行时进行的优化工作与执行即时编译或自适应优化的执行引擎结合起来。
  • 网络移动性
    • 指令集的设计能够提高class文件的紧凑性。提升网络上class文件的传输速度
    • class 文件中的字节码,除了两个处理表的跳转指令外,都是按照字节对齐的
  • 安全性
    • 指令集的设计另一个目标是进行字节码验证,特别是在装载字节码时。

执行技术(自适应优化)

Java虚拟机实现可以使用多种执行技术:解释、即时编译、自适应优化、芯片级直接执行。虚拟机实现可以自由选择任何技术来执行字节码,只要它遵循Java虚拟机指令集定义。

最有意义也是最迅速的执行技术是自适应优化。最初的虚拟机每次解释一条字节码;第二代加入了即时编译器,在第一次执行方法时先编译成本地代码,然后再执行。自适应优化搜集那些只在运行时才有效的信息,试图以某种方式把字节码解释和编译成本地代码结合起来,以得到最优的性能。

自适应优化的虚拟机一开始都是解释运行,但是它会监视代码的执行情况。当判断出某个特定的方式是瓶颈的时候,虚拟机会启动一个后台线程,把字节码编译成本地代码,非常仔细的优化这些本地代码。

不止如此,自适应优化器可以在运行时根据Java程序的特性进行微调。Java运行时的特征就是方法调用动态派发的高频度发生,而这两个特性,对于自适应优化有着很大的挑战。

方法调用会使优化器的有效性降低,因为优化器在不同的方法调用间不能有效工作。方法调用频度越高,方法间可以用来优化的代码就越少,优化器就变得低效。解决方法调用的问题的标准方案是内嵌,但是对于一个动态派发的方法调用,内嵌就变得复杂了,一个函数调用可能有多个函数实现。对于这种情况,可以把所有实现都内嵌进去,但是优化后的代码可能会变得很大。

线程

Java虚拟机规范定义了线程模型,这个模型的主要目标是要有助于在很多体系结构上都实现它。同时规范定义了很多规则,用来管理线程和主存之间的低层交互行为。

优先级问题

Java虚拟机规范对于不同优先级别的线程行为,只规定了优先级高的线程会得到大多数的CPU时间,较低优先级的线程在比它高优先级的线程都阻塞的情况下才能保证得到CPU时间。不过级别低的线程在级别高的线程没有阻塞的情况下也能得到CPU时间,但这没有任何保证。
所以程序的正确运行不能依靠时间分片,要协调多线程之间的活动,应使用同步

线程的行为

Java线程的行为是通过属于变量,主存,工作内存来定义的。

  • 主存:每个 Java虚拟机实例都有一个主存,用于保存所有的程序变量(对象的实例变量、数组的元素以及类变量)
  • 工作内存:每一个线程都有一个工作内存,线程用它保存所使用和赋值变量的『工作拷贝』

Java 虚拟机规范规定了一个线程何时可以做以及何时必须做以下的事情:

  • 把变量的值从主存拷到它的工作内存
  • 把值从工作内存拷贝回主存

而所有管理线程低层规则的高层含义是:如果访问没有被同步的变量,虚拟机允许线程用任意顺序更新主存。

本地方法接口(JNI)

Sun 的 Java本地接口,或者称为 JNI,是为可移植性准备的。JNI 设计的可以被任何Java 虚拟机实现支持,而不管它用的是何种垃圾收集器或对象表示技术。并且为了实现可移植性,JNI 在指针与指针之间、指针与方法之间使用了很多间接方法。

为了做好工作,本地方法必须能够和 Java虚拟机实例的某些内部状态有某种程度的交互。比如,本地方法接口运行本地方法完成下列部分或全部工作。

  • 传递或返回数据
  • 操作实例变量或调用具有垃圾收集功能的堆中的对象的方法
  • 操作类变量或者调用类方法
  • 操作数组
  • 对堆中的对象加锁,以便当前线程独占使用
  • 在具有垃圾收集功能的堆中创建对象
  • 装载新的类
  • 抛出新的异常
  • 捕获本地方法调用的 Java方法抛出的异常
  • 捕获虚拟机抛出的异步异常
  • 指示垃圾收集器不再需要

看完 JNI 官方规范,再单独开个 item 记录 JNI 的心得哈

好滴好滴,到此 Java 虚拟机的整体样貌已经出来了,后面就是对每个点进行深入的了解了,加油!!

你可能感兴趣的:(深入 Java 虚拟机(一)Java虚拟机概述)