InsideJVM

  类加载器体系

类加载器是沙箱的第一道防线,毕竟代码都是由它装入jvm中的,其中也包括有危险的代码。它的安全作用有三点:

一 保护善意代码不受恶意代码的干扰
二 保护已验证的类库
三 代码放入有不同的行为限制的各个保护域中

类加载体系通过使用不同的类加载器把类放入不同的名字空间中从而保护善意代码不受恶意代码的干扰。

JVM为每个类加载器维护一个名字空间。例如,如果jvm在某个名字空间中加载了一个称为volcano的类,就不能再在这个名字空间中加载另一个也称为volcano的类,除非你再创建另一个名字空间。也就是说,如果jvm有三个名字空间,你就可以加载三个叫做volcano的类,一个名字空间一个。

jvm中,同一个名字空间中的类是可以直接交互的,但在不同名字空间中的类就不行,除非提供另外的机制。这样,名字空间就起到了一个屏障的作用。

在图3-1中,显示了两个名字空间,各有一个类加载器,各加载了两个类型,两个名字空间都有一个叫做volcano的类型。左边颜色较深的类加载器加载了类型climbervolcano,右边颜色较浅的类加载器加载了类型bakingsodavolcano。图中的箭头表示名字空间中的名字对在方法区(method area)中对应类型定义数据的引用。因为名字空间的屏障作用,当climber引用volcano时,是指的同一个名字空间中的volcano。尽管都在同一个jvm,它也无法知道另一个名字空间中的volcano,。如果想知道如何达到名字空间的分隔的,可以看第八章“链接模式”

类加载器体系可以通过使用不同的类加载器加载可信包(trusted packages)和不可信包(untrusted packages)从而保护可信包的安全。尽管你可以对同一包中的类型制定访问控制,但这种控制只有在被同一个加载器加载的前提下才起作用。

通常,一个用户定义的类加载器需要依赖其他类加载器来完成任务,至少需要一个在jvm启动时创建的类加载器,这个类加载器称为启动类加载器。在1.2版本以前,类加载器必须显示的调用其他类加载器,如调用其他用户定义的类加载器的loadClass方法,或者调用启动类加载器(bootstrap class loader)的静态函数findSystemClass()。
在1.2版本中,一个类加载器要求另一个类加载器加载某个类型的过程被规范化为代理模式(parent-delegation model 译者:就是Chain of Responsibility模式)

在某个类加载器试图以自己的方式加载一个类时,它首先缺省把这个工作交给自己的父对象。而这个父对象又会首先把这个任务交给自己的父对象处理,这样这个任务会一直传到启动类加载器,因为启动类加载器通常是代理链的最后一个类。如果父类加载器能够加载这个类型,就会返回此类型,否则由子类加载器处理。
在1.2版本以前的多数jvm的实现中,内建类加载器负责加载本地可用的类文件,通常包括java应用的类文件和所有这个应用需要的库,尽管加载所需类文件的方式根据应用不同而不同,但许多应用都以class path定义的路径来搜寻所需类文件。

在1.2版本中,加载本地可用类文件的任务被分解给了多个类加载器。以前称为原始类加载器(primordial class loader)的类加载器被改称为启动类加载器,用来表示它只用来加载核心java api的类文件,因为核心java api类文件是用来启动jvm的。

而负责加载其他类文件的任务都给了用户定义的类加载器(译者:这里指广义用户,包括虚拟机的实现者),这些类文件包括应用的类文件,用来进行安装和下载的标准扩展类文件,用来在class path中查找库的类文件等等。
因此当1.2的JVM开始运行时,它会创建至少一个用户定义的类加载器,所有的这些类加载器串成一个链,在链的头部是启动类加载器,在链的尾部是系统类加载器(system class loader)。在1.2之前,有时称内建类加载器为系统类加载器,在1.2,这个名字被更正式的用于称呼java应用所创建的新的类加载器的父亲。

这个缺省父代理通常来加载应用的初始类,但任何用户定义的类加载器都可能被java平台的设计者所改变。
例如,假如你写了一个应用,此应用需要安装一个类加载器,用来加载从网络上下载的类文件。这个应用运行在一个jvm上,而这个jvm有两个用户定义的类加载器,一个是安装扩展类加载器,另一个是类路径类加载器。它们和启动类加载器串成一个链,依次为:启动类加载器,安装扩展类加载器,类路径加载器。

如图3-2,类路径加载器被设计成了系统类加载器,它将是java应用新的类加载器的父亲。当你的应用的网络类加载器被安装时,它将这个系统类加载器设为它的父亲。

假如在java应用运行中需要加载一个称为volcano的类,你的类加载器会首先把这一任务交给它的父亲,类路径类加载器,去查找和加载这个类文件。而类路径类加载器同样首先交给自己的父亲,安装扩展类加载器,去完成任
务。这样,这个任务最后交给启动类加载器去首先尝试处理。

假设类volcano不是java api的一部分,也不是安装扩展和类路径的一部分,所有对应的类加载器都没有返回这个类型,这样就轮到你自己的类加载器了。它将会从网络上下载此类文件,这样这个类就称为你应用中的一部分了。
我们继续这个例子,假如以后某个时候第一次调用了类volcano的一个方法,这个方法中引用了java api中的类java.util.HashMap,而这个类是这个应用第一次引用,这样jvm就要求你的类加载器去加载这个类。象以前一样,这个请求最终到达了启动类加载器。

但这次不同,启动类加载器能够加载java.util.Hashmap并把它返回给了你的类加载器。这样安装扩展类加载器和类路径类加载器只起到了一个传递的作用,而你的类加载器也不用从网络下载这个类文件了。从此,在类volcano中,所有对java.util.Hashmap的引用都会使用这个类。

有了这个背景知识,我们就可以看看类加载器是如何被用来保护可信库(trusted libraries)的了。类加载器体系通过防止不可信类冒充可信类保护了可信类的边界,防止了对java runtime安全的潜在威胁。
有了这个链状的代理关系,我们知道,要加载一个类,需要链上的类加载器按特定顺序逐次检查,这样自己定义的类加载器始终处于一个较低优先级的状态,如果你自己的类加载器想要从网络上下载一个叫做java.lang.Integer的类是不可能的。它只能使用从父类加载器传来的类型。通过这种方法,防止了用不可信代码替换可信代码的发生。

但假如代码不准备去替换一个可信类型,而只想在可信包中插入一个新类型呢?假如在前面例子中,你的网络类加载器想加载一个叫做java.lang.Virus的类。象以前一样,加载类的要求在链内传递,直到启动类加载器,尽管启动类加载器负责加载核心java api的类,其中也包括一个叫java.lang的包名,但找不到Virus,我们假设这个类同样在安装扩展类加载器和类路径类加载器中也没有找到。这样你的网络类加载器就从网络上下载了这个类。
假设你成功下载了类java.lang.VirusJava对在同一个包中的类的相互访问有一定的特权。因此,因为你的类加载器加载了一个无耻的宣称自己是java api的一部分的类java.lang.Virus,你肯定希望能够享受到某种特权,从而干一些罪恶的勾当。但类加载机制制止了这种事情的发生,因为类加载机制限制这种特权只有在同一个类加载器加载的前提下。

因为java apijava.lang包中的可信类都由启动类加载器加载,而邪恶的java.lang.Virus由你的网络类加载器加载,他们并不属于同一个运行包(runtime package)。

运行包这个术语首次在jvm第二版的规范中引入,指由同一个类加载器加载的同一个包中的所以类型。
在允许同一个包中的两个类型访问之前,jvm还有确信此两个类型是由同一个类加载器加载的。
因此,jvm不允许java.lang.Virus去访问java apijava.lang 包中的其他类型,因为他们不是由同一个类加载器加载的。

引入运行包的目的之一就是使用不同的类加载器加载不同类型的类文件。启动类加载器用来加载最值得信赖的核心java api。安装扩展类加载器用来加载安装扩展的任何类文件。虽然安装扩展也是可以信赖的,但还没有到可以向java api添加新类型的程度。同样,类路径类加载器加载的类也不能访问安装扩展和java api中的类型。

类加载器也可以简单的禁止加载某些类型来保护可信代码。

例如,你可能安装了某些包,其中有一些类你想由类路径类加载器来加载,而不是你的网络类加载器。
假设你创建一个叫做absolutepower的包,并把它安装在了类路径类加载器的访问范围内。同时你希望由你的类加载器加载的类不能加载absolutepower包中任何类。这样在你的类加载器中的第一件事就是检查需要加载的类是不是把自己声明为absolutepower中的类,如果是,则抛出一个异常,并不是交给父类加载器来处理。
类加载器机制除了屏蔽不同名字空间,保护可信类库外,还把每个加载的类放到了一个保护域(protection domain)中,保护域对类的活动范围也有一个定义。

类加载器子系统

类加载器子系统指jvm系统中负责查找和加载类型的子系统。
jvm有两种类加载器:启动类加载器和用户定义类加载器。
前者是jvm实现的一部分,后者是java应用的一部分。
由不同的类加载器加载的类被放在了jvm的不同名字空间中。

类加载器子系统包括了jvm的其他几个部分,利用了
java.lang库中的几个类。例如,用户定义类加载器继承
了类java.lang.ClassLoader。ClassLoader的方法
为应用提供了访问jvm类加载器子系统的接口。另外,对
每个加载上来的类,类加载器都会创建一个java.lang.Class
的实例来描述这个类。象其他所有对象一样用户定义类加载器和Class的实例也放在堆中。加载的类型信息放在方法区。

加载,链接 和初始化

类加载器子系统除了定位和加载二进制类文件外,还必须保证类文件的正确性,为类变量分配内存并初始化和帮助对符号引用的解析。这些行为严格遵照以下的顺序:

加载:包括定位和加载某类型的二进制数据
链接:包括进行验证,准备和解析(可选)
     a 验证:确信引入类型的正确性
     b 准备:为类变量分配内存并初始化
     c 解析:将符号引用改为直接引用
初始化:激活类变量的初始化java代码

启动类加载器

jvm必须能够识别和加载放在符合类文件格式的二进制文件中的类和接口.除了类文件,jvm实现也可以识别其他文件。

启动类加载器对每个jvm实现都是必须的,它知道如何加载包括java api的可信类。jvm规范并没有定义启动类如何定位类文件,这个将由jvm实现者自行掌握。

启动类加载器必须能够根据一个完整的有效的类型名称产生这个类型的定义数据。在Windows98平台上采用Sun's 1.1 jdk的jvm实现提供了一个常用的方法。启动类加载器通过对定义在环境变量classpath中的所有路径进行搜寻,直到找到了这个类定义文件,类定义文件的名称是类名加".class"。通常一个类都有一个所属包,启动类加载器会到相应的子目录下去找,例如一个叫做java.lang.Object的类,启动类加载器会在所有定义在CLASSPATH的路径中的javalang目录下找一个叫做Object.class的类,直到找到。

在1.2的版本中,启动类加载器只在系统类的安装目录中查找,而不是所有定义在classpath的目录。这个任务交给了系统类加载器,系统类加载器是一个用户定义的类加载器,它在jvm启动时就被自动创建。

用户定义的类加载器

尽管用户定义类加载器本事是属于java应用的,但可以通过它的四个函数与jvm交互。
// 在类java.lang.ClassLoader中声明的四个函数:
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);
任何jvm的实现都必须把这四个函数连到内部的类加载器子系统。

两个defineClass()方法都有一个类型为字节数组名称为data[]的输入参数,defineClass()希望data[offset]到data[offset +length]中的数据是一个合法的类型数据,这个新类型将会被放到指定的保护区中,如果是第一个defineClass方法则会放到缺省保护区中,每个jvm实现必须保证defineClass能够把新类型放到方法区中。

findSystemClass()方法以一个类型名为参数。在1.0和1.1版本,这个方法会通过启动类加载器加载这个类型。假如类加载器已经加载了这个类型,它就会返回描述这个类型的Class对象。如果没有找到,就会抛出一个ClassNotFoundException.在1.2版本,findSystemClass()方法通过系统类加载器来加载这个类型。不管通过那个类加载器,其对外的行为是被规范的。

resolveClass()方法接收一个对Class对象的引用。它对这个Class对象代表的类型进行链接。

名字空间

每个类加载器都有自己的名字空间,所以一个java应用可以对同一个类型加载多次。这样一个类型的完整的有效名称并不足以确定在jvm中的唯一性。当有多个类加载器都加载了同一个类型,为了唯一确定类型还要在类型名称前加上类加载器的名字。

除了加载类型的数据外,jvm还要记录类型的加载器。当jvm解析一个类到另一个类的符号引用时,jvm会
保证被引用类的类加载器与引用类的类加载器相同。

方法区
在一个jvm实例的内部,类型信息被存储在一个称为方法区的内存逻辑区中。类型信息是由类加载器在类加载时从类文件中提取出来的。类(静态)变量也存储在方法区中。

jvm实现的设计者决定了类型信息的内部表现形式。如,多字节变量在类文件是以big-endian存储的,但在加载到方法区后,其存放形式由jvm根据不同的平台来具体定义。

jvm在运行应用时要大量使用存储在方法区中的类型信息。在类型信息的表示上,设计者除了要尽可能提高应用的运行效率外,还要考虑空间问题。根据不同的需求,jvm的实现者可以在时间和空间上追求一种平
衡。

因为方法区是被所有线程共享的,所以必须考虑数据的线程安全。假如两个线程都在试图找lava的类,在lava类还没有被加载的情况下,只应该有一个线程去加载,而另一个线程等待。

方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。同样方法区也不必是连续的。方法区可以在堆(甚至是虚拟机自己的堆)中分配。jvm可以允许用户和程序指定方法区的初始大小,最小和最大尺
寸。

方法区同样存在垃圾收集,因为通过用户定义的类加载器可以动态扩展java程序,一些类也会成为垃圾。jvm可以回收一个未被引用类所占的空间,以使方法区的空间最小。

类型信息
对每个加载的类型,jvm必须在方法区中存储以下类型信息:
一 这个类型的完整有效名
二 这个类型直接父类的完整有效名(除非这个类型是interface或是
    java.lang.Object,两种情况下都没有父类)
三 这个类型的修饰符(public,abstract, final的某个子集)
四 这个类型直接接口的一个有序列表

类型名称在java类文件和jvm中都以完整有效名出现。在java源代码中,完整有效名由类的所属包名称加一个".",再加上类名组成。例如,类Object的所属包为java.lang,那它的完整名称为java.lang.Object,但在类文件里,所有的"."都被斜杠“/”代替,就成为java/lang/Object。完整有效名在方法区中的表示根据不同的实现而不同。

除了以上的基本信息外,jvm还要为每个类型保存以下信息:
 类型的常量池( constant pool)
 域(Field)信息
 方法(Method)信息
 除了常量外的所有静态(static)变量

常量池
jvm为每个已加载的类型都维护一个常量池。常量池就是这个类型用到的常量的一个有序集合,包括实际的常量(string,integer, 和floating point常量)和对类型,域和方法的符号引用。池中的数据项象数组项一样,是通过索引访问的。
因为常量池存储了一个类型所使用到的所有类型,域和方法的符号引用,所以它在java程序的动态链接中起了核心的作用。

域信息
jvm必须在方法区中保存类型的所有域的相关信息以及域的声明顺序,
域的相关信息包括:
域名
域类型
域修饰符(public, private, protected,static,final  volatile, transient的某个子集)
       
方法信息
jvm必须保存所有方法的以下信息,同样域信息一样包括声明顺序
方法名
方法的返回类型(或 void)
方法参数的数量和类型(有序的)
方法的修饰符(public, private, protected, static, final,synchronized, native, abstract的一个子集)
除了abstract和native方法外,其他方法还有保存
方法的字节码(bytecodes)
操作数栈和方法栈帧的局部变量区的大小           
异常表

类变量(
  Class Variables
  译者:就是类的静态变量,它只与类相关,所以称为类变量)
类变量被类的所有实例共享,即使没有类实例时你也可以访问它。
这些变量只与类相关,所以在方法区中,它们成为类数据在逻辑上的一部分。在jvm使用一个类之前,它必须在方法区中为每个non-final类变量分配空间。

常量(被声明为final的类变量)的处理方法则不同,每个常量都会在常量池中有一个拷贝。non-final类变量被存储在声明它的类信息内,而final类被存储在所有使用它的类信息内。

对类加载器的引用
jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。
如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。

jvm在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。这对jvm区分名字空间的方式是至关重要的。

对Class类的引用
jvm为每个加载的类型(译者:包括类和接口)都创建一个java.lang.Class的实例。而jvm必须以某种方式把
Class的这个实例和存储在方法区中的类型数据联系起来。

你可以通过Class类的一个静态方法得到这个实例的引用
// A method declared in class java.lang.Class:
public static Class forName(String className);

假如你调用forName("java.lang.Object"),你会得到与java.lang.Object对应的类对象。你甚至可以通过这个函数
得到任何包中的任何已加载的类引用,只要这个类能够被加载到当前的名字空间。如果jvm不能把类加载到当前名字空间,forName就会抛出ClassNotFoundException。(译者:熟悉COM的朋友一定会想到,在COM中也有一个称为      类对象(Class Object)的东东,这个类对象主要是实现一种工厂模式,而java由于有了jvm这个中间层,类对象可以很方便的提供更多的信息。这两种类对象都是Singleton的)

也可以通过任一对象的getClass()函数得到类对象的引用,getClass被声明在Object类中:
// A method declared in class java.lang.Object:
public final Class getClass();
例如,假如你有一个java.lang.Integer的对象引用,可以激活getClass()得到对应的类引用。

通过类对象的引用,你可以在运行中获得相应类存储在方法区中的类型信息,下面是一些Class类提供的方法:
// Some of the methods declared in class java.lang.Class:
public String getName();
public Class getSuperClass();
public boolean isInterface();
public Class[] getInterfaces();
public ClassLoader getClassLoader();

这些方法仅能返回已加载类的信息。getName()返回类的完整名,getSuperClass()返回父类的类对象,isInterface()判断是否是接口。getInterfaces()返回一组类对象,每个类对象对应一个直接父接口。如果没有,则返回一个长度为零的数组。
getClassLoader()返回类加载器的引用,如果是由启动类加载器加载的则返回null。所有的这些信息都直接从方法区中获得。

方法表
为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,jvm的实现者还可以添加一些其他的数据结构,如方法表。jvm对每个加载的非虚拟类的类型信息中都添加了一个方法表,方法表是一组对类实例方法的直接引用(包括从父类继承的方法)。jvm可以通过方法表快速激活实例方法。
(译者:这里的方法表与C++中的虚拟函数表一样,但java方法全都是virtual的,自然也不用虚拟二字了。正像java宣称没有指针了,其实java里全是指针。更安全只是加了更完备的检查机制,但这都是以牺牲效率为代价的,个人认为java的设计者始终是把安全放在效率之上的,所有java才更适合于网络开发)

一个例子
为了显示jvm如何使用方法区中的信息,我们据一个例子,我们
看下面这个类:
class Lava {
    private int speed = 5; // 5 kilometers per hour
    void flow() {
    }
}

class Volcano {
    public static void main(String[] args) {
        Lava lava = new Lava();
        lava.flow();
    }
}
下面我们描述一下main()方法的第一条指令的字节码是如何被执行的。不同的jvm实现的差别很大,这里只是其中之一。

为了运行这个程序,你以某种方式把“Volcano"传给了jvm。有了这个名字,jvm找到了这个类文件(Volcano.class)并读入,它从类文件提取了类型信息并放在了方法区中,通过解析存在方法区中的字节码,jvm激活了main()方法,在执行时,jvm保持了一个指向当前类(Volcano)常量池的指针。

注意jvm在还没有加载Lava类的时候就已经开始执行了。正像大多数的jvm一样,不会等所有类都加载了以后才开始执行,它只会在需要的时候才加载。

main()的第一条指令告知jvm为列在常量池第一项的类分配足够的内存。jvm使用指向Volcano常量池的指针找到第一项,发现是一个对Lava类的符号引用,然后它就检查方法区看lava是否已经被加载了。

这个符号引用仅仅是类lava的完整有效名”lava“。这里我们看到为了jvm能尽快从一个名称找到一个类,一个良好的数据结构是多么重要。这里jvm的实现者可以采用各种方法,如hash表,查找树等等。同样的算法可以用于Class类的forName()的实现。

当jvm发现还没有加载过一个称为"Lava"的类,它就开始查找并加载类文件"Lava.class"。它从类文件中抽取类型信息并放在了方法区中。

jvm于是以一个直接指向方法区lava类的指针替换了常量池第一项的符号引用。以后就可以用这个指针快速的找到lava类了。而这个替换过程称为常量池解析(constant pool resolution)。在这里我们替换的是一个native指针。

jvm终于开始为新的lava对象分配空间了。这次,jvm仍然需要方法区中的信息。它使用指向lava数据的指针(刚才指向volcano常量池第一项的指针)找到一个lava对象究竟需要多少空间。

jvm总能够从存储在方法区中的类型信息知道某类型对象需要的空间。但一个对象在不同的jvm中可能需要不同的空间,而且它的空间分布也是不同的。(译者:这与在C++中,不同的编译器也有不同的对象模型是一个道理)

一旦jvm知道了一个Lava对象所要的空间,它就在堆上分配这个空间并把这个实例的变量speed初始化为缺省值0。假如lava的父对象也有实例变量,则也会初始化。

当把新生成的lava对象的引用压到栈中,第一条指令也结束了。下面的指令利用这个引用激活java代码把speed变量设为初始值,5。另外一条指令会用这个引用激活Lava对象的flow()方法。



一个java应用在运行中所创建的所有类实例或数组都放在了同
一个堆中,并由应用所有的线程共享。因为一个java应用
唯一对应了一个jvm实例,所以每个应用都独占了一个堆,它
不可能对另一个应用的堆进行破坏。然而,一个多线程应用必须
考虑同步问题。

jvm有在堆中分配对象的指令,却没有释放对象的指令。正如
你无法用java代码去释放一个对象一样,字节码也没有对应的
功能。应用本身不用去考虑何时和用什么方法去回收不用对象所
占用的内存。通常,jvm把这个任务交给垃圾收集器。

垃圾收集
一个垃圾收集器的主要工作是回收不再被引用的对象所占用
的内存。它也可能去移动仍然使用的对象以减少内存碎片。

jvm规范没有指定垃圾收集使用什么技术,这些都由jvm的实现者
去定夺。因为对象的引用可能存在很多地方,如java堆栈,堆,
方法区,native方法栈。所以垃圾收集技术的使用在很大程度上
影响了运行数据区的设计。

象方法区一样,堆不必是一块连续的内存区,也可以根据应用的需要
动态调整大小。可以把方法区放在堆的顶部,换句话说就是类型信息和实际
对象都在同一个堆上。负责清理对象的垃圾收集器可能也要负责类的回收
。堆的初始化大小,最大最小尺寸可以由用户或程序指定。

对象表现( Object Representation)
(译者:C++中称为对象模型)
jvm规范没有规定对象在堆中该如何表现。对象的表现会影响堆和
垃圾收集的整个设计,它由jvm的实现者决定。

对象的主要数据是由对应类和其父类声明的实例变量组成
(instance variables 译者:对应Class variables,
Class variables存储在方法区中,这在上篇译文中有讲)
jvm应该既能够从一个对象引用快速的找到实例变量,也能够快速
的找到存储在方法区中的类数据。所以在对象中常常会有一个指向
方法区的指针。

一个可能的实现是把堆分成两部分:一个句柄池和一个对象池。如图5-5
一个对象引用是一个指向句柄池的native指针。句柄池的每个条目有
两部分:一个指向对象实例变量的指针,一个指向方法区类型数据的指针。
这种设计的好处是利于堆碎片的整理,当移动对象以减少碎片时不用更新
每个对象引用而只更改句柄就可以了。缺点是每次访问对象都要经过两次
指针传递。

图5-5

另一种设计是使对象指针直接指向对象实例变量,而在对象实例内包含一个
指向方法区类型数据的指针。这样的设计的优缺点正好与前面的方法相反。
如图5-6.

图5-6

jvm有若干理由使它能够从对象引用中得到对应类的数据。
1。 当应用试图转型(cast)时,jvm需要保证要转的类型是此类型本身
    或是这个类型的父类型。
2。 当应用进行 instanceof 操作时
3。 当应用激活一个实例方法时,jvm必须进行动态绑定,而它所依赖的信息
    不是这个引用的类型,而是这个对象对应的类的信息。   

不管对象以什么形式表现,好像都有一个能够方便访问的方法表。由于方法表
能加速实例方法的调用,所以对jvm的性能有重要的影响。jvm规范并没有规定
必须要使用方发表,例如在内存稀少的环境下,可能不能负担方法表的内存支出。
然而如果使用了方法表,它就应该能够快速的从一个对象引用中获得。

图5-7

图5-7显示了一种连结方法表和对象引用的实现。每个对象的数据包含一个
指向特殊数据结构的指针,这个数据结构位于方法区,它包括两部分:
一 一个指向方法区对应类数据的指针
二 此对象的方法表
   方法表的每一项都是一个指向方法数据的指针,方法数据包括:
   一 此方法的操作数堆栈和局部变量区的大小
   二 方法的字节码
   三 异常表
这些信息足够jvm去激活一个方法了。方法表的函数指针包括类或其父类声明的
函数。也就是说,方法表所指向的函数可能是此类声明的,也可能是它继承下来
的。

如果你熟悉c++的内部工作原理,你会发现这和c++的vtbl非常相似。在c++
中,对象由实例数据和一组指向虚拟函数的指针组成,jvm也可以采用这种方法。
jvm可以在堆中为每个对象都附加一个方法表,这样较之图5-7会占用更多的内存,
但可提高一些效率。这个方案适用在内存足够充裕的系统。
(译者:总觉得作者对c++有些误解,c++的对象模型在函数表上的设计和图5-7
     是类似的,在有虚拟函数的情况下(不考虑多继承),每个对象也只多出
     一个指向vtable的指针,而vtable也是与类关联的。)

除了图5-5和5-6显示的实例数据外,对象数据还有一个逻辑部分,那就是对象
锁(object's lock)。在jvm中每个对象都有一个锁,以用于多线程访问时的
同步。在某个时刻只有一个线程拥有这个对象锁,而且只有这个线程才可以对
对象的数据进行访问。其他要访问这个对象的线程只有等待,直到拥有对象锁的
线程释放所有权。当一个线程拥有对象锁后,可以继续对锁追加请求。但请求
几次,必须对应释放几次。

许多对象在其生命期内可能不需要加锁,这样也不需要附加数据,正如图5-5 5-6
所示,对象数据内没有一个指向锁数据(lock data)的指针。而只有当需要加锁的
时候才分配对应锁数据,但这时需要其他的方法来联系对象数据和对应的锁数据,
例如把锁数据放在一个以对象地址为索引的树中。

除了实现锁需要的数据,每个java对象逻辑上还有为实现同步而添加的数据。
锁是用来实现多个线程对共享数据的互斥访问,而同步则是实现多个线程为
完成一个共同目标而协调工作。

同步由等待方法和通知方法共同实现。每个类都从Object那里继承了
三个等待方法(三个名为wait()过载函数)和两个通知方法(notify()
和notifyAll())。当一个线程在一个对象上调用wait方法,jvm就
阻塞了这个线程并把它放在了这个对象的等待集(wait set)中。当
有一个线程在这个对象调用了通知方法,jvm就会在将来的某个时间
唤醒一个或多个在等待集中阻塞的线程。正像锁数据一样,并不是
每个对象都需要同步数据。许多jvm实现都把同步数据与对象数据分开,
只有在需要时才为此对象创建同步数据,一般是在第一次调用等待方法
或通知方法时。

最后,一个对象还可能要包含与垃圾收集有关的数据。垃圾收集必须
要跟踪每个对象,这个任务不可避免的要附加一些数据,数据的类型
要视垃圾收集的算法而定。例如,假如垃圾收集使用标志清除算法,
必须要一个数据来标志此对象是否被引用。像线程锁一样,这些数据
也可以放在对象外。一些垃圾收集技术只在运行时需要额外数据。例如
标志清除算法使用一个位图来标志对象的引用情况。

除了标志对象的引用情况外,垃圾收集还要区分一个对象是否调用
了finalizer。在收集一个对象之前,垃圾收集器必须调用声明了
finalizer的类的对象。java语言规范指出垃圾收集器对某个对象
只能调用finalizer一次,在finalizer中允许这个对象复生
(resurrect),即使之再次被引用。这样当这个对象再次被收集时,
就不再调用finalizer了。需要finalizer的对象不多,而复生的
对象更少,所以对一个对象回收两次的情况很少见。这样用来标志
finalizer的数据虽然逻辑上是对象的一部分,但通常与对象分开
保存。

数组表现
再java中,数组是一个成熟的对象。像其他对象一样,数组也存储在
堆上,jvm实现的设计者也有权决定数组的表现。

数组也有一个相关的类实例(Class instance),所有具有相同维度
和类型的数组同为一个类,而不管数组的长度(多维数组每一维的长度)。
例如一个有三个ints的数组和一个有六个ints的数组都是同一个类。
数组的长度只与实例数据有关。

数组类的名称由两部分组成,一个是用'['表示的维和一个字符表示的类型。
例如,类型为ints的一维数组的类名为“[I”。类型为bytes的三维数组为
“[[[B”。类型为Object的二维数组为“[[Ljava.lang.Object”。

多维数组被表示为数组的数组。例如,类型为ints的二维数组,将表示为
一个一维数组,数组元素是一个一维ints数组的引用。如图5-8

图5-8

每个数组必须保存的数据是数组的长度,jvm必须能够从一个数组的引用得到
此数组的长度,通过下标访问数组元素,检查数组下标是否越界,激活Object
声明的方法。

Java堆栈
jvm为每个新创建的线程都分配一个堆栈。堆栈以帧为单位保存
线程的状态。jvm对堆栈只进行两种操作:以帧为单位的压栈和出栈
操作。

某个线程正在执行的方法称为此线程的当前方法。当前方法使用的帧称
为当前帧。当前方法所属的类称为当前类。当前类的常量池称为当前
常量池。当线程执行一个方法时,它会跟踪当前的类和常量池。当jvm
会在当前帧内执行帧内数据的操作。

当线程激活一个java方法,jvm就会在线程的java堆栈里新压入一个帧。
这个帧自然成为了当前帧。在此方法执行期间,这个帧将用来保存参数,
局部变量,中间计算过程和其他数据。

一个方法可以以两种方法结束。一种是正常返回结束。一种是通过
异常抛出而异常结束(abrupt completion)。不管以那种方式返回,jvm
都会将当前帧弹出堆栈然后释放掉,这样上一个方法的帧就成为当前帧了。
(译者:可能可以这样理解,位于堆栈顶部的帧为当前帧)

java堆栈上的所有数据都为此线程私有。一个线程不能访问另一个线程
的堆栈数据,所以在多线程的情况下也不需要对堆栈数据的访问进行同步。

象方法区和堆一样(见以前的译文),java堆栈和帧在内存中也不必是连续
的。帧可以分布在连续的内存区,也可以不是。帧的数据结构由jvm的实现者
来决定,他们可以允许用户指定java堆栈的初始大小或最大最小尺寸。

堆栈帧( The Stack Frame)
堆栈帧有三部分:局部变量区,操作数堆栈和帧数据区。局部变量区和操作数堆栈
的大小要视对应的方法而定。编译器在编译的时候就对每个方法进行了计算并放在
了类文件(class file)中了。帧数据区的大小对一种jvm实现来说是一定的。
当jvm激活一个方法时,它从类信息数据得到此方法的局部变量区和操作数堆栈的
大小,并据此分配大小合适堆栈帧压入java堆栈中。

局部变量区
java堆栈帧的局部变量区是一个基为零类型为word的数组。指令通过索引来
使用这些数据。类型为int,float,reference和returnAddress的值在
数组中占据一项,类型为byte,short,和char的值在存入数组前都转为了
int值而占据一项。类型为long和double的值在数组中占据连续的两项,在
访问他们的时候,指令提供第一项的索引。例如一个long值占据3,4项,指令会
取索引为3的long值。局部变量区的所有值都是字对齐的,long和doubles
的起始索引值没有限定。

局部变量区包含此方法的参数和局部变量。编译器首先以声明的顺序把参数
放入局部数据区。图5-9显示了下面两个方法的变量区。
// On CD-ROM in file jvm/ex3/Example3a.java
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;
    }
}


图5-9. 局部变量区中的方法参数

注意在方法runInstanceMethod()的帧中,第一个参数是一个
类型为reference的值,尽管方法没有显示的声明这个参数,但
这是个对每个实例方法(instance method)都隐含加入的一个
参数值,用来代表调用的对象。(译者:与c++中的this指针一样)
我们看方法runClassMethod()就没有这个变量,这是因为这是一
个类方法(class method),类方法与类相关,而不与对象相关。

我们注意到在源码中的byte,short,char和boolean在局部变量区
都成了ints。在操作数堆栈也是同样的情况。如前所述,jvm不直接
支持boolean类型,java编译器总是用ints来表示boolean。但java
对byte,short和char是支持的,这些类型的值可以作为实例变量
存储在局部变量区中,也可以作为类变量存储在方法区中。但在局部变量区
和操作数堆栈中都被转成了ints类型的值,期间的运算也是以int来的,
只当存回堆或方法区中,才会转回原来的类型。

同样需要注意的是runClassMethod()的对象o。在java中,所以的对象
都以引用(reference)传递。所有的对象都存储在堆中,你永远都不会在
局部变量区或操作数堆栈中发现对象的拷贝,只会有对象引用。

编译器对局部变量的放置方法可以多种多样,它可以任意决定放置顺序,
甚至可以用一个索引指代两个局部变量。例如,当两个局部变量的作用域
不重叠时,如Example3b的局部变量i和j。

// On CD-ROM in file jvm/ex3/Example3b.java
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);
        }
    }
}

jvm的实现者对局部变量区的设计仍然有象其他数据区一样的灵活性。
关于long和double数据如何分布在数组中,jvm规范没有指定。
假如一个jvm实现的字长为64位,可以把long或double数据放在
数组中的低项内,而使高项为空。(在字长为32位的时候,需要两项
才能放下一个long或double)。

操作数堆栈
操作数堆栈象局部变量区一样是用一个类型为word的数组存储数据,
但它不是通过索引来访问的,而是以堆栈的方式压入和弹出。假如
一个指令压入了一个值,另一个指令就可以弹出这个值并使用之。

jvm在操作数堆栈中的处理数据类型的方式和局部变量区是一样的,同样
有数据类型的转换。jvm没有寄存器,jvm是基于堆栈的而不是基于寄存器
的,因为jvm的指令从堆栈中获得操作数,而不是寄存器。虽然操作数还可以
从另外一些地方获得,如字节码中,或常量池内,但主要是从堆栈获得的。


jvm把操作数堆栈当作一个工作区使用。许多指令从此堆栈中弹出数据,进行
运算,然后压入结果。例如,iadd指令从堆栈中弹出两个数,相加,然后压入
结果。下面显示了jvm是如何进行这项操作的:
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

在这个字节码的序列里,前两个指令iload_0和iload_1将存储在
局部变量区中索引为0和1的整数压入操作数据区中,然后相加,将
结果压入操作数据区中。第四条指令istore_2从操作数据区中弹出
结果并存储到局部数据区索引为2的地方。在图5-10中,详细的表述
了这个过程,图中,没有使用的区域以空白表示。


图5-10. 两个局部变量的相加.

帧数据区
除了局部变量区和操作数据堆栈外,java栈帧还需要数据来支持
常量池解析(constant pool resolution),方法的正常返回
(normal method return)和异常分派(exception dispatch)。
这些信息保存在帧数据区中。

jvm中的许多指令都涉及到常量池的数据。一些指令仅仅是取出常量池
中的数据并压入操作数堆栈中。一些指令使用常量池中的数据来指示
需要实例化的类或数组,需要访问的域,或需要激活的方法。还有一些
指令来判断某个对象是否是常量池指定的某个类或接口的子孙实例。

每当jvm要执行需要常量区数据的指令,它都会通过帧数据区中指向
常量区的指针来访问常量区。以前讲过,常量区中对类型,域和方法
的引用在开始时都是符号。如果当指令执行的时候仍然是符号,jvm
就会进行解析。

除了常量区解析外,帧数据区还要帮助jvm处理方法的正常和异常结束。
正常结束,jvm必须恢复方法调用者的环境,包括恢复pc指针。假如
方法有返回值,jvm必须将值压入调用者的操作数堆栈。

为了处理方法的异常退出,帧数据区必须保存对此方法异常表的引用。
一个异常表定义了这个方法受catch子句保护的区域,每项都有一个
catch子句的起始和开始位置(position),和用来表示异常类在常量池
中的索引,以及catch子句代码的起始位置。

当一个方法抛出异常时,jvm使用帧数组区指定的异常表来决定如何处理。
如果找到了匹配的catch子句,就会转交控制权。如果没有发现,方法会
立即结束。jvm使用帧数据区的信息恢复调用者的帧,然后重新抛出同样
的异常。

除了上述信息外,jvm的实现者也可以将其他信息放入帧数据区,如调试
数据。

java堆栈的一种实现
实现者可以按自己的想法设计java堆栈。如以前所讲,一个方法是从堆中
单独的分配帧。我以此为例,看下面的类:
// On CD-ROM in file jvm/ex3/Example3c.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;
    }
}

图5-11显示了一个线程执行这个方法的三个快照。在这个jvm的实现中,
每个帧都单独的从堆中分配。为了激活方法addTwoTypes(),方法
addAndPrint()首先压入int 1和double88.88到操作数堆栈中,然后
激活addTwoTypes()方法。

图5-11. 帧的分配

激活addTwoTypes()的指令使用了常量池的数据,jvm在常量池中查找这些数据
如果有必要则解析之。

注意addAndPrint()方法使用常量池引用方法addTwoTypes(),尽管
这两个方法是属于一个类的。象引用其他类一样,对同一个类的方法和域
的引用在初始的时候也是符号,在使用之前需要解析。

解析后的常量池数据项将指向存储在方法区中有关方法addTwoTypes()的信息。
jvm将使用这些信息决定方法addTwoTypes()局部变量区和操作数堆栈的大小。
如果使用Sun的javac编译器(JDK1.1)的话,方法addTwoTypes()的局部变量区
需要三个words,操作数堆栈需要四个words。(帧数据区的大小对某个jvm实现
来说是定的)jvm为这个方法分配了足够大小的一个堆栈帧。然后从方法
addAndPrint()的操作数堆栈中弹出double参数和int参数(88.88和 1)并把他们
分别放在了方法addTwoType()的局部变量区索引为1和0的地方。

当addTwoTypes()返回时,它首先把类型为double的返回值(这里是89.88)
压入自己的操作数堆栈里。jvm使用帧数据区中的信息找到调用者(为
addAndPrint())的堆栈帧,然后将返回值压入addAndPrint()的操作数堆栈
中并释放方法addTwoType()的堆栈帧。然后jvm使addTwoType()的堆栈帧
为当前帧并继续执行方法addAndPrint()。

图5-12显示了相同的方法在不同的jvm实现里的执行情况。这里的堆栈帧是在
一个连续的空间里的。这种方法允许相邻方法的堆栈帧可以重叠。这里调用者的
操作数堆栈就成了被调者的局部变量区。

图5-12. 从一个连续的堆栈中分配帧

这种方法不仅节省了空间,而且节省了时间,因为jvm不必把参数从一个
堆栈帧拷贝到另一个堆栈帧中了。

注意当前帧的操作数堆栈总是在java堆栈的顶部。尽管这样可能
可以更好的说明图5-12的实现。但不管java堆栈是如何实现的,
对操作数堆栈的操作总是在当前帧执行的。这样,在当前帧的
操作数堆栈压入一个数也就是在java堆栈压入一个值。

java堆栈还有一些其他的实现,基本上是上述两种的结合。一个jvm可以
在线程初期时从堆栈分出一段空间。在这段连续的空间里,jvm可以采用
5-12的重叠方法。但在与其他段空间的结合上,就要使用如图5-11的方法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值