第一部分 类和类的装载
我们来看一下类以及它们被JVM装载的时候做了些什么?
一个类的二进制形式
用Java语言的开发人员通常不必关心通过编译器运行他们的源代码时所发生的一些细节问题。在本文中。我会介绍许多有关从源代码到可执行的程序这个过程的背后细节,因此,我们先来看一下编译器所产生的二进制类。
二进制类的格式实际上是被JVM(Java虚拟机)规范定义的。正常的类的描述是一个编译器利用Java语言的源代码生成的,并且通常被保存在一以.class为扩展名的文件中。但是这些特征都不是本质的。
其它的一些编程语言已经被开发使用Java的二进制类的格式,并且,因为一些目的,新的类的描述被创建并且被直接装载进一个正在执行的JVM中。但是JVM所关心的,重要的不是这些源代码或它是怎样被存储的,而是这个格式自身。
因此,先来这种类格式看上去象什么呢?下面(List 1.)列出了一个非常短的类的源代码,紧跟着是用编译器输出的这个类文件的一部分十六进制的显示:
List 1.Hello.java的源代码和(部分)二进制表示
public class Hello
{
public static void main(String[] args)
{
System.out.println("Hello, World!");
}
}
0000: cafe babe 0000 002e 001a 0a00 0600 0c09
................
0010: 000d 000e 0800 0f0a 0010 0011 0700 1207
................
0020: 0013 0100 063c 696e 6974 3e01 0003 2829
.....<init>...()
0030: 5601 0004 436f 6465 0100 046d 6169 6e01
V...Code...main.
0040: 0016 285b 4c6a 6176 612f 6c61 6e67 2f53
..([Ljava/lang/S
0050: 7472 696e 673b 2956 0c00 0700 0807 0014
tring;)V........
0060: 0c00 1500 1601 000d 4865 6c6c 6f2c 2057
........Hello, W
0070: 6f72 6c64 2107 0017 0c00 1800 1901 0005
orld!...........
0080: 4865 6c6c 6f01 0010 6a61 7661 2f6c 616e
Hello...java/lan
0090: 672f 4f62 6a65 6374 0100 106a 6176 612f
g/Object...java/
00a0: 6c61 6e67 2f53 7973 7465 6d01 0003 6f75
lang/System...ou
... |
二进制的内部
List1中所显示的二进制类的表示的第一件事情是标识Java二进制类的格式的café babe签名,这个签名只是一种确认实际请求的Java类的格式的一个实例的数据块的简易方法。每个Java的二进制类,即使在不同的文件系统上,也需要用这四个字节开始。
数据的其它部分不是很有趣。跟在签名后面是一对类格式的版本号(在这个例子中,用1.4.1javac编译生成的时候,会产生次版本为0、主版本为46------十六进制的形式是0x2e的版本号),然后是常量池中的条目的计数。
跟在条目计数(在这个例子中是26,或0x001a)后面的是实际的常量池数据。这是保存所有类定义所使用的常量的地方。它包括类和方法的名字、签名以及字符串(这些字符串是你能够认可的在十六制的存放处的正确性的文本解释)、以及连同在一起的各种二进制值。
在常量池中项目是可变长度的,每个项目的第一个字节标识了项目的类型和它应该怎样被解码。我不打算对这些内容做详细介绍,如果你有兴趣以实际的JVM规范开始,这里有许多有用参考。
关键点是常量池包含了所有的对其它类和这个类所使用的方法的引用,还有这个类自身以及它的方法的实际定义。尽管平均值可能会少一些,但是常量池的大小很容易的超过二进制类的在小的一半或更多。
跟在常量池后面是几个引用常量池条目的项目,它们是类本身,它的超类以及接口。这些项目的后面是有关字段和方法的信息,这些信息是做为复合结构来描述自己的。对于方法的可执行代码以代码属性(code attributes)的形式被包含在方法的定义中。这种代码是JVM的指令形式,通常叫做字节码(bytecode),这是下一节的主题之一。
在Java类的格式中属性(Attributes)用来做为几种定义的用途,包括已经提到的字节码(bytecode),用于字段的常量值,异常处理,以及调试信息。但是,属性(Attributes)不只有这些可能的用途。
从一开始,JVM规范要求JVMs(Java虚拟机)忽略未知类型的属性。这种要求对于属性的使用提供了灵活性,使得它在将来能够服务于其它的用途,例如提供与用户类一起工作的框架所需要的元信息------这是一种Java源于C#语言所广泛使用的方法。
字节码和堆栈
组成类文件的可执行部分的字节码是适应特定类型计算机(JVM)是的实际的机器码,这所以叫做虚拟机是因为它是用软件来设计实现的,而不是硬件。每个运行在JVM上的应用程序都是建立在这种机器的一种实现。
虚拟机实际上相当的简单,它使用堆栈结构,这就意味着它们在被使用之前指令操作要被装载进一个内部的堆栈。指令集包括所有的一般的算术运算和逻辑操作,还有有条件和无条转移,装载/存储,调用/返回,堆栈的维护,以及几种特殊的指令类型。包括立即数的一些指令被直接编码进指令,另外一些直接从常量池引用值。
虽然虚拟机是简单的,但执行起来却不是这样的,第一代JVM基本上是虚拟机的字节码的解析器,相对而言,比较简单,但却遇到严重的性能问题———解析代码总是要比执行本地代码花费更长的时间。
为了减少这些性能问题,第二代JVM添加了即时(JIT)翻译。JIT技术是在Java字节码第一次执行之前把它编译成本地代码,从而为重复执行提供了更好的性能。当前的JVM做的更好,它使用相应的技术来监控程序的执行并且选择性使使用代码得到优化。 装载类
把源代码编译成本地代码的语言(如C和C++)在源代码被编译之后通常需要链接这样的步骤。这种链接过程把独立编译的源文件连同共享类库的代码合并到一起,从而形成一个可执行的程序。
Java语言是不同的,使用Java语言,编译器生成的类文件一般情况下单独保存的,直到它们装载进一个JVM为止,即使是建立一个JAR文件也不会改变这种情况———JAR文件只是类文件的一个容器。
优于一个分开的步骤,JVM把类装载进内存的时候,链接类成为JVM所要执行的工作的一部分。这样就可以在初始化装载的时候增加一些系统开销,但是也为Java应用程序提供了高级的灵活性。
例如,应用程序可以使用直到运行时才知道的实际实现的接口来编写。这种后期绑定(late binding)的方法来装配一个应用程序在Java平台中被广泛使用,servlets就是一个普通的例子。
对于装载类的规则在JVM规范的细节中被清楚的说明了。基本原则是类只有在需要的时候才被装载(或者至少是显示的装载,JVM的这种方法在实际装载过程中有一些灵活性,但是必需保持一个固定的类初始化的顺序)。
每个被装载的类可以有其它的它所依赖的类,因此装载过程是递归的。在Listing2中的类显示了这种递归装载是怎样工作的。这个Demo类包含了一个简单的创建Greeter类的一个实例并且调用这个类的greet方法的main方法。Greeter类的构造器创建了一个Message的实例,然后它在greet方法中使用这个Message实例。
Listing 2用于类装载演示的源码
public class Demo
{
public static void main(String[] args)
{
System.out.println
("**beginning execution**");
Greeter greeter = new Greeter();
System.out.println
("**created Greeter**");
greeter.greet();
}
}
public class Greeter
{
private static Message s_message
= new Message("Hello, World!");
public void greet()
{
s_message.print(System.out);
}
}
public class Message
{
private String m_text;
public Message(String text)
{
m_text = text;
}
public void print(java.io.PrintStream ps)
{
ps.println(m_text);
}
} |
设置Java命令的命令行参数为-verbose:class,这样就可打印类装载过程的轨迹。Listing 3显示了使用这个参数的来运行Listing 2时的部分输出:
[Opened /usr/java/j2sdk1.4.1
/jre/lib/rt.jar]
[Opened /usr/java/j2sdk1.4.1
/jre/lib/sunrsasign.jar]
[Opened /usr/java/j2sdk1.4.1
/jre/lib/jsse.jar]
[Opened /usr/java/j2sdk1.4.1
/jre/lib/jce.jar]
[Opened /usr/java/j2sdk1.4.1
/jre/lib/charsets.jar]
[Loaded java.lang.Object from
/usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.io.Serializable from
/usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.lang.Comparable from
/usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.lang.CharSequence from
/usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.lang.String from
/usr/java/j2sdk1.4.1/jre/lib/rt.jar]
...
[Loaded java.security.Principal from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]
[Loaded java.security.cert.Certificate
from /usr/java/j2sdk1.4.1
/jre/lib/rt.jar]
[Loaded Demo]
**beginning execution**
[Loaded Greeter]
[Loaded Message]
**created Greeter**
Hello, World!
[Loaded java.util.HashMap$KeySet
from /usr/java/j2sdk1.4.1
/jre/lib/rt.jar]
[Loaded java.util.HashMap$KeyIterator
from /usr/java/j2sdk1.4.1
/jre/lib/rt.jar] |
这里只列出了最重要的部分———全部的装载轨迹由294行组成。试图装载Demo类所要触发的需要装载的初始的类的有279个(在这个例子中),这些是每个Java程序所使用的核心类,不管这个类的代码如何的小,即使去掉Demo类main方法中的所有的代码,也不会影响这个初始化的装载顺序。但是,装载类的数量和名字与使用类库的版本不同有关。
在Demo类被装载之后所列出的那一部分更加有趣。这里所显示的顺序是Greeter类只有这个类要被创建的时候才被装载,但是Greeter类使用了Message类的一个静态实例,所以,在创建一个Greeter类的实例之前,后面的Message类也需要被装载。
当一个Java类被装载和初始化时。在JVM内部发生很事情,包括对二进制类格式进行解码,检查与其它类的兼容性,确认字节码操作的顺序,并最终创建一个java.lang.class的实例来描述这个新类。
这个类对象成为JVM创建的这个新类的所有实例的基础。同时它也标识了被装载类的自身———你可以在一个JVM中装载同样的二进制类的多个副本,每个都拥有它们本身的类的实例。尽管这些副本共享着同样的类名,但是对于JVM来说他们独立的类。
装载进JVM中的类是通过类装载器来控制的,有一个bootstrap类装载器建立在JVM的内部,它负责装载基本的Java类库中的类。这个特殊的类装载器有一些专用的特征。首先,它只基于根类路径来装载类。因为这些是系统类所信赖的,bootstap装载器跳过那些确认为不信赖的类。
Bootstrap不仅是一个类装载器。.对于一个起动器,JVM为了从标准的Java扩展API中装载类定义了一个扩展的类装载器,并且为了从一般的类路径中(包括应用程序的类)装载类还定义了一个系统类装载器。
应用程序为了特殊的目的也可定义它们自己的类装载器(例如运行时类的重载)。这种被添加的类装载器继承于java.lang.ClassLoader类(也可能是间接的),这种方式为用一个字节数组来建立一个内部的类描述(一个java.lang.Class的实例)提供了核心的支持。
每人被创建的类都能够做到被装载它的类装载器所感知拥有。类装载器通常保持一个它们所装载的类的映射,如果这些类被再次请求,通过名字就能够找到它。
每个类装载器也保持着一个对父类类装载器的引用,以bootstrap装载器为根定义了类装载器的树。当一个特殊的类的实例被请求时(通过定义名字来请求),最初,无论哪一个类装载器处理这个请求,通常情况下,在首次尝试直接装载这个类之前,都要与它们的父类装载器进行协商。
如果有多层类装载器,就要这样递归申请,这就意味着一个类在装载它的类装载器中将是不可见的,而且对它所有的子类装载器也是不可见的。它也意味着如果在一个链中的一个类能够被多个类装载器装载,那么距这个类装载的树最上层的那个类才是实际装载这个类的类装载器。
有许多多个应用程序类装载器被Java程序使用的情况,一个例子就是在J2EE框架内,每个能框架来装载的应用程序,都需要一个独立的类装载器来防止应用程序间类的干扰。框架代码自身也会使用一个或多其它的类装载器,来防止应用程序间的冲突。一套完整的类装载器组成了一个树形结构的层次以便在不同的层次上装载不同类型的类。
装载器的树形结构
做为一个类装载器层次描述的例子,Figure 1显示了Tomcat的servlet引擎所定义的类装载器的层次结构。Common类装载器从Tomcat的安装目录中的JAR文件中装载那些打算在服务器和Web应用程序之间共享的代码。
Catalina装载器是Tomcat自已的类,Share装载器用于Web应用间共享的类。最后,每个Web应用程序都有它们自己的装载器做为它们的私有类。
Figure 1.Tomcat 类装载器
在这种环境类型下,保存所使用的正确的装载器的轨迹对于正在请求的一个新类来说可能是杂乱无章的。因为这样,所以在Java2平台中的java.lang.Thread类添加了setContextClassLoader和getContextClassLoader方法,这些方法让框架可以为每个应用程序在运行来自应用程序的代码时设置所使用的类装载器。
能够装载独立的类的集合的灵活性是Java平台的一个重要特征。尽管这个特征是有益的,但它却能在一些实例中产生混乱。其中之一就是连续的处理JVM的类路径的问题。例如,在Figure 1中所显示的Tomcat中的类装载器的层次关系中,被Common类装载器所装载的类将不能够直接被Web application装载的类来访问(通过名字)。
把这两个类装载器结合到一起的仅的方法是通过使用接口使用双方的类的集合彼此可见,在这人案例中,通过java servlets实现的java.servlet.Servlet类包含了这种方法。
当因为一些原因在类装载器之间移动代码时,就可能产生一些问题,例如,当J2SE1.4把处理XML的JAXP API移到标准的发布版中时,对于那些先前的信赖它们自己XML API的实现应用程序来说,就会产生一些问题。
在使用J2SE1.3的情况下,能够通过在用户的类路径中包含相应的JAR文件就能访问自己的API,但在J2SE1.4中,当前的APIs的标准版中这个装载器是在扩展类路径中,因些一般情况下,它将会不管出现在用户类路径的任何实现。
当使用多个类装载器时,也可能有其它类型的混乱。Figure 2显示了一个identity crisis类在一个接口和相关联的实现独立的类装载器分别装载时所出现的结果。尽管接口和类的名字以及接口是相同的,但是,但是来自于一个装载器的类的实例却不能被来自另一个装载器的正在执行的接口所承认。
这种混乱能够通过(如Figure 2中所示)把类I的接口移入System类的装载器空间来解决,虽然这样依然有两个独立的类A的实例,但是它们都实现相同的接口I。
Figure 2类identity crisis
Java类定义和JVM规范一起为运行时的代码汇编定义了一个非常强大的框架。通过使用类装载器Java应用程序能够和类的多个版本一起工作,否则就会产生冲突。类装载器的这种灵活性甚至充许在一个应用程序连续执行的时候动态的重新装载被编辑的代码。
Java平台在这方面的灵活所付出的代价是在启动应用程序时要付出更的系统开销。在应用程序(即使是最小的应用程序代码)开始执行之前,几百个独立的类需要JVM来装载。一般情况下,这种起动成本使得Java平台更加适应长时间运行的服务器类型的应用程序,而不适应于那些经常使用的小程序。
服务器应用程序也从运行时的代码汇编的灵活性中获取最大的好处,因此,Java平台成为日益流行的开发平台是不足为奇的。
在这个系列的第2部分中,将包含一个使用Java平台的动态机制基础的另一个特点的介绍:这就是Reflection API。Reflection让你的正在执行的代码访问内部的类的信息。
这种机制是创建灵活的代码的一个强大的工具,它能够在运行时没有必需的任何连接类之间的源代码的前提下把这些代码链接在一起。但是做是最有价值的工具,你需要知道什么时候和怎样来使用以获取最大的收益。
|