Java虚拟机之所以被称之为是“虚拟”的,就是因为它仅仅是由一个规范来定义的抽象计算机。因此,要运行某个Java程序,首先需要一个符合该规范的具体实现。
Java虚拟机是什么
要理解Java虚拟机,你首先必须意识到,当你说“Java虚拟机”时,可能指的是如下三种不同的东西:
1)抽象规范
2)一个具体的实现
3)一个运行中的虚拟机实例
当运行一个Java程序的同时,也就在运行了一个Java虚拟机实例。
每个Java程序都运行于某个具体的Java虚拟机实现的实例上。
Java虚拟机的生命周期
一个运行时的Java虚拟机实例的天职就是:负责运行一个Java程序。当启动一个Java程序时,一个虚拟机实例也就诞生了。当该程序关闭退出,这个虚拟机实例也就随之消亡。如果在同一台计算机上同时运行三个Java程序,将得到三个Java虚拟机实例。每个Java程序都运行于它自己的Java虚拟机实例中。
Java虚拟机实例通过调用某个初始类的main()方法来运行一个Java程序。
Java程序初始类中的main()方法,将作为该初始线程的起点,任何其他的线程都是由这个初始线程启动的。
在Java虚拟机内部有两种线程:守护线程与非守护线程。守护线程通常是由虚拟机自己使用的,比如执行垃圾收集任务的线程。但是,Java程序也可以把它创建的任何线程标记为守护线程。而Java程序中的初始线程——就是开始于main()的那个,是非守护线程。
只要还有任何非守护线程在运行,那么这个Java程序也在继续运行(虚拟机仍然存活)。当该程序中所有的非守护线程都终止时,虚拟机实例将自动退出。假若安全管理器允许,程序本身也能够通过调用 Runtime类或者System类的exit()方法来退出。
Java虚拟机的体系结构
在Java虚拟机规范中,一个虚拟机实例的行为是分别按照子系统、内存区、数据类型以及指令这几个术语来描述的。这些组成部分一起展示了抽象的虚拟机的内部抽象体系结构。
每个Java虚拟机都有一个类装载器子系统,它根据给定的全限定名来装入类型(类或接口)。同样,每个Java虚拟机都有一个执行引擎,它负责执行那些包含在被装载类的方法中的指令。
规范本身对“运行时数据区”只有抽象的描述,这就使得Java虚拟机可以很容易地在各种计算机和设备上实现。
某些运行时数据区是由程序中所有线程共享的,还有一些则只能由一个线程拥有。每个Java虚拟机实例都有一个方法区以及一个堆,它们是由该虚拟机实例中所有线程共享的。当虚拟机装载一个class文件时,它会从这个class文件包含的二进制数据中解析类型信息。然后,它把这些类型信息放到方法区中。当程序运行时,虚拟机会把所有该程序在运行时创建的对象都放到堆中。
当每一个新线程被创建时,它都将得到它自己的PC寄存器(程序计数器)以及一个Java栈。如果线程正在执行的是一个Java方法(非本地方法),那么PC寄存器的值将总是指示下一条将被执行的指令,而它的Java栈则总是存储该线程中的Java方法调用的状态——包括它的局部变量,被调用时传进来的参数,它的返回值,以及运算的中间结果等等。而本地方法调用的状态,则是以某种依赖于具体实现的方式存储在本地方法栈中,也可能是在寄存器或者其他某些与特定实现相关的内存区中。
Java栈是由许多栈帧(stack frame)或者说帧(frame)组成的,一个栈帧包含一个Java方法调用的状态。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java栈中;当该方法返回时,这个栈帧被从Java栈中弹出并抛弃 。
Java虚拟机没有寄存器,其指令集使用Java栈来存储中间数据。
下图描绘了Java虚拟机为每一个线程创建的内存区,这些内存区域是私有的,任何线程都不能访问另一个线程的PC寄存器或者Java栈。
上图展示了一个虚拟机实例的快照,它有三个线程正在执行。线程1和线程2都正在执行Java方法,而线程3则正在执行一个本地方法。
和本书其他地方一样,Java栈都是向下生长的,而栈顶显示在图的底部。当前正在执行的方法的栈帧则以浅色表示。对于一个正在运行Java方法的线程而言,它的PC寄存器总是指向下一条将被执行的指令。像这样的PC寄存器(线程1和线程2)都是以浅色显示的。由于线程3当前正在执行一个本地方法,因此,它的PC寄存器——以深色显示的那个,其值是不确定的。
数据类型
数据类型可以分为两种 :基本类型和引用类型。基本类型的变量持有原始值,而引用类型的变量持有引用值。术语“引用值”指的是对某个对象的引用,而不是该对象本身。与此相对,原始值则是真正的原始数据。
Java语言中的所有基本类型同样也都是Java虚拟机中的基本类型。但是boolean有点特别,虽然Java虚拟机也把boolean看做基本类型,但是指令集对boolean只有很有限的支持。当编译器把Java源码编译为字节码时,它会用int或byte来表示boolean。在Java虚拟机中,false是由整数零来表示的,所有非零整数都表示true。涉及boolean值的操作则会使用int。另外,boolean数组是当做byte数组来访问的,但是在“堆”区,它也可以被表示为位减。
和Java语言一样,Java虚拟机的基本类型的值域在任何地方都是一致的。
Java虚拟机中还有一个只在内部使用的基本类型:returnAddress,Java程序员不能使用这个类型。这个基本类型被用来实现Java程序中的finally子句。
三种引用类型的值都是对动态创建对象的引用。在Java虚拟机中,数组是个真正的对象。
类装载器子系统
在Java虚拟机中,负责查找并装载类型的那部分被称为类装载器子系统。
Java虚拟机有两种类装载器:启动类装载器和用户自定义类装载器。前者是Java虚拟机实现的一部分,后者则是Java程序的一部分。
对于每一个被装载的类型,Java虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。
装载、连接以及初始化
1)装载——查找并装载类型的二进制数据。
2)连接——执行验证,准备,以及解析(可选)。
验证 确保被导入类型的正确性。
准备 为类变量分配内存,并将其初始化为默认值。
解析 把类型中的符号引用转换为直接引用。
3)初始化——把类变量初始化为正确初始值。
启动类装载器
每个Java虚拟机实现都必须有一个启动类装载器,它知道怎么装载受信任的类,比如Java API的class文件。Java虚拟机规范并未规定启动类装载器如何去寻找class文件,这又是一件保留给具体的实现设计者去决定的事情。
在Sun的JDK 1.2中,启动类装载器将只在系统类(Java API的类文件)的安装路径中查找要装入的类;而搜索CLASSPATH目录的任务,现在交给了系统类装载器——它是一个自定义的类装载器,当虚拟机启动时就被自动创建。
方法区
在Java虚拟机中,关于被装载类型的信息存储在一个逻辑上被称为方法区的内存中。当虚拟机装载某个类型时,它使用类装载器定位相应的class文件,然后装载这个class文件——一个线性二进制数据流——然后将它传输到虚拟机中。紧接着虚拟机提取其中的类型信息,并将这些信息存储到方法区。该类型中的类(静态)变量同样也是存储在方法区中。
Java虚拟机在内部如何存储类型信息,这是由具体实现的设计者来决定的。
由于所有线程都共享方法区,因此它们对方法区数据的访问必须被设计为线程安全的。
方法区的大小不必是固定的,虚拟机可以根据应用的需要动态调整。同样,方法区也不必是连续的,方法区可以在一个堆(甚至是虚拟机自己的堆)中自由分配。另外,虚拟机也可以允许用户或者程序员指定方法区的初始大小以及最小和最大尺寸等。
方法区也可以被垃圾收集。
类型信息
对每个装载的类型,虚拟机都会在方法区中存储以下类型信息:
1)这个类型的全限定名。
2)这个类型的直接超类的全限定名(除非这个类型是java.lang.Object,它没有超类)。
3)这个类型是类类型还是接口类型。
4)这个类型的访问修饰符(public、abstract或final的某个子集)。
5)任何直接超接口的全限定名的有序列表。
在Java class文件和虚拟机中,类型名总是以全限定名出现。在Java源代码中,全限定名由类所属包的名称加一个“.”,再加上类名组成,如java.lang.Object。但在class文件里,所有的“.”都被斜杠“/”代替,如java/lang/Object。
除了上面列出的基本类型信息外,虚拟机还得为每个被装载的类型存储以下信息:
- 该类型的常量池。
- 字段信息。
- 方法信息。
- 除了常量以外的所有类(静态)变量。
- 一个到类ClassLoader的引用。
- 一个到Class类的引用。
常量池
虚拟机必须为每个被装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合,包括直接常量(string、integer和floating point常量)和对其他类型、字段和方法的符号引用。
字段信息
对于类型中声明的每一个字段,方法区中必须保存下面的信息。除此之外,这些字段在类或者接口中的声明顺序也必须保存。
- 字段名。
- 字段的类型。
- 字段的修饰符(public、private、protected、static、final、volatile、transient的某个子集)。
方法信息
对于类型中声明的每一个方法,方法区中必须保存下面的信息。和字段一样,这些方法在类或者接口中的声明顺序也必须保存。
- 方法名。
- 方法的返回类型(或void)。
- 方法参数的数量和类型(按声明顺序)。
- 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的某个子集)。
如果某个方法不是抽象的和本地的,它还必须保存下列信息:
- 方法的字节码(bytecodes)。
- 操作数栈和该方法的栈帧中的局部变量区的大小。
- 异常表。
类(静态)变量
类变量是由所有类实例共享的,但是即使没有任何类实例,它也可以被访问。除了在类中声明的编译时常量外,虚拟机在使用某个类之前,必须在方法区中为这些类变量分配空间。
指向ClassLoader类的引用
每个类型被装载的时候,虚拟机必须跟踪它是由启动类装载器还是由用户自定义类装载器装载的。如果是用户自定义类装载器装载的,那么虚拟机必须在类型信息中存储对该装载器的引用。这是作为方法表中的类型数据的一部分保存的。
指向Class类的引用
对于每一个被装载的类型(不管是类还是接口),虚拟机都会相应地为它创建一个java.lang.Class类的实例,而且虚拟机还必须以某种方式把这个实例和存储在方法区中的类型数据关联起来。
Class类中的一个静态方法可以让用户得到任何已装载的类的Class实例的引用。
public static Class forName(String className);
(注:
在JDK 1.5、1.6中,另一个重载的方法是:
public static Class> forName(String name, boolean initialize, ClassLoader loader);
例如,在一个实例方法中:
Class.forName("Foo");
与下面的用法相同:
Class.forName("Foo", true, this.getClass().getClassLoader());
参数说明:
name:类型的全限定名
initialize:是否需要初始化类型——true将会初始化静态变量、静态代码块等
loader:指定装载类型的类装载器
)
另外一个得到Class对象引用的方法是,可以调用任何对象引用的getClass()方法。
public final Class getClass();
(注:
在JDK 1.5、1.6中,getClass()为native方法。
此外,通过类名加.class的方式也可以得到Class对象,如String.class。
)
给出一个指向Class对象的引用,就可以通过Class类中定义的方法来找出这个类型的相关信息。如果查看这些方法,会很快意识到,Class类使得运行程序可以访问方法区中保存的信息。
如类型的全限定名、直接超类的Class实例、是否为接口、直接超接口Class对象数组、类装载器对象的引用等。
方法表
虚拟机对每个装载的非抽象类,都生成一个方法表,把它作为类信息的一部分保存在方法区。方法表是一个数组,它的元素是所有它的实例可能被调用的实例方法的直接引用,包括那些从超类继承过来的实例方法。(对于抽象类和接口,方法表没有什么帮助,因为程序决不会生成它们的实例。)运行时可以通过方法表快速搜寻在对象中调用的实例方法。
堆
Java程序在运行时创建的所有类实例或数组都放在同一个堆中。而一个Java虚拟机实例中只存在一个堆空间,因此所有线程都将共享这个堆。又由于一个Java程序独占一个Java虚拟机实例,因而每个Java程序都有它自己的堆空间——它们不会彼此干扰。但是同一个Java程序的多个线程却共享一个堆空间,在这种情况下,就得考虑多线程访问对象(堆数据)的同步问题了。
Java虚拟机有一条在堆中分配新对象的指令,却没有释放内存的指令。正如你无法用Java代码去明确释放一个对象一样,字节码指令也没有对应的功能。
垃圾收集
垃圾收集器的主要工作就是自动回收不再被运行的程序引用的对象所占用的内存。
Java虚拟机规范并没有强制规定垃圾收集器,它只要求虚拟机实现必须“以某种方法”管理自己的堆空间。Java虚拟机规范仅仅告诉实现的设计者:Java程序需要从堆中为对象分配空间,并且程序本身不会主动释放它。
和方法区一样,堆空间也不必是连续的内存区。在程序运行时,它可以动态扩展或收缩。事实上,一个实现的方法区可以在堆顶实现。
对象的内部表示
Java虚拟机规范并没有规定Java对象在堆中是如何表示的。
一种可能的堆空间设计就是,把堆分为两部分:一个句柄池,一个对象池,而一个对象引用就是一个指向句柄池的本地指针。
另一种设计方式是使对象指针直接指向一组数据,而该数据包括对象实例数据以及指向方法区中类数据的指针。
不管虚拟机的实现使用什么样的对象表示法,很可能每个对象都有一个方法表,因为方法表加快了调用实例方法时的效率。Java虚拟机规范并未要求必须使用方法表,所以并不是所有实现中都会使用它。
下图展示了一种把方法表和对象引用联系起来的实现方式。每个对象的数据都包含一个指向特殊数据结构的指针,这个数据结构位于方法区,它包括两部分:
- 一个指向方法区对应类数据的指针。
- 此对象的方法表。
虚拟机中的每个对象都有一个对象锁,它被用于协调多个线程访问同一个对象时的同步。在任何时刻,只能有一个线程“拥有”这个对象锁,因此只有这个线程才能访问该对象的数据。
等待集合由等待方法和通知方法联合使用。每个类都从Object那里继承了三个等待方法(三个名为wait()的重载方法)和两个通知方法(notify()及notifyAll())。
锁是用来实现多个线程对共享数据的互斥访问的,而等待集合是用来让多个线程为完成一个共同目标而协调工作的。
数组的内部表示
在Java中,数组是真正的对象。和其他对象一样,数组总是存储在堆中。
和其他所有对象一样,数组也拥有一个与它们的类相关联的Class实例,所有具有相同维度和类型的数组都是同一个类的实例,而不管数组的长度(多维数组每一维的长度)是多少。
数组类的名称由两部分组成:每一维用一个方括号“[”表示,用字符或字符串表示元素类型。
(注:
例如:
int[].class.getName():[I
int[][].class.getName():[[I
boolean[].class.getName():[Z
String[][][].class.getName():[[[Ljava.lang.String;
)
多维数组被表示为数组的数组。比如,int类型的二维数组,将表示为一个一维数组,其中的每个元素是一个一维int数组的引用。
程序计数器
对于一个运行中的Java程序而言,其中的每一个线程都有它自己的PC(程序计数器)寄存器,它是在该线程启动时创建的。当线程执行某个Java方法时,PC寄存器的内容总是下一条将被执行指令的“地址”,这里的“地址”可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么此时PC寄存器的值是“undefined”。
Java栈
每当启动一个新线程时,Java虚拟机都会为它分配一个Java栈。Java栈以帧为单位保存线程的运行状态。虚拟机只会直接对Java栈执行两种操作:以帧为单位的压栈或出栈。
每当线程调用一个Java方法时,虚拟机都会在该线程的Java栈中压入一个新帧。
Java方法可以以两种方式完成。一种通过return返回的,称为正常返回;一种是通过抛出异常而异常中止的。不管以哪种方式返回,虚拟机都会将当前帧弹出Java栈然后释放掉。
栈帧
栈帧由三部分组成:局部变量区,操作数栈和帧数据区。
当虚拟机调用一个Java方法时,它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧的内存,然后压入Java栈中。
局部变量区
Java栈帧的局部变量区被组织为一个以字长为单位、从0开始计数的数组。类型为int、float、reference和returnAddress的值在数组中只占据一项,而类型为byte、short和char的值在存入数组前都将被转换为int值,因而同样占据一项。但是类型为long和double的值在数组中却占据连续的两项。
在访问局部变量中的long和double值的时候,指令只需指出连续两项中第一项的索引值。
局部变量区包含对应方法的参数和局部变量。
下图描绘了Example3a两个方法的局部变量区。
class Example3a {
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;
}
}
上图方法runInstanceMethod()中,局部变量中第一个参数是一个reference(引用)类型,这个参数this对于任何一个实例方法都是隐含加入的,用来表示调用该方法的对象本身。
操作数栈
和局部变量一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问的,而是通过标准的栈操作——压栈和出栈——来访问的。
虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:
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
帧数据区
除了局部变量区和操作数栈外,Java栈帧还需要一些数据来支持常量池解析、正常方法返回以及异常派发机制。这些信息都保存在Java栈帧的帧数据区中。
每当虚拟机要执行某个需要用到常量池数据的指令时,它都会通过帧数据区中指向常量池的指针来访问它。以前讲过,常量池中对类型、字段和方法的引用在开始时都是符号。当虚拟机在常量池中搜索的时候,如果遇到指向类、接口、字段或者方法的入口,假若它们仍然是符号,虚拟机那时候才会(也必须)进行解析。
Java栈的可能实现方式
实现的设计者可以任意按自己的想法设计Java栈,正如前面提到的,一个可能的方式就是从堆中分配每一个帧。例如,考虑下面的类:
class Example3c {
public static void addAndPrint() {
double result = addTwoTypes(1, 88.88);
System.out.println(result);
}
public static double addTwoTypes(int i, double d) {
return i + d;
}
}
下图显示了一个线程执行addAndPrint()方法的三次快照。
下图显示了另一种虚拟机实现执行同一方法的Java栈快照。它的栈帧不是从堆中单独分配,而是从一个连续的栈中分配,因而这种方式允许相邻方法的栈帧可以相互重叠。
这种方式不仅节省了内存空间,而且也节省了时间。
Java栈还有一些其他的实现方式,但基本上都是以上两种情形的混合。
本地方法栈
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止于此,它还可以做任何它想做的事情。它和虚拟机拥有同样的权限(或者说能力)。
任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。
就像其他运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的。
执行引擎
任何Java虚拟机实现的核心都是它的执行引擎。在Java虚拟机规范中,执行引擎的行为使用指令集来定义。对于每条指令,规范都详细规定了当实现执行到该指令时应该处理什么,但是却对如何处理言之甚少。
“执行引擎”这个术语可以有三种理解:一个是抽象的规范,一个是具体的实现,另一个是正在运行的实例。
运行中Java程序的每一个线程都是一个独立的虚拟机执行引擎的实例。
指令集
方法的字节码流是由Java虚拟机的指令序列构成的。每一条指令包含一个单字节的操作码,后面跟随0个或多个操作数。当虚拟机执行一条指令的时候,可能使用当前常量池中的项、当前帧的局部变量中的值,或者位于当前帧操作数栈顶端的值。
抽象的执行引擎每次执行一条字节码指令。
执行技术
实现可以使用多种执行技术:解释、即时编译、自适应优化、芯片级直接执行。关于执行技术要记住的最主要的一点是,实现可以自由选择任何技术来执行字节码,只要它遵守Java虚拟机指令集的定义。
本地方法接口
并不强求Java虚拟机实现支持任何特定的本地方法接口。有些实现可以根本不支持本地方法接口,还有一些可能支持少数几个,每一个对应一种不同的需求。
Sun的Java本地接口,或者称为JNI,是为可移植性准备的。
写在最后
首先批评一下自己,在做笔记的过程中,慢慢才发现原书其实已经很精简,按照现在做笔记的方式,抄的内容太多了。但好处就是画图、抄书的过程中自己又加深了印象与理解,坏处是花了太多时间、觉得自己有点木。
读完本章内容,个人记忆最深刻的一点就是Java虚拟机相关规范的定义比较抽象,对于具体实现都交给设计者,比较自由。建议大家对本章内容细细研读,慢慢品味Java虚拟机的原理与设计之美。
在此说明,本系列文章的内容均出自《深入理解Java虚拟机》一书,除了极少数的“注”或对内容的裁剪整理外,内容原则上与原书保持一致。由于这是一本原理性的书籍,本人不想因为自己能力与理解的问题对大家造成误解,所以除了对原书内容的裁剪整理之外,基本不做任何内容的延伸思考与扩展。
另外,如果您对本系列文章的内容感兴趣,建议您去阅读原版书籍,谢谢!
(转载请注明来源:http://zhanjia.iteye.com/blog/1852334)