Java 语言的面向对象,平台无关,安全,开发效率高等特点,使其在许多领域中得到了越来越广泛的应用。但是由于Java程序由于自身的局限性,使其无法应用于实时领域。由于垃圾收集器运行时将中断Java程序的运行,其运行时刻和垃圾搜集时间具有不确定性。在应用程序高频率分配和释放内存时,垃圾收集要占用的时间可能比程序自身运行的时间还要多。这些都使得 Java程序无法满足在实时领域应用的要求。
Java实时规范(RTSJ)的内存管理机制既保证了Java本身的内存安全的优势,同时又保证了在实时系统中对内存操作的的可预测性。不产生垃圾的代码不会导致请求式的垃圾收集;不引用堆中对象的代码可以抢占垃圾收集器的线程。为了能够保留垃圾收集的好处又能避免垃圾收集器对实时特性的影响,基于以上两个事实RTSJ扩展了Java内存管理机制,在传统的堆内存的基础上,又提出了内存区域(Memory Area)的概念。在新增加的这几个内存区域中分配对象不会导致垃圾收集器的执行,不会使系统受到其不可预测性的影响。
本文根据RTSJ的要求,介绍了RTSJ内存管理机制实现的各个基本要点,包括使用Display树的内存引用检查, LTMemory和VTMemory的分配机制, ScopeStack的维护等。文章通过研究一个可运行在多种操作系统并兼容多种硬件平台的开源的Java虚拟机SableVM的基础上,结合国内外最新的理论,提出了一个对其实时性进行改进的方案,并对其进行实验。该方案有别于国内外现有的实时Java虚拟机的实现,在内存管理方面即符合RTSJ的要求,同时又保证了Java程序可移植性的要求。
目录
3 实时 Java 虚拟机及其内存管理
3.1 实时 Java 虚拟机与嵌入式实时系统
3.2 普通 Java 虚拟机的内存管理
3.2.1 方法区
3.2.2 Java堆
3.2.3 Java栈
3.2.4 寄存器
3.2.5 本地方法栈
3.3 实时 Java 虚拟机的内存管理
3.3.1 内存区域的管理
3.3.2 垃圾收集
3.3.3 内存分配时间
3.3.4 对象引用规则
3 实时 Java 虚拟机及其内存管理
3.1 实时 Java 虚拟机与嵌入式实时系统
实时Java虚拟机可以说是实时系统的一个实例,不过它是一个运行于操作系统和硬件平台之上的实时系统。实时 Java 虚拟机要保证在其中运行的Java 程序具有实时特性,而它自身又是运行于操作系统之上,其实时性很大程度上依赖于该宿主操作系统所提供服务的实时性。因此要实现实时Java虚拟机,需要有实时操作系统的支持。实时Java虚拟机系统框图见图3-1所示。
嵌入式系统是设计一套完成复杂功能的硬件和软件,并使其紧密耦合在一起的计算机系统。嵌入式实时系统是具有实时性能的嵌入式系统。一般情况下它并不要虚拟一个系统,而是其本身的操作系统即具有实时特性。如图3-2 所示。这种专门为实时而设计的简洁专用的实时系统,往往能够实现比虚拟机更加精确的响应时间,但是在这样的系统上开发应用,一般采用的是汇编或 C 语言,开发难度较大。
因此实时 Java 虚拟机系统不同于嵌入式实时系统,但是在实际应用中嵌入式实时系统往往可以包含实时 Java 虚拟机。为了使 Java 虚拟机达到真正意义上的实时,必须要有实时操作系统的支持,这就加大了研究实时 Java虚拟机的难度。因为现有的实时操作系统较少,且不容易得到,要将Java虚拟机和实时操作系统完美结合难度较大。由于本文仅研究 Java 虚拟机实时内存管理的机制,因此忽略了其它方面的次要矛盾。在普通的x86硬件平台上,运行非实时的操作系统ubuntu(linux的一个发行版本),在其基础上,对Java虚拟机实时内存管理进行了深入的研究,并且予以实现。同时为了实验的需要,在普通线程的基础上,模拟了一个简单的实时线程。本课题所设计的实时 Java虚拟机框图如图3-3 所示。
实时线程要达到实时,必须要有实时操作系统的支持,对于非实时系统中的“实时线程”只能给实时内存管理提供一个编程的接口。由于只是在比较理想的环境下测量内存管理的实时特性,非实时线程对其影响不会很大,不会对实验结果造成太大的影响。
3.2 普通 Java 虚拟机的内存管理
实时Java虚拟机内存管理是建立在普通Java虚拟机内存管理的基本原理基础上的,首先介绍一下普通 Java虚拟机的内存管理机制。
图3-4是Java虚拟机的体系结构,Java虚拟机为每个执行的程序定义了不同的运行时数据区,有一些数据区是在Java虚拟机启动时创建而在退出时销毁。而其他一些数据区是为每一个线程服务的,当线程创建时创建该数据区,而在线程退出时销毁。
如图3-4,我们可以看出Java虚拟机将内存划分方法区(method area)、堆(heap)、栈(java stack)、寄存器(register)、本地码栈(native stack)等几个部分。执行一个虚拟机实例时,虚拟机需要对申请到的内存进行管理,这就涉及到一个管理策略的问题。在Java虚拟机中的运行时数据区在虚拟机设计时都以某种形式实现的,但虚拟机规范对其描述较为抽象,在运行时的数据结构细节大多数由具体的设计者决定。
下面分别介绍跟内存有关的这几个部分。
3.2.1 方法区
Java 虚拟机有一个被所有虚拟机线程共享的方法区(如图3-5),它保存的是每一个类的数据比如运行时常量池、字段、方法数据和代码。当虚拟机装载某个类型时,它使用类装载器定位到相应的class文件,然后读入这个文件(一个线性二进制数据流),将它传输到虚拟机中。紧接着虚拟机提取其中的类型信息,并将这些信息存储到方法区。该类型中的类(静态)变量也是存储在方法区中。
由于所有线程都共享方法区,因此它们对方法区的数据的访问必须被设计为线程安全的。比如,假如同时有两个线程都企图访问一个名为Class1的类,而这个类还没有被装入虚拟机,那么,这时应该只有一个线程装载它,而另一个线程则只能等待。
方法区的大小不必是固定的,虚拟机可以根据需要动态调整。同样,方法区也不必是连续的,方法区可以在一个堆(甚至虚拟机自己的堆)中自由分配。另外,虚拟机也可以允许用户或程序员指定方法区的初始大小以及最小和最大尺寸等。
方法区也可以被垃圾收集,因为虚拟机允许通过用户定义的类装载器来动态扩展Java程序,因此一些类也会成为程序“不再引用”的类。当某个类变为不再被引用时,Java虚拟机可以卸载这个类(垃圾收集),从而使方法区占据的内存保持最小。
在方法区中,存储着每个类型的以下信息:类型信息、常量池、字段信息、方法信息、类(静态)变量、类装载器等。
3.2.2 Java堆
Java堆由所有的虚拟机线程共享,所有的类实例和数组都是从堆中分配内存(如图3-6)。Java堆在虚拟机启动时创建,由自动内存管理程序来管理,对象在堆中是隐式回收的。
Java程序在运行时创建的所有类实例或数组都放在同一个堆中。而一个Java虚拟机实例中只存在一个堆空间,因此所有线程都将共享这个堆。又由于一个Java程序独占一个 Java 虚拟机实例,因而每个 Java 程序都有它自己的堆空间,它们不会彼此干扰。但是同一 Java 程序的多个线程却共享着同一个堆空间,在这种情况下,就得考虑多线程访问对象(堆数据)的问题。
Java虚拟机有一条在堆中分配新对象的指令,却没有释放内存的指令。虚拟机自己负责决定如何以及何时释放不再被运行的程序引用的对象所占据的内存。通常,这个任务虚拟机交给垃圾收集器来完成。垃圾收集器的主要工作就是自动回收不再被运行的程序引用的对象所占用的内存。此外,它也可能去移动那些还没有使用的对象,以此减少堆碎片。和方法区一样,堆空间也不必是连续的内存区。在程序运行时,它可以动态扩展或收缩。事实上,方法区也可以在堆中实现。换句话说,就是当虚拟机需要为一个新装载的类分配内存时,类型信息和实际对象可以都在同一个堆上。因此,负责回收无用对象的垃圾收集器可能也要负责无用类的释放(卸载)。另外,某些实现可能也允许用户或者程序员指定堆的初始大小、最大最小值等。
Java对象中包含的基本数据由它所属的类及其所有超类声明的实例变量组成。只要有一个对象引用,虚拟机就必须能够快速地定位对象实例的数据。另外,它也必须能够通过该对象引用相应的类数据(存储于方法区的类型信息)。因此在对象中通常会有一个指向方法区的指针。通常,堆空间以下有两种设计方法。
一种是把堆分成两个部分:句柄池和对象池。如图3-7 所示,对象引用就是指向句柄池的指针。句柄池的每个条目有两个部分:一个指向对象实例变量的指针,一个指向方法区类型数据的指针。这种设计的好处是有利于堆碎片的整理,当移动对象池中的对象时,句柄部分只需要更改一下指针指向对象的新地址就可以了,也就是将在句柄池中的对象指针替换为新地址。缺点是每次访问对象的实例变量都要经过两次指针传递。
另一种方法是使对象指针直接指向堆中一组数据,而该数据包括对象实例数据以及指向方法区中类数据的指针,如图3-8 所示。这样设计的优缺点正好跟上一种相反,它只需要一个指针就可以访问对象的实例数据,但是移动对象会变得更加复杂。当使用这种堆的虚拟机为了减少内存碎片而移动对象时,它必须在整个运行时数据区中更新指向被移动对象的引用。
3.2.3 Java栈
每当启动一个新线程时, Java 虚拟机都会为它分配一个Java栈【10】 (如图3-9)。 Java栈是以帧(frame)为单位保存线程的运行状态。虚拟机只会直接对Java栈执行两个操作:以帧为单位的压栈或出栈。
某个线程正在执行的方法被称为该线程的当前方法,当前方法使用的栈帧称为当前帧,当前方法所属的类称为当前类,当前类的常量池称为当前常量池。在线程执行一个方法时,它会跟踪当前类和当前常量池。此外,当虚拟机遇到栈内操作指令时,它对当前帧内数据执行操作。
每当线程调用一个Java方法时,虚拟机都会在该线程的Java栈中压入一个新帧。而这个新帧自然就成为了当前帧。在执行方法时,它使用这个帧来存储参数、局部变量、中间运算结果等数据。
Java方法可以以两种方式完成。一种通过return返回的,称为正常返回;一种通过抛出异常而异常终止。不管以哪种方式返回,虚拟机都会将当前帧弹出Java栈然后释放掉,这样上一个方法的帧就成为当前帧。
Java栈上的所有数据都是此线程私有的。任何线程都不能访问另一个线程的栈数据。
栈帧由三个部分组成:局部变量区、操作数栈和帧数据区。局部变量区和操作数栈的大小要视对应的方法而定,它们是按字长计算的。编译器在编译时就确定了这些值并放在 class 文件中。而帧数据区的大小依赖于具体的实现。
当虚拟机调用一个Java方法时,它从对应类的类型信息中得到此方法的局部变量和操作数栈的大小,并据此分配栈帧内存,然后压入Java栈中。
3.2.4 寄存器
对于一个运行中的 Java 程序而言,其中的每一个程序都有它自己的PC(程序计数器)寄存器,它是在该线程启动时创建的。PC寄存器的大小是一个字长,因此它既能够有一个本地指针,也能够持有一个返回地址(return address)。当线程执行某个 Java 方法时,PC 寄存器的内容总是下一条将执行的地址。这里的地址可以是一个本地指针,也可以是方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么此时 PC 寄存器的值是“未定义的(undefined)”。
3.2.5 本地方法栈
任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入 Java 栈。然而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不再在线程的 Java 栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。
如果某个虚拟机实现的本地方法接口是使用 C连接模型,那么它的本地方法栈就是C栈。当 C 程序调用一个C函数时,其栈操作都是确定的接口传递给该函数的参数以某个确定的顺序入栈,它的返回值也以确定的方式传回调用者。同样,这就是虚拟机实现中本地方法栈的行为。很可能本地方法接口需要回调Java虚拟机中的Java方法,这种情况下,该线程会保存本地方法栈的状态并进入到另一个Java 栈。
如图3-10所示,该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。图中的本地方法栈显示为一个连续的内存空间。假设这是一个C语言栈,其间有两个C函数,它们都以包围在虚线中的灰色块表示。第一个C函数被第二个Java方法当作本地方法调用,而这个C函数又调用了第二个 C 函数。之后第二个 C 函数又通过本地方法接口回调了一个 Java 方法〔第三个方法),最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。
就像其它运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的,它可以根据需要动态扩展或者收缩。某些实现也允许用户或者程序员指定内存区的初始大小以及最大、最小值。
3.3 实时 Java 虚拟机的内存管理
RTSJ 在普通 Java 虚拟机的基础上,对其实时性进行了扩展,提出了内存区域(Memory Area)的概念。在普通Java虚拟机中只有一个内存空间,就是堆。而RTSJ根据实时性的要求在此基础上新加入了几种内存,内存区域是对 Java虚拟机各种内存的总称,用继承的观点来说,就是所有内存的父类。这些内存在实际操作中,可以不注重其中的细节,都可以当作内存区域的一个实例来看待。图1-1 列出了新增内存区域的继承关系。
由于对堆的操作会导致垃圾收集而严重影响实时性,RTSJ提出了两种不涉及堆的内存形式:不朽内存(Immortal Memory)和领域内存(Scoped Memory)。不朽内存中的对象一旦分配就一直存在直到虚拟机退出。而领域内存可以看作临时性的不朽内存,它也不会引起垃圾收集,但只在某个范围内有效,离开该范围时,该内存块作为一个整体被释放掉,释放操作不影响系统的实时性。
垃圾收集器和内存区域模型都是实时 Java虚拟机内存管理的重要内容,但是RTSJ侧重于对内存区域模型的规范,而没有限定垃圾收集器的实现算法1.本文也主要关注于内存区域模型的实现,而对于垃圾收集器还是使用常用的算法。新增的内存区域的设计需要考虑以下几个问题:
1. 内存区域的管理问题。
2. 垃圾收集的问题。
3. 内存分配时间的问题。
3.3.1 内存区域的管理
内存区域需要在Java虚拟机中良好的管理,分配和释放。区别于传统的Java虚拟机所有的线程都只使用唯一的一个内存区域——堆,实时Java虚拟机的多个生命期不同的内存区域是由各个使用它的线程中的领域堆栈(Scope Stack)来管理的。
检查对象引用规则时需要领域堆栈来保存区域内存嵌套关系,每个线程有自己的领域堆栈。在创建一个实时线程时,领域堆栈初始化如下:
1、如果该线程是在区域内存中创建,则它的初始领域堆栈包含了其创建时双亲线程领域堆栈结构的副本。
2、如果该线程在堆或永久内存中创建,它的领域堆栈结构初始化为只包含堆或永久内存。
之后随着线程的推移,领域堆栈不断地把区域内存压入或弹出,保存它们嵌套使用的层次关系。
RTSJ提供了三种方法来进入一个区域内存,此后的空间将在此区域进行分配,直到退出该区域或进入另一个区域。
New thread:在创建一个线程时,把一个区域内存(ma)句柄作为参数传递给该线程,该线程默认的空间分配将在该区域内存中进行。
enter:当每次调用ma.enter()方法时,把ma压入该线程的领域堆栈。同时该线程默认的空间分配将在 ma进行,直到 enter()方法返回,或进入另一个区域。
executeInArea:用法和enter)类似,区别是该线程的领域堆栈不变,只是使用已在领域堆栈中的区域内存。
3.3.2 垃圾收集
由于新增的内存区域中的对象可能会引用堆中的对象,因此垃圾收集的过程中也需要扫描新增内存区域中对象的字段,不过不会回收新增内存区域中的对象。
3.3.3 内存分配时间
内存分配时间的问题。Scoped Memory 主要派生出3 个子类 CTMemoy, LTMemory,VTMemory, RTSJ对它们分配对象的时间有不同的要求。CTMemory要求在其中分配对象的时间是常数时间, LTMemory要求在其中分配对象的时间与对象的大小成正比,VTMemory要求在其中分配对象的时间是任意的,由于没有限制,它更关注于在空间上高效的分配内存。
3.3.4 对象引用规则
由于内存区域的生命期的不同,有些内存区域的生命期与Java虚拟机的生命期相同如HeapMemory和Immortal Memory,而有的生命期是短暂的,与操作它的线程相关,如Scoped Memory。这就引发了内存区域间的引用关系的问题。总的来说,生命期长的内存区域中的对象的字段不能引用生命期短的内存区域中的对象,因为当后者被销毁的时候,而前者由于继续存活而变成非法引用,这就要求建立一个合理而高效的内存引用检查机制。内存区域间的引用关系要求如表3.1所示1。
为了方便多个线程共享领域内存时对象引用规则的检查, RTSJ提出单亲规则,如果单亲规则不成立,对象引用规则一定不成立。
单亲规则:每次往领域堆栈(Scope Stack)加入一个区域时都要确保它只有一个双亲。双亲是领域堆栈(Scope Stack)上相邻的外层领域内存、堆或不朽内存。
如图3-11,线程 T1 在内存区域 Ma3 中创建了一个对象 O1,在 Mal 中保存了 O1的引用变量R1。
线程 T2 在 Ma2 中创建了一个对象 O2,在 Mal 中保存了 O2 的引用变量 R2。
单独就 T1 和 T2 来说,都符合对象引用规则。但图3-11 中,Mal 的双亲分别是 Ma3和Ma2,违犯了单亲规则。假设T1线程退出Ma3后,没有其它线程使用,Ma3被释放掉。这时Mal中的R1就成了悬空引用。所以在多线程共享区域内存的情况下,首先要保证单亲规则成立,再进一步针对每个线程检查对象引用规则。