Java虚拟机
Java虚拟机之所以被称之为是虚拟的,就是因为它仅仅是由一个规范来定义的抽象计算机。因此,要运行某个Java程序,首先需要一个符合该规范的具体实现。
Java虚拟机的生命周期
一个运行时的Java虚拟机实例的天职就是:负责运行一个Java程序。当启动一个Java程序时,一个虚拟机实例就诞生了。当该程序关闭退出,这个虚拟机实例也就随之消亡。每个Java程序都运行于它自己的Java虚拟机实例中。
Java虚拟机实例通过调用某个初始类的main()方法来运行一个Java程序。而这个main()方法必须是共有的public、静态的static、返回值为void,并且接受一个字符串数组作为参数。任何拥有这样一个main()方法的类都可以作为Java程序运行的起点。
比如,考虑这样一个Java程序,它打印出传给它的命令行参数:
Class Echo{
Public static void main(String[] args){
Int len = args.length;
For(int i = 0; i < len; ++i){
System.out.print(args[i] + “ ”);
}
System.out.println();
}
}
必须告诉Java虚拟机要运行的Java程序中初始类的名字,这个程序将从它的main()方法开始运行。如在Windows上使用命令:
Java Echo Greetings, Planet.
Java程序初始类中的main()方法,经作为该程序初始线程的起点,任何其他的线程都是由这个线程启动的。
在Java虚拟机内部有两种线程:守护线程和非守护线程。守护线程通常是由虚拟机自己用的,比如执行垃圾收集任务的线程。但是,Java程序也可以把它创建的任何线程标记为守护线程。而Java程序中的初始线程---就是开始于main()的那个,是非守护线程。
只要还有任何非守护线程在运行,那么这个Java程序也在继续运行(虚拟机仍然存活)。当该程序所有的非守护线程都终止时,虚拟机实例将自动退出。假若安全管理器运行,程序本身也能够通过调用Runtime类或者System类的exit()方法退出。
Java虚拟机的体系结构
在Java虚拟机规范中,一个虚拟机实例的行为是分别按照子系统、内存区、数据类型以及指令这几个术语来描述的。
下图为Java虚拟机的结构框图,包括在规范中描述的主要子系统和内存区。前面提到,每个Java虚拟机都有一个类装载器子系统,它根据给定的全限定名来装入类型。同样,每个Java虚拟机都有一个执行引擎,它负责执行那些包含在被装载类的方法中的指令。
当Java虚拟机运作一个程序时,它需要内存来存储许多东西,例如,字节码,从已装载的class文件中得到的其他信息,程序创建的对象、传递给方法的参数、返回值、局部变量以及运算的中间结果等,Java虚拟机把这些东西都组织到几个“运行时数据区”中,以便于管理。Java虚拟机规范对“运行时数据区”的描述是抽象的,由具体实现的设计者决定。
某些运行时数据区是由程序中所有线程共享的,还有一些则只能由一个线程拥有。每个Java虚拟机实例都有一个方法区以及一个堆,它们是由该虚拟机实例中所有线程共享的。当虚拟机装载一个class文件时,它会从这个class文件包含的二进制数据中解析类型信息。然后,它把这些类型信息放到方法区中。当程序运行时,虚拟机会把所有该程序在运行时创建的对象都放到堆中。
当每一个新线程被创建时,它都将得到它自己的PC寄存器(程序计数器)以及一个Java栈:如果线程正在执行的是一个Java方法(非本地方法),那么PC寄存器的值将总是指示下一条将被执行的指令,而它的Java栈则总是存储该线程中Java方法调用的装载---包括它的局部变量,被调用时传进来的参数,它的返回值,以及运算的中间结果等。而本地方法调用的状态,则是以某种依赖于具体实现的方式存储在本地方法栈中,也可能是在寄存器或者其他某些与特定实现相关的内存中。
Java栈是由许多栈帧(stack frame)或帧(frame)组成的,一个栈帧包含一个Java方法调用的状态。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java栈中,当该方法返回时,这个栈帧从Java栈中弹出。
Java虚拟机没有寄存器,其指令集使用Java栈来存储中间数据。这样设计的原因是为了保持Java虚拟机的指令集尽量紧凑,同时也便于Java虚拟机在那些只有很少通用寄存器的平台上实现。另外Java虚拟机的这种基于栈的体系结构也有助于运行时某些虚拟机实现的动态编译器和即时编译器的代码优化。
下图描绘了Java虚拟机为每一个线程创建的内存区,这些内存区域是私有的,任何线程都不能访问另一个线程的PC寄存器或者Java栈。
数据类型
Java虚拟机是通过某些数据类型来执行计算的,数据类型及其运算都是由Java虚拟机规范严格定义的,数据类型可以分为两种:基本类型和引用类型。基本类型的变量持有原始值,而引用类型的变量持有引用值。术语“引用值”指的是对某个对象的引用,而不是该对象的本身,与此相对,原始值则是真正的原始数据。
Java语言中的所有基本类型同样也都是Java虚拟机中的基本类型。但是boolean有点特别,虽然Java虚拟机也把boolean看做基本类型,但是指令集对boolean只有很有限的支持。当编译器把Java源码编译为字节码时,它会用int或byte来表示boolean。在Java虚拟机中,false是由整数零来表示的,所有非零整数都表示true,涉及boolean值的操作则会使用int。另外,boolean数组是当做byte数组来访问的,但是在“堆”区,它也可以被表示为位域。
Java虚拟机中还有一个只在内部使用的基本类型:returnAddress,Java程序猿不能使用这个类型,这个基本类型被用来实现Java程序的finally字句。
Java虚拟机的引用类型被统称为“引用”(reference),有三种引用类型:类类型、接口类型以及数组类型,它们的值都是对动态创建对象的引用。类类型的值是对类实例的引用;数组类型的值是对数组对象的引用,在Java虚拟机中,数组是个真正的对象;而接口类型的值,则是对实现了该接口的某个类实例的引用。
Java虚拟机规范定义了每一种数据类型的取值范围,但是却没有定义它们的位宽。位宽由具体的虚拟机实现设计者决定。
字长的考量
Java虚拟机中,最基本的数据单元就是字,它的大小是由每个虚拟机实现的设计者来决定的。字长必须足够大,至少是一个字单元就足以持有byte、short、int、char、float、returnAddress或者reference类型的值,而两个字单元就足以持有long或者double类型的值。因此,虚拟机实现的设计者至少得选择32位作为字长,或者选择更为高效的字长大小。通常根据底层主机平台的指针长度来选择字长。
在Java虚拟机规范中,关于运行时数据区的大部分内容,都是基于“字”这个抽象概念的。比如,关于栈帧的两个部分---局部变量和操作数栈---都是按照“字”来定义的。这些内存区能够容纳任何虚拟机数据类型的值,当把这些值放到局部变量或者操作数栈中时,它将占用一个或两个字单元。
在运行时,Java程序无法侦测到底层虚拟机的字长大小;同样,虚拟机的字长大小也不会影响程序的行为---它仅仅是虚拟机实现的内部属性。
类装载子系统
在Java虚拟机中,负责查找并装载类型的那部分被称为类装载子系统。
Java虚拟机有两种类装载器:启动类装载器和用户自定义类装载器。前者是Java虚拟机实现的一部分,后者则是Java程序的一部分。由不同的类装载器装载的类将被放在虚拟机内部的不同命名空间中。
类装载子系统涉及Java虚拟机的其他几个组成部分,以及几个来自java.lang库的类。比如,用户自定义的类装载器是普通的Java对象,它的类必须派生自java.lang.ClassLoader类。ClassLoader中定义的方法为程序提供了访问类装载器机制的接口。此外,对于每一个被装载的类型,Java虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。和所有其他对象一样,用户自定义的类装载器以及Class类的实例都放在内存中的堆区,而装载的类型信息则都位于方法区。
装载、连接以及初始化 类装载子系统除了要定位和导入二进制class文件外,还必须负责验证被导入类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。这些动作必须严格按以下顺序进行:
1、装载---查找并装载类型的二进制数据
2、连接---执行验证,准备,以及解析
验证---确保被导入类型的正确性
准备--为类变量分配内存,并将其初始化为默认值
解析---把类型中的符号引用转换为直接引用
3、初始化---把类变量初始化为正确初始值
启动类装载器 只要是符合Java class文件格式的二进制文件,Java虚拟机实现都必须能够从中辨别并装载其中的类和接口。某些虚拟机实现也可以识别其他的非规范的二进制格式文件,但它必须能够辨别class文件。
每个Java虚拟机实现都必须有一个启动类装载器,它知道怎么装载受信任的类,比如Java API的class文件。Java虚拟机规范并未规定启动类装载器如何去寻找class文件。
只要给定某个类型的全限定名,启动类装载器就必须能够以某种方法得到定义该类型的数据。在JDK1.2中,启动类装载器只在系统类(Java API的类文件)的安装路径中查找要装入的类;而搜索CLASSPATH目录的任务,现在交给了系统类装载器---它是一个自定义的类装载器,当虚拟机启动时就被自动创建。
用户自定义类装载器 尽管“用户自定义类装载器”本身是Java程序的一部分,但类ClassLoader中的四个方法是通往Java虚拟机的通道:
protected final class defineClass(String name, byte data[], int offset, int length)
protected final class defineClass(String name, byte data[], int offset, int length, ProtectionDomain protectionDomain)
protected final Class findSystemClass(String name);
protected final void resolveClass(Class c);
任何Java虚拟机实现都必须把这些方法连接到内部的类装载器子系统中。
两个被重载的defineClass()方法都要接受一个名为data[]的字节数组作为输入参数,并且在data[offset]到data[offset+length]之间的二进制数据必须符合Java class文件格式---它表示一个新的可用类型。而name参数是个字符串,它给出指定类型的全限定名。使用第一个defineClass()时,该类型被赋以默认的保护域,使用第二个时该类型的保护域由它的protectionDomain参数指定。每个Java虚拟机实现都必须保证ClassLoader类的defineClass()方法能够把新类型导入到方法区中。
findSystemClass()方法接受一个字符串作为参数,它指出被装入类型的全限定名。在版本1.2中,该方法使用系统类装载器来装载指定类型。任何Java虚拟机实现都必须保证findSystemClass()方法能够以这种方式调用系统类装载器。
resolveClass()方法接受一个Class实例的引用作为参数,它将对该Class实例表示的类型执行连接动作。而defineClass()方法则只负责装载。当defineClass方法返回一个Class实例时,也就表示指定的class文件已经被找到并装载到方法区了,但是却不一定被连接和初始化了。Java虚拟机实现必须保证ClassLoader类的resolveClass方法能够让类装载器子系统执行连接动作。
命名空间 每个类装载器都有自己的命名空间,其中维护着由它装载的类型。一个Java程序可以多次装载具有同一个全限定名的多个类型,当多个类装载器都装载了同名的类型时,为了唯一地标识该类型,还要在类型名称前加上装载该类型的类装载器的标识。
Java虚拟机中的命名空间,其实是解析过程的结果。对于每一个被装载的类型,Java虚拟机都会记录装载它的类装载器。当虚拟机解析一个类到另一个类的符号引用时,它需要被引用类的类装载器。
方法区
在Java虚拟机中,关于被装载类型的信息存储在一个逻辑上被称为方法区的内存中。当虚拟机装载某个类型时,它使用类装载器定位相应的class文件,然后读人这个class文件---一个线性二进制数据流---然后将它传输到虚拟机中、.紧接着虚拟机提取其中的类型信息,并将这些信息存储到方法区。该类型中的类(静态)变量同样也是存储在方法区。Java虚拟机在内部如何存储类型信息,这是由具体实现的设计者来决定的。
当虚拟机运行Java程序时,它会查找使用存储在方法区中的类型信息。设计其应当为类型信息的内部表示设计适当的数据结构,以尽可能在保持虚拟机小巧紧凑的同时加快程序的运行效率。如果正在设计一个需要在少量内存的限制中操作的实现,设计者可能会决定以牺牲某些运行速度来换取紧凑性。另外一方面,如果设计一个将在虚拟内存系统中运行的实现,设计者可能会决定在方法区中保存一些冗余倍息,以此来加快执行速度。(如果底层主机没有提供虚拟内存,但是提供了一个硬盘,设计者可能会在实现中创建一个虚拟内存系统。Java虛拟机的设计者可以根据目标平台的资源限制和需求,在空问和时间上做出权衡.选择实现什么样的数据结构和数据组织。
由于所有线程都共享方法区,因此它们对方法区数据的访问必须被设计为是线程安全的。比如,假设同时有两个线程都企图访问一个名为Lava的类,而这个类还没有被装人虚拟机,那么,这时只应该有一个线程去装载它,而另一个线程则只能等待。方法区的大小不必是固定的,虚拟机可以根据应用的需要动态调整。同样,方法区也不必是连续的,方法区可以在一个堆(甚至是虚拟机自己的堆)中*分配。另外,虚拟机也可以允许用户或者程序员指定方法区的初始大小以及最小和最大尺寸等。
方法区也可以被垃圾收集,因为虚拟机允许通过用户定义的类装载器来动态扩展Java程序,因此一些类也会成为程序“不再引用”的类。当某个类变为不再被引用的类时,Java虚拟机可以卸载这个类(垃圾收集)从而使方法区占据的内存保持最小。
类型信息 对每个装栽的类型,虚拟机都会在方法区中存储以下类型信息:
•这个类型的全限定名。
•这个类型的直接超类的全限定名(除非这个类型是java.lang.Object,它没有超类)
•这个类型是类类型还是接口类型
•这个类型的访问修饰符(public、abstract或final的某个子集)
•任何直接超接口的全限定名的有序列表。
在Java class文件和虚拟机中,类型名总是以全限定名出现在Java源代妈中,全限定名由类所属包的名称加一个再加一个“.”,再加上类名组成。例如,类Object的所属包为java.lang,那它的全限定名应该是java.lang.Object,但在class文件里,所有的“.”都被斜杠“/”代替.这样就成为java/lang/Objectc。至于全限定名在方法区中的表示,则因不同的设计者有不同的选择而不同,可以用任何形式和数据结构来代表。
除了上面列出的基本类型息外,虚拟机还得为每个被装载的类型存储以下信息:
•该类型的常量池。
•字段信息。
•方法信息
•除了常量以外的所有类(静态)变量。
•一个到类ClassLoader的引用。
•一个到Class类的引用。
常量池 虚拟机必须为每个被装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合,包括直接常量(string、integer和floating point常量)和对其他类型、字段和方法的符号引用。池中的数据项就像数组一样是通过索引访问的。因为常量池存储了相应类型所用到的所有类型、字段和方法的符号引用,所以它在Java程序的动态连接中起着核心的作用。
字段信息 对于类型中声明的每一个字段,方法区中必须保存下面的信息。除此之外,这些字段在类或接口中的声明顺序也必须保存。下面是字段信息的清单:
•字段名。
•字段的类型。
•字段的修饰符(public、private、protected.、static、final、volatile、transient的某个子集)。
方法信息 对于类型中声明的每一个方法,方法区中必须保存下面的信息。和字段一样,这些方法在类或者接口中的声明顺序也必须保存。下面是力法信息的清单:
•方法名。
•方法的返回类型(或void)
•方法参数的数量和类型(按声明顺序).
•方法的修饰符(public、private、protected、static, find、synchronized、native、abstract的某个子集)。
除上面的清单中列出的条目之外,如果某个方法不是抽象的和本地的,它还必须保存下列信息:
•方法的字节码(bytecodes)。
•操作数栈和该方法的栈帧中的局部变量区的大小。
•异常表。
类(静态)变量类变量是由所有类实例共享的,但是即使没有任何类实例,它也可以被访问。这些变量只与类有关——而非类的实例,因此它们总是作为类型信息的一部分而存储在方法区。除了在类中声明的编译时常量外,虚拟机在使用某个类之前,必须在方法区中为这些类变量分配空间。
而编译时常量(就是那些用final声明以及用编译时已知的值初始化的类变量)则和一般的类变量的处理方式不同,每个使用编译时常量的类型都会复制它的所有常量到自己的常量池中,或嵌人到它的字节码流中。作为常量池或字节码流的一部分,编译时常量保存在方法区中——就和一般的类变量一样。但是当一般的类变量作为声明它们的类型的一部分数据面保存的时候,编译时常量作为使用它们的类型的一部分而保存。
指向ClassLoader类的引用 每个类型被装载的时候,虚拟机必须跟踪它是由启动类装载器还是由用户自定义类装载器装载的。如果是用户自定义类装载器装载的,那么虚拟机必须在类型信息中存储对该装载器的引用。这是作为方法表中的类型数据的一部分保存的。
虚拟机会在动态连接期间使用这个信息。当某个类型引用另一个类型的时候,虚拟机会请求装载发起引用类型的类装载器来装载被引用的类型。这个动态连接的过程,对于虚拟机分离命名空间的方式也是至关重要的。为了能够正确地执行动态连接以及维护多个命名空间,虚拟机需要在方法表中得知每个类都是由哪个类装载器装载的。
指向Class类的引用 对于每一个被装载的类型(不管是类还是接口)虚拟机都会相应地为它创建一个java.lang.Class类的实例,而且虚拟机还必须以某种方式把这个实例和存储在方法区中的类型数据关联起来。在你的Java程序中,你可以得到并使用指向Class对象的引用。Class类中的一个静态方法可以让用户得到任何己装载的类的Class实例的引用。
public static Class forName(String classHame) //连接数据库常用此方法
比如,如果调用forName("java.lang.Object"),那么将得到一个代表java.lang.Object的Class对象的引用。如果调用forName("java.util.Enumeration"),那么得到的是代表java.util包中java.util.Enumeration接口的Class对象的引。可以使用forName()来得到代表任何包中任何类型的Class对象的引用,只要这个类型可以被(或者已经被)装载到当前命名空间中。如果虚拟机无法把请求的类型装载到当前命名空间,那么forName ()会抛出ClassNotFoundException异常。
另一个得到Class对象引用的方法是,可以调用任何对象引用的getClass()方法。这个方法被来自Object类本身的所有对象继承:
Public final class getClass();
比如,如果你有一个到java.lang.Integer类的对象的引用,那么你只需简单地调用Integer对象引用的getClass()方法,就可以得到表不java.lang,Integer类的Class对象。给出一个指向Class对象的引用,就可以通过Class类中定义的方法来找出这个类型的相关信息。如果查看这些方法,会很快意识到,Class类使得运行程序可以访问方法区中保存的信息。
下面是Class类中生明的方法:
public String getNameO;
public Class getSuperClass();
public boolean islnterface();
public Class[] getlnterface();
public ClassLoader getClassLoader ();
这些方法仅能返回已装载类型的信息。getName()返回类型的全限定名,getSuperChss()返回类型的直接超类的Class实例。如果类型是java.lang.Object类或者是一个接口,它们都没有超类,getSuperClass()返回null。Islntcrface()判断该类型是否是接口,如果Class对象描述一个接口就返回true;如果它描述一个类则返回false。getlnterfaces()返回一个Class对象数组,其中每个Class对象对应一个直接超接口,超接口在数组中以类型声明超接口的顺序出现。如果该类型没有直接超接口,getlnterfaces()则返回一个长度为零的数纽。getClassLoader()返回装载该类型的ClassLoadeT对象的引用,如果类型是由启动类装载器装载的,则返回null。所有这些信息都直接从方法区中获得。
方法表 为了尽可能提高访问效率,设计者必须仔细设计存储在方法区中的类型信息的数据结构,因此,除了以上讨论的原始类型信息,实现中还可能包括其他数据结构以加快访问原始数据的速度,比如方法表。虚拟机对每个装载的非抽象类,都生成一个方法表,把它作为类信息的一部分保存在方法表中。方法表是一个数组,它的元素是所有它的实例可能被调用的实例方法的直接引用,包括那些从超类继承过来的实例方法。(对于抽象类和接口,方法表没有什么帮助,因为程序决不会生成它们的实例。)运行时可以通过方法表快速搜寻在对象中调用的实例方法。
方法区使用示例,为了展示虚拟机如何便用方法表中的信息,我们举个例子,看下面这个类:
Class Lava {
Private int speed = 5;
Void flow() {
}
}
Class Volcano{
Lava lava = new Lava();
lava.flow();
}
下面的段落描述了某个实现中是如何执行Volcano程序中main()方法的字节码中第一条指令的。不同的虚拟机实现可能会用完全不同的方法来操作,下面描述的只是其中一种可能——但并不是仅有的一种,下面看一下Java虚拟机是如何执行Volcano程序中main ()方法的第一条指令的。
要运行Vokano程序,首先得以某种“依赖于实现的”方式告诉虚拟机“Volcano”这个名字。之后,虚拟机将找到并读人相应的class文件“Volcano.class”,然后它会从导人的class文件里的二进制数据中提取类型信息并放到方法区中。通过执行保存在方法区中的字节码,虚拟机开始执行main()方法,在执行时,它会一直持有指向当前类(Volcano类)的常量池(方法区中的一个数据结构)的指针。
注意,虚拟机开始执Volcano类中main()方法的字节码的时候,尽管Lava类还没被装载,但是和大多数(.也许所有)虚拟机实现一样,它不会等到把程序中用到的所有类都装载后才开始运行程序。恰好相反,它在需要时才装载相应的类。main()的第一条指令告知虚拟机为列在常量池第一项的类分配足够的内存。所以虚拟机使用指向Volcano常量池的指针找到第一项,发现它是一个对Lava类的符号引用,然后它就检查方法区,看Lava类是否已经被装载了。
这个符导引用仅仅是一个给出类Lava的全限定名“Lava”的字符串。为了能让虚拟机尽可能快地从一个名称找到类,设计者应当选择最佳的数据结构和算法。这里可以采用各种方法,如散列表,搜索树等等。同样的算法也可以用于实现Class类的forName()方法,这个方法根据给定的全限定名返同Class引用。
当虚拟机发现还没有装载过名为“Lava”的类时,它就开始査找并装载文件“Lava.class”,并把从读人的二逬制数据中提取的类型信息放在方法区中。紧接着,虚拟机以一个直接指向方法区Lava类数据的指针来替换常量池第一项(就是那个字符串“Lava”)——以后就可以用这个指针来快速地访问Lava类。这个替换过挥称为常量池解析,即把常量池中的符号引用替换为直接引用。这是逋过在方法区中搜索被引用的元素实现的,在这期间可能又需要装载其他类。在这里,我们替换掉符号引用的“直接引用”是一个本地指针。
终于,虚拟机准备为一个新的Lava对象分配内存。此吋,它又需要方法区中的信息。还记得刚刚放到Volcano类常量池第一项的指针吗?现在虚拟机用它来访问Lava类型倍息(此前刚放到方法区中的),找出其中记录的这样一个信息:一个Lava对象需要分配多少堆空间。
Java虚拟机总能够通过存储于方法区的类型信息来确定一个对象需要多少内存,但是,某个特定对象事实上需要多少内存,是跟特定实现相关的。对象在虚拟机内部的表示是由实现的设计者来决定的。
当Java虛拟机确定了一个Lava对象的大小后,它就在堆上分配这么大的空间,并把这个对象实例的变量speed切始化为默认初始值0。假如Lava类的超类Object也有实例变量,则也会在此时被初始化力相应的默认值。
当把新生成的Lava对象的引用压到栈中,main()方法的第—条指令也完成了。接下来的指令通过这个引用调用Java代码(该代码把speed变量初始化为正确初始值5)。另外一条指令将用这个引用调用Lava对象引用的flow()方法。
堆
Java程序在运行时创建的所有类实例或数组都放在同一个堆中。而一个Java虚拟机实例中只存在一个堆空间,因此所有线程都将共享这个堆。又由于一个Java程序独占一个Java虚拟机实例,因而每个Java程序都有它自己的堆空间——它们不会彼此干扰。但是同一个Java程序的多个线程却共享着同一个堆空间,在这种情况下,就得考虑多线程访问对象(堆数据)的同步问题了。
Java虚拟机有一条在堆中分配新对象的指令,却没存释放内存的指令。正如你无法用Java代码去明确释放一个对象一样,字节码指令也没有对应的功能。虚拟机自己负责决定如何以及何时释放不再被运行的程序引用的对象所占据的内存。程序本身不用去考虑何时需回收对象所占用的内存,通常,虚拟机把这个任务交给垃圾收集器。
垃圾收集 垃圾收集器的主要工作就是自动回收不再被运行的程序引用的对象所占用的内存。此外,它也可能去移动那些还在使用的对象,以此减少堆碎片。
Java虛拟机规范并没有强制规定垃圾收集器,它只要求虚拟机实现必须“以某种方式”管理自己的堆空间。举个例子,某个实现可能只有固定大小的堆空问可用,当空间填满,它就简单地拋出OutOfMemory异常,根本不去考虑回收垃圾对象的问题。这样的一个实现虽然简陋,担却是符合规范的。总之,Java虚拟机规范并没有规定具体的实现必须为Java程序准备多少内存,也没有说它必须怎么管理自已的堆空间,它仅仅告诉实现的投计者:Java稈序需要从堆中为对象分配空间,并且程序本身不会主动释放它。因此堆空间的管理(包括垃圾收集)问题得由设计者自行去考虑处理方式。
Java虚拟机规范没有指定垃圾收集应该采用什么技术。这些都由虚拟机的设计者根据他们的目标、考虑所受的限制、用自己的能力去决定什么才是最好的技术。因为到对象的引用可能很多地方都存在,如Java栈、堆、方法区、本地方法栈,所以垃圾收集技术的使用在很大程度上会影响到运行时数据区的设计。
和方法区一样,堆空间也不必是连续的内存区。在程序运行时,它可以动态扩展或收缩。事实上,一个实现的方法区可以在堆顶实现。换句话说,就是虚拟机需要为一个新装载的类分配内存时,类型信息和实际对象可以都在同一个堆上。因此,负责回收无用对象的垃圾收集器可能也要负责无用类的释放(卸载)。另外,某些实现可能也允许用户或程序员指定堆的初始大小、最大最小值等等。
对象的内部表示 Java虚拟机规范并没有规定lava对象在堆中是如何表示的。对象的内部表示也影响着整个堆以及垃圾收集器的设计,它由虚拟机的实现者决定。
Java对象中包含的基本数据由它所属的类及其所有超类声明的实例变量组成。只要有一个对象引用,虚拟机就必须能够快速地定位对象实例的数据。另外,它也必须能通过该对象引用访问相应的类数据(存储于方法区的类型信息)。因此在对象中通常会有一个指向方法区的指针。一种可能的堆空间设计就是,把堆分为两部分:一个句栖池,一个对象池,如图5-5所示。而一个对象引用就是一个指向句栖池的本地指针。句柄池的每个条目有两部分:一个指向对象实例变量的指针,一个指向方法区类型数据的指针。这种设计的好处是有利于堆碎片的整理,当移动对象池中的对象时,句柄部分只需耍更改一下指针指向对象的新地址就可以了——就是在句柄池中的那个指针。缺点是每次访问对象的实例变量都要舒过两次指针传递。
另一种设计方式是使对象指针宜接指向一组数据,而读数据包括对象实例数据以及指向方法区中类数据的指针。这样设计的优缺点正好与前面的方法相反,它只需要一个指针就可以访问对象的实例数据,但是移动对象就变得更加复杂。当使用这种堆的虚拟机为了减少内存碎片。而移动对象的时候,它必须在整个运行时数据K中更新指向被移动对象的引用。图5-6描绘了这种表示对象的方法。
有如下几个理由要求虚拟机必须能够通过对象引用得到类(类權)数据:当程序在运行时需要转换某个对象引用为另一种类型时,虚拟机必须要检查这种转换是否被允许,被转换的对象是否的确足被引用的对象或者它的超类型。当程序在执行instanceof操作时,虚拟机也进行了同样的检查。在这两种情况下,虚拟机部需要查看被引用的对象的类数据。最后,当程序中调用某个实例方法时,虚拟机必须迸行动态绑定,换句话说,它不能按照引用的类型来决走将要调用的方法.而必须报据对象的'实际类。为此,虚戒机必须再次通过对象的引用去访问类数据。
不管虚拟机的实现使用什么样的对象表示法,很可能每个对象都有一个方法表,因为方法表加快了调用实例方法时的效率,从而对Java虚拟机实现的整体性能起着非常重要的正面作用;但是Java虚拟机规范并未要求必须使用方法表,所以并是所有实现中都会使用它。比如那些有严格内存资源限制的实现,或许它们裉本不可能有足够的额外内存资源来存储方法展。如果一个实现使用方法表,那么仅仅使用一个指向对象的引用,就可以很快地访问到对象的方法表。
下图展示了一种把方法表和对象引用联系起来的实现方式。每个对象的数据都包含一个指向特殊数据结构的指针,这个数据结构位于方法区,它包括两部分:
•一个指向方法区对应类数据的指针。
•此对象的力法表。
方法表是个指针数组,其中的每一项都是一个指向“实例方法数据”的指针,实例方法可以被那类的对象调用。方法表指向的实例方法数据包括以下信息:
•此方法的操作数栈和局部变里区的大小。
•此方法的字节码。
•异常表。
这些足够虚拟机去用一个方法了,方法表中包含有方法指针---指向类或其超类声明的方法的数据:也就是说,方法表所指向的方法可能是此类声明的,也可能是它继承下来的。
堆上的对象数据中还有一个逻辑部分,那就是对象锁。这是—个互斥对象,虚拟机屮的每个对象都有一个对象锁,它被用于协调多个线程访问同一个对象时的同步。在任何时刻,只能有一个线程“拥有”这个对象锁,因此只有这个线程才能访问该对象的数据。此时其他希望访问这个对象的线程只能等待,直到拥有对象锁的线程释放锁。当某个线程拥有一个对象锁后,可以继续对这个锁追加请求。但请求几次,必须对应地释放几次,之后才能轮到其他线程。比如一个线程清求了三次锁,在它释放三次锁之前,它一直保持“拥有”这个锁。
很多对象在其整个生命周期内都没有被任何线程加锁。在线程实际请求某个对象的锁之前,实现对象锁所需要的数据是不必要的。很多实现不在对象自身内部保存一个指向锁数据的指针。而只有当第一次需要加锁的时候才分配对应的锁数据,但这时虚拟机要用某种间接方法来联系对象数据和对应的锁数据,例如把锁数据放在一个以对象地址为索引的搜索树中。
除了实现锁所需要的数据外,每个Java对象逻辑上还与实现等待集合(wait set)的数据相关联。锁是用来实现多个线程对共享数据的互斥访问的,而等待集合是用来让多个线程为完成一个共同目标而协调工作的。
等待集合由等待方法和通知方法联合使用。每个类都从Object那里继承了三个等待方法(三个名为wait()的重载方法)和两个通知方法(notify()及notifyAll())。当某个线程在一个对象上调用等待方法吋,虚拟机就阻塞这个线程,并把它放在了这个对象的等待集合中。直到另一个线程在同一个对象上调用通知方法,虚拟机会在之后的某个时刻唤醒一个或多个在等待集合中被阻塞的线程。正像锁数据一样,在实际调用对象的等待方法或通知方法之前,实现对象的等待集合的数椐并不是必需的。因此,许多虚拟机实现都把等待集合数据与实际对象数据分开,只有在需要时才为此对象创建同步数据(通常是在第一次调用等待方法或通知方法时)。
最后一种数据类型——可以作为堆中某个对象映像的一部分,是与拉圾收集器有关的数据。垃圾收集器必须(以某种方式)跟踪程序引用的每个对象,这个任务不可避免地要附加一些数据给这些对象,数据的类型要视拉圾收集使用的算法而定。例如,假如垃圾收集器使用“标记并清除”算法,这就需要能够标记对象能否被引用。此外,对于不再被引用的对象,还需要指明它的终结方法(finalize)是否已经运行过了。像线程锁一样,这些数据也可以放在对象数据外。有一些垃圾收集技术只在垃圾收集器运行时需要额外数据。例如“标记并清除”算法就使用一个独立的位图来标记对象的引用情况。
除了标记对象的引用情况外,垃圾收集器还要区分对象是否调用了终结方法。对干在其类中声明了终结方法的对象,在回收它之前,垃圾收集器必须调用它的终结方法。Java语言规范指出,拉圾收集器对每个对象只能调用一次终结方法,但是允许终结方法复活(resurrect)这个对象,即允许该对象被再次引用。这样当这个对象再次被回收时,就不用再调用终结方法了。需要终结方法的对象不多,而需要复活的更少,所以对一个对象回收两次的情況很少见。这种用来标志终结方法的数据虽然逻辑上是对象的一部分,但通常实现上不随对象保存在堆中。大部分情况下,垃圾收集器会在一个单独的空间保存这个信息。
数组的内部表示 在Java中,数组是真正的对象。和其他对象一样,数组总是存储在堆中。同样,和普通对象一样,实现的设计者将决定数组在堆中的表示形式。
和其他所有对象一样,数组也拥有一个与它们的类相关联的Class实例,所有具有相同维度和类型的数组都是同一个类的实例,而不管数组的长度(多维数组每一维的长度)是多少,例如一个包含3个int整数的数组和一个包含300个int整数的数组拥有同一个类。数组的长度只与实例数据有关。
数组类的名称由两部分组成:每一维用一个方括号“[”表示,用字符或字符串表示类型。比如,元素类型为int整数的、一维数组的类名为“[I”,元素类型为byte的三维数组为“[[[B”,元素类型为Object的二维数组“[[Ljava/lang/Object”。
多维数组被表示为数组的数组。比如,int类型的二维数组,将表示为一个一维数组,其中的毎个元素是一个一维int数组的引用。
在堆中的每个数组对象还必须保存的数据是数组的长度、数组数据,以及某些指向数组的类数据的引用。虚拟机必须能够通过一个数组对象的引用得到此数组的长度,通过索引访问其元素(其间要检查数组边界是否越界),调用所有数组的直接超类Object声明的方法等等。
程序计数器
对于一个运行中的Java程序而言,其中的每一个线程都有它自己的PC(程序计数器)寄存器,它是在该线程启动时创建的。PC寄存器的大小是一个字长,因此它既能够持有一个本地指针,也能够持有一个returnAddress。当线程执行某个Java方法时,PC寄存器的内容总是下一条将被执行指令的“地址”,这里的“地址”可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么此PC寄存器的值是“undefined'”。
Java栈
每当启动一个新线程时,Java虚拟机都会为它分配一个Java栈。前面我们曾经提到,Java栈以帧为单位保存线程的运行状态。虚拟机只会直接对Java栈执行两种操作:以帧为单位的压栈或出栈。某个线程正在执行的方法被称为该线程的当前方法,当前方法使用的帧栈称为当前帧,当前方法所属的类称为当前类,当前类的常量池称为当前常量池。在线程执行一个方法时,它会跟踪当前类和当前常量池。此外,当虚拟机遇到栈内操作指令时,它对当前帧内数据执行操作。
每当线程调用一个Java方法时,虛拟机都会在该线程的Java栈中压人一个新帧。而这个新帧自然就成为了当前侦。在执行这个方法时,它使用这个帧来存储参数、局部变量、中间运算结果等等数据。
Java方法可以以两种方式完成。一种通过return返回的,称为正常返回;一种是通过抛出异常而异常中止的。不管以哪种方式返回,虚拟机都会将当前帧弹出Java栈然后释放掉,这样上—个方法的帧就成为当前帧了。
Java栈上的所有数据都是此线程私有的。任何线程都不能访问另一个线程的栈数据,因此我们不需要考虑多线程情况下栈数据的访问同步问题。当一个线程调用一个方法时,方法的局部变量保存在调用线程Java找的帧中。只有一个线程能总是访问那些局部变最,即调用方法的线程。
像方法区和堆一样,Java栈和帧在内存中也不必是连续的。帧可以分布在连续的栈里,也可以分布在堆里,或者二者兼而有之。表示Java栈和栈帧的实际数据结构由虛拟机的实现者决定,某些实现允许用户指定Java栈的初始大小和最大最小值。
栈帧
栈帧由三部分组成:局部变量区、操作数栈和帧数据区。局部变量区和操作数栈的大小要视对应的方法而定,它们是按字长计算的。编译器在编译时就确定了这些值并放在class文件中。而帧数据区的大小依赖于具体的实现。
当虚拟机调用一个Java方法时,它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧内存,然后压入Java栈中。
局部变量区 Java栈帧的局部变量区被组织为一个以字长为单位、从0开始计数的数组。字节码指令通过从0开始的索引来使用其中的数据类型。类型为int、float、reference和returnAddress的值在数组中只占据一项,而类型为byte、short和char的值在存入数组前都将被转换为int值,因而同样占据一项。但是类型为long和double的值在数组中却占据连续的两项。
在访问局部变量中的long和double值的时候,指令只需指出连续两项中第一项的索引值。例如某个long值占据第3、4项,那么指令会取索引为3的long值。局部变量区的所有值都是字对齐的,long和double这样占据两项数组元素的值同样可以起始于任何索引。
局部变量区包含对应方法的参数和局部变量。编译器首先按声明的顺序把这些参数放入局部变量数组。
除了Java方法的参数(编译器首先严格按照它们的声明顺序放到局部变量数组中,而对于真正的局部变量,它可以任意决定放置顺序,甚至可以用一个索引指代两个局部变量---比如当两个局部变量的作出域不重叠时,像下面Example3b中的局部变量i和j就是这种情形:在方法的前半段,在开始生效之前,0号索引的入口可以被用来代表i。在方法的后半段,i经超过了有效作用域,0号入口就可以用来表示j了。
class Example3b{
public static void runtwoLoops(){
for(int i=0; i < 10; ++i){
System.out.println(i);
}
for(int j=9; j >=0; --j){
System.out.println(j);
}
}
}
和其他运行时内存区一样,虚拟机的实现者可以为局部变量区设计任意的数据结构。比如对于怎样把long和double类型的值存储到两个数组项中,Java虚拟机规范没有指定。假如某个虚拟机实现的字长为64位,这时就可以把整个long或double数据放在数组中相邻两数组项的低项内,而使高项保持为空。
操作数栈 和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作——压栈和出栈——来访问的。比如,如果某个指令把一个值压人到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的,如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。
不同于程序计数器,Java虚拟机没有寄存器,程序计数器也无法被程序指令直接访问。Java虚拟机的指令是从操作数栈中而不是从寄存器中取得操作数的,因此它的运行方式是基于栈的而不是基于寄存器的。虽然指令也可以从其他地方取得操作数,比如从字节流中跟随在操作码(代表指令的字节)之后的字节中或从常量池中,但是主要还是从操作数栈中获得操作数。
虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。比如,iadd指令就要从操作数栈中弹出两个整数,执行加法运算,其结果又压回到操作数栈中。看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:
iload_0 //push the int in local variable 0
iload_l // push the int in local variable 1
iadd // pop two ints, add them, push result
istore_2 /7 pop int, store into local variable 2
在这个字节码序列里,前两个指令iload_0和iload_l将存储在局部变量区中索引为0和1的整数压人操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置。图5-10详细表述了这个过程中局部变量和操作数栈的状态变化,阁中没有使用的局部变量区和操作数栈区域以空白表示。
帧数据区 除了局部变量区和操作数栈外,Java栈帧还需要一些数据来支持常量池解析、正常方法返回以及异常派发机制,这些信息都保存在Java栈帧的帧数据区中。
Java虚拟机中的大多数指令都涉及到常量池入口。有些指令仅仅是从常量池中取出数据然后压人Java栈(这些数据的类型包括int、long、float、double和String);还有些指令使用常暈池的数据来指示要实例化的类或数组、要访问的字段,或要调用的方法;还有些指令需要常量池中的数据才能确定某个对象是否属于某个类或实现了某个接口。
每个虚拟机要执行某个需要到常量池数据的指令时,它都会通过帧数掘区中指向常量池的指针来访问它。以前讲过,常景池中对类型、字段和方法的引用在开始时都是符号。当虚拟机在常量池中搜索的时候,如果遇到指向类、接口、字段或者方法的入口,假若它们仍然是符号,虚拟机那时候才会(也必须)进行解析。
除了用于常量池的解析外,帧数据区还要帮助虚拟机处埋Java方法的正常结束或异常中止。如果是通过return正常结束,虚拟机必须恢复发起调用的方法的栈帧,包括设置PC寄存器指向发起调用的方法中的指令---即紧跟着调用了完成方法的指令的下一个指令。假如方法有返回值,虛拟机必须将它压入到发起调用的方法的操作数栈,为了处理Java方法执行期间的异常退出情况,帧数据区还必须保存一个对此方法异常表的引用。异常表会在第17章深入描述,它定义了在这个方法的字节码中受catch子句保护的范围,异常表中的每一项都有一个被catch子句保护的代码的起始和结束位置(译者注:即try子句内部的代码),可能被catch的异常类在常量池中的索引值,以及catch子句内的代开始的位置。
当某个方法抛出异常时,虚拟机根据帧数据区对应的异常表来决定如何处理。如果在异常表中找到了匹配的catch子句,就会把控制权转交给catch子句内的代码。如果没有发现,方法会立即异常终止。然后虚拟机使用帧数据区的信息恢复发起调用的方法的帧,然后在发起调用的方法的上下文中重新抛出同样的异常。
除了上述信息(支持常量池解析、正常方法返回和异常派发的数据)外,虚拟机的实现者也可以将其他信息放人帧数据区,如用于调试的数据等。
本地方法栈
前面提到的所有运行时数据区都是在Java虚拟机规范中明确定义的,除此之外,对于一个运行中的Java程序而言,它还可能会用到一些跟本地方法相关的数据区。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止于此,它还可以做任何它想做的事情。比如,它甚至可以直接使用本地处理器中的寄存器,或者直接从本地内存的堆中分配任意数量的内存等等。总之,它和虚拟机拥有同样的权限(或者说能力)。
本地方法本质上是依赖于实现的,虚拟机实现的设计者们可以*地决定使用怎样的机制来让Java程序调用本地方法。
任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压人Java栈。然而当它调用的是本地方法时,虛拟机会保持Java栈不变,不再在线程的Java栈中压人新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。可以把这看做是虚拟机利用本地方法来动态扩展自己。就如同lava虛拟机的实现在按照其中运行的Java程序的吩附,调用属于虚拟机内部的另一个(动态连接的)方法。
如果某个虚拟机实现的本地方法接口是使用C连接模型的话,那么它的本地方法栈就是C栈。我们知道,当C程序调用一个C函数时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。同样,这就是该虚拟机实现中本地方法栈的行为。
很可能本地方法接口需要回调Java虚拟机中的Java方法(这也是由设计者决定的),在这种情形下,该线程会保存本地方法栈的状态并进人到另一个Java栈。
图5-13描绘了这种情况,就是当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个Java方法。这幅图展示了Java虚拟机内部线程运行的全景图。一个线程可能在整个生命周期中都执行Java方法,操作它的Java栈;或者它可能毫无障碍地在Java栈和本地方法栈之间跳转。
如图5-13所示,该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。图中的本地方法栈显示为一个连续的内存空间。假设这是一个C语言栈,其间有两个C函数,它们都以包围在虚线中的灰色块表示。第一个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法),最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。
就像其他运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的,它可以根据需要动态扩展或者收缩。某些实现也允许用户或者程序员指定该内存区的初始大小以及最大、最小值。