JVM系列之一:Java虚拟机
什么是Java虚拟机?
Java虚拟机,从字面上来看,像是某种机器,但Java虚拟机之所以被称之为“虚拟”的,是因为它是由一个规范来定义的抽象计算机,所以在我们说Java虚拟机的时候,可能指的是如下三种不同的东西:
抽象规范
一个具体的实现
一个运行中的虚拟机实例
Java虚拟机的生命周期
当启动一个Java程序时,一个虚拟机实例也就诞生了。该程序关闭退出时,虚拟机实例也就随之消亡。Java虚拟机通过调用某个初始类的main()方法作为Java程序运行的起点。在Java虚拟机内容,有两种线程,守护线程和非守护线程。守护线程一般为虚拟机自己使用,比如垃圾收集线程,非守护线程比如运行main()的线程。当程序中所有非守护线程终止时,则虚拟机实例自动退出。
Java虚拟机的体系结构
在Java虚拟机规范中,一个虚拟机实例的行为按照子系统、内存区、数据类型以及指令几个术语来描述。每个Java虚拟机都有一个类装载子系统,它根据全限定名来装入系统,同样,每个Java虚拟机都有一个执行引擎,它负责执行那些包含在被装载类的方法中的指令。
当Java虚拟机运行一个程序时,它需要内存来存储许多东西,比如,字节码、对象、局部变量、运算的中间结果等。某些运行时数据区由程序中所有线程共享,还有一些只能由一个线程拥有。每个虚拟机都有一个方法区和一个堆,他们是由该虚拟机中所有线程共享的。当虚拟机装载一个class文件时,会把类型信息存入方法区,程序运行时虚拟机会把运行时创建的对象存入堆中。
每当一个新线程创建的时候,它都将获得它自己的PC寄存器和一个Java栈,若线程正在执行一个Java方法(非本地方法),那么PC计数器的值总是指示下一条将被执行的指令,Java栈则包含线程中Java方法的调用状态—-包括它的局部变量,被调用时传进来的参数、它的返回值、计算的中间结果等。本地方法调用,则是以某种依赖于具体实现的方式存储在本地方法栈中,也可能是寄存器和其他某些与特定实现相关的内存区。
Java栈是由许多栈帧或者说帧组成,一个栈帧包含一个Java方法的调用状态。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到栈中,方法返回后此栈帧弹出并抛弃。
数据类型
数据类型可分为两类:基本类型和引用类型,基本类型持有原始值,引用类型持有引用值。Java基本类型的值域在任何地方都是一致的,比如一个long类型在任何虚拟机中都是64位二进制补码表示的有符号整数。
注意:boolean有些特别,虽然boolean也是基本类型,但是在编译为字节码时,它会用int或者byte来表示boolean,false表示为整数0,true为整数1。另外boolean数组是被当做byte数组类访问的。
字长的考量
Java虚拟机中,最基本的数据单元就是字,虚拟机实现者最少选择32位作为字长,或者选择更为高效的字长。通常根据底层主机平台的指针长度来选择字长。
类装载器子系统
Java虚拟机中有两种类装载器:启动类装载器和用户自定义装载器。前者是Java虚拟机实现的一部分,后者是Java程序的一部分。不同类装载器放在虚拟机内部的不同命名空间中。
ClassLoader中定义的方法为程序提供了访问类装载器机制的接口。每一个被装载的类型,Java虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。用户自定义的类装载器以及Class类的实例都放在内存中的堆区,而装载的类型信息则都位于方法区。
类装载器除了要定位和导入二进制class文件外,还需要负责导入类的正确性,分配变量和初始化内存,解析符号引用等。这些动作必须严格按照以下步骤进行:
1、装载 ——– 查找并载入二进制数据
2、连接 ——– 执行验证,准备,已经解析(可选)
验证: 确保被导入类型的正确性
准备:为类变量分配内存,并将其初始化为默认值
解析:把类型中的符号引用转换为直接引用
3、初始化 ——– 把类变量初始化为正确的初始值
方法区
在Java虚拟机中,关于被装载类型的信息存储在一个逻辑上被称为方法区的内存中。当虚拟机装载某个类型时,首先使用类装载器定位相应的class文件,然后读入class文件
——-
一个线性二进制数据流,然后将它传输到虚拟机中。之后虚拟机提取出其中的类型信息存入方法区,同时该类中的静态变量也是存储在方法区中。
所有线程都共享方法区,所以它们对方法区的访问必须为线程安全的,比如,如果两个线程同时都企图访问名为Lava的类,而此类尚未装载进虚拟机,那么,这时只应该有一个线程去装载它,另一个只能等待。
方法区大小不是固定的,可以根据需要自己调整。同样方法区也不必是连续的,方法区可以在同一个堆中自由分配,也可以由程序员指定方法区的初始大小的最大尺寸和最小尺寸等。
方法区也可以被垃圾收集,虚拟机允许用户定义的类装载器来动态扩展Java程序(反射),因此一些类也会成为程序“不再引用”的类。当某个类不再被引用时,Java虚拟机可以卸载此类。
对应每个装载的类型,虚拟机会在方法区存储以下类型信息:
此类的全限定名
此类的直接超类全限定名
此类是类类型还是接口类型
此类的访问修饰符
任何直接超类的全限定名的有序列表
除以上列出的基本类型信息,虚拟机还得为每个装载的类型存储以下信息
该类型的常量池
字段信息
方法信息
除了常量以外所有类的(静态)变量
一个到类的ClassLoader引用
一个到Class类的引用
堆
Java程序运行时创建的所有类实例和数组都放在同一个堆中,而一个Java虚拟机实例中只有存在一个堆空间,因此所有线程都将共享这个堆。由于每一个Java程序独占一个堆空间,因此所有的线程将共享这个堆。但是同一个程序的多个线程却共享着同一个空间,此种情况下,需要考虑多线程访问对象(堆数据)的同步问题。
Java虚拟机有一条在堆中分配新对象的指令,却没有释放内存的指令。正如我们无法用Java代码去明确释放一个对象一样,字节码中也没有相关功能。需要虚拟机自己负责决定如何已及何时开始垃圾收集。程序本身也不需要关心何时回收,通常虚拟机把这个任务交给垃圾收集器。
Java虚拟机规范并没有规定Java对象在堆中是如何表示的。对象的内部表示影响整个堆以及垃圾收集器的设计,它由虚拟机的实现者决定。
一种可能的堆空间设计为,把堆分为两个部分:一个句柄池,一个对象池,而一个对象引用则是指向句柄池的本地指针。句柄池分为两个部分:一个指向对象实例变量的指针,一个指向方法区类型的指针。这种设计的有点为有利于堆碎片的整理,缺点为每次访问对象都要经过两次指针的传递。
另一种设计是使对象指针直接指向一组数据,而该数据包括对象的实例以及方法区中的类数据指针。此设计优缺点与上一中方法正好相反。
虚拟机必须要能通过对象的引用获取到类(类型)数据:在程序运行时需要转换某个对象引用为另一种类型时,虚拟机需要检查这种转换是否被允许,及被转换的对象是否确定是被引用的类型以及它的超类。比如程序执行instanceof()时,虚拟机都需要查看被引用数据的对象的类数据。最后,当程序中调用某个实例方法时,虚拟机必须进行动态绑定。
不管虚拟机的实现是用了什么样的对象表示法,每个对象都可能有一个方法表,因为方法表可以加快调用实例的速度。但是Java虚拟机的实现规范中并未要求必须使用方法表,所有并不是所有实现都会使用它。而且为每一个对象都建一个方法表会占用更多的内存,所以该方案只适用于内存足够的系统。
上图中展示了一种把方法表和对象引用联系起来的方式,每个对象都包含一个指向特殊数据结构的指针,这个数据结构位于方法区,它包括两部分:
一个指向方法区对应类数据的指针
此对象的方法表:方法表是一个指针数组,其中每一项都是一个“实例方法的指针”,方法表指向的实例方法数据包括以下信息:
此方法的操作数栈和局部变量区的大小
此方法的字节码
异常表
方法表中包含方法指针,指向类或其超类声明的方法数据:也就是说方法表中所指向的方法可能是此类声明的,也可能是继承下来的。
堆上的对象数据中还有一个逻辑部分,那就是对象锁,这是一个互斥对象。虚拟机上每个对象都有一个对象锁,被用于协调多个线程访问同一个对象的同步。在任何时刻,只能由一个线程“拥有”这个对象锁,因此只有这个线程能访问此对象的数据,其他想访问此对象的线程只能等待,直到拥有此锁的线程释放锁。(某个线程拥有一个对象锁后,可以继续对此锁追加请求。需要注意的是,请求几次,对应地需要释放几次,比如一个线程请求了三次锁,在它释放三次锁之前,他一直“拥有”此锁)。很多对象在其生命周期内没有被任何线程加锁,所以锁数据不是必须存在的,只有在第一次加锁的时候才会分配锁数据,这时虚拟机需要用某种方法来联系对象数据和对应的锁数据,比如把锁数据放在一个以对象地址为索引的搜索树中。
除了实现锁所需要的数据外,每个Java对象逻辑上与实现等待集合的数据相关联。等待集合和通知方法联合使用,每个类都从Object继承三个等待方法(三个名为wait()的重载方法)和两个通知方法(notify()和notifyAll())。当某个线程在一个对象上调用等待方法时,虚拟机就阻塞这个线程,并把它放到相应的等待集合中,直到另一个线程在同一个对象上调用通知方法,虚拟机才会在某个时刻唤醒一个或多个等待集合中被阻塞的线程。
最后一种数据类型 ——- 可以作为堆中某个对象映像的一部分,是与垃圾收集器有关的数据。垃圾收集器必须以某种方式跟踪引用的每个对象。此任务不可避免需要附加一些数据给这些对象,数据类型需要根据垃圾收集器的算法而定。
数组的内部表示,数据也拥有一个与它们的类相关联的Class类实例。同样也在堆中表示,与其他对象一样,数组也拥有一个与它们的类相关联的Class类实例,所有具有相同维度和类型的数组都是同一个类的实例。而不管长度是多少。
数组类的名称由两部分组成:每一维用一个方括号表示”[“,用字符或字符串表示元素类型,比如,类型为int的一维数组“[1”,元素类型为byte的三维数组为“[[[B”,类型为Object的二维数组为”[[Ljava/lang/Object”。
程序计数器
对于每一个运行中的Java程序而言,其中的每一个线程都有它自己的PC寄存器,它是在该线程启动时创建的,PC寄存器的大小是一个字长。当线程执行某个Java方法时,PC寄存器的内容总是下一条将被执行的”地址”。
这里“地址”可以是一个本地指针,也可以是方法字节码中相对于该方法起始的偏移量。
Java栈
每当启动一个线程,Java虚拟机都会为它分配一个Java栈。Java栈以帧的形式为单位保存线程的运行状态,虚拟机只会对栈执行两种操作:以帧为单位的压栈和出栈。
每当线程调用一个Java方法时,虚拟机都会在该线程的Java栈中压入一个新帧。当前使用的栈帧被称为当前帧,在执行这个方法时,它使用这个帧来存储参数,局部变量,中间运算结果等等数据。
Java方法可以由两种方式完成,一种是通过return返回,一种是抛出异常终止。不管以哪种方式返回,虚拟机都会将当前栈帧弹出Java栈然后释放掉。此外,Java栈上的所有数据都是此线程私有的。任何线程不可访问另一个线程的栈数据。
栈帧
栈帧有三部分组成:局部变量区,操作数栈和帧数据区
当虚拟机调用一个Java方法时,它从对于类的类型信息得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧内存,然后压入Java栈中。
局部变量区:Java栈帧的局部变量区被组织为一个字长为单位、从0开始计数的数组。字节码通过从0开始的索引来使用其中的数据。类型为int,float,reference和returnAddress的值在数组中只占据一项,类型byte,short和char的值存入数组前将被转换为int,因而同样占据一项,类型为long和double的值在数组中占据连续的两项。
需要注意的是,在runInstanceMethod()中,局部变量的第一个参数是一个reference引用类型,这个参数this用于表示对象本身,对于任何一个实例方法this都是隐含加入的。
操作数栈:和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者的区别是,它不是通过索引来访问的,而是通过标注的栈操作—压栈和出栈来访问的。
不同于程序计数器,Java虚拟机没有寄存器,程序计数器也无法被程序指令直接访问,Java虚拟机指令是从操作数栈中而不是寄存器中取得操作数的,因此它的运行方式是基于栈的而不是基于寄存器的。下图演示了两个局部变量相加的过程
帧数据区:除了局部变量和操作数栈外,Java栈帧还需要一些数据来支持常量池的解析,正常方法的返回以及异常派发机制。这些信息都存储在Java帧栈的帧数据区中。
Java中大多数指令都涉及到常量池入口。有些指令仅仅是从常量池中取出数据压入Java栈(这些数据包括int,long,float,double和String),还有些指令使用常量池中的数据来指示要实例化的类或数组、要访问的字段或者要调用的方法。
当虚拟机需要使用到常量池中的数据时,它会通过帧数据区中指向常量池的指针来访问他它。常量池中对于类型、字段和方法的引用在开始的时候都是符号。当虚拟机在常量池中搜索的时候,如果遇到的类型、字段和方法仍然是符号,虚拟机这时才会去解析。
Java栈可能的实现方式:一种可能的方式,从堆中分配每一个帧,例如,下面考虑下面的类
下图显示了addAndPrint()方法的三次快照。在这个Java虚拟机实现中,每个帧都单独从堆中分配。为了调用方法addTowTypes(),方法addAndPrint()首先把1和88.88压入它的操作数栈中,然后调用addTowTypes()方法。
调用addTowTypes()的指令指向一项常量池的数据,因此在常量池中查找这些数据,这期间有必要还需进行解析。
解析后的常量池数据将指向方法区中对应的addTwoTypes()的信息。虚拟机需要利用这些信息来确定addTwoTypes()的局部变量区和操作数栈的大小。
本地方法栈:
前面所有运行时数据区都是在Java虚拟机规范中明确定义的,不过程序运行时可能还会使用到一些与本地方法相关的数据区。当某个线程调用一个本地方法时,它就进去了一个全新的并且不受虚拟机限制的世界。
当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈中。但它调用的是本地方法,虚拟就就会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单的动态链接并直接调用使用本地方法。
下面展示了线程调用本地方法的过程:
我们首先调用两个方法,然后在这两个方法中的第二个方法中调用一个本地方法,导致虚拟机使用一个本地方法栈,假设这是一个C语言栈,期间有两个C函数,第一个C函数被第二个Java方法调用,而后第一个C函数调用第二个C函数,第二个C函数又调用一个Java方法。之后第二个C函数又通过本地方法接口回调一个Java方法,最终这个Java方法又调用一个Java方法。
与其他运行时内存区一样,本地方法栈所占用的内存区也不是固定大小的,它可以根据需要动态扩展或者收缩。某些实现也允许用户或者程序员指定内存区初始大小以及最大最小。
参考:深入理解Java虚拟机第二版