类和类的装载
我们来看一下类以及它们被JVM装载的时候做了些什么?
在这个新的有关动态的Java编程特征的系列文章中,将会看到在正在执行的Java应用程序的背后发生了些什么。企业级Java专家Dennis Sosnoski给出了Java二进制格式和发生在JVM内部的类中的事情。遵循这条路线,他介绍正在装载的类所影响的范围(从正在运行的一简单的Java应用程序所必须的大量的类到在J2EE和类似的复杂的框架结构中类装载器冲突所可能导致的问题)。
这篇文章揭示了Java动态编程这组主题所包含的一系列的新的知识。这些主题包括从Java二进制类文件格式的结构到使用反射访问运行时的元数据,以及所有的在运行时编辑和构造新的类的方法。贯穿这个材料的全部基本路线是Java平台的编程思想,是比用其它直接编译成本地代码的语言更加动态的工作。如果你理解了这些动态的特征,你就可用Java语言做一些用其它的主流的编程语言所不能做的事情。
在这篇文章中。我介绍了位于Java平台的动态特征之下的一些基本概念。这些概念围绕用于描述Java类的二进制格式,包括类被装载进JVM(Java虚拟机)时所发生的事情。这篇文件不仅为理解这个系列主题的其它文章提供基础,同时也演示了一些非常实际的在Java平台上工作的开发人员所关心的事情。
一个类的二进制形式
用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#语言所广泛使用的方法。不幸的是,no hook have yet been provided for making of this flexibility at the user level.
字节码和堆栈
组成类文件的可执行部分的字节码是适应特定类型计算机(JVM)是的实际的机器码,这所以叫做虚拟机是因为它是用软件来设计实现的,而不是硬件。每个运行在JVM上的应用程序都是建立在这种机器的一种实现。
虚拟机实际上相当的简单,它使用堆栈结构,这就意味着它们在被使用之前指令操作要被装载进一个内部的堆栈。指令集包括所有的一般的算术运算和逻辑操作,还有有条件和无条转移,装载/存储,调用/返回,堆栈的维护,以及几种特殊的指令类型。包括立即数的一些指令被直接编码进指令,另外一些直接从常量池引用值。
虽然虚拟机是简单的,但执行起来却不是这样的,第一代JVM基本上是虚拟机的字节码的解析器,相对而言,比较简单,但却遇到严重的性能问题―――解析代码总是要比执行本地代码花费更长的时间。为了减少这些性能问题,第二代JVM添加了即时(JIT)翻译。JIT技术是在Java字节码第一次执行之前把它编译成本地代码,从而为重复执行提供了更好的性能。当前的JVM做的更好,它使用相应的技术来监控程序的执行并且选择性使使用代码得到优化。
装载类
把源代码编译成本地代码的语言(如C和C++)在源代码被编译之后通常需要链接这样的步骤。这种链接过程把独立编译的源文件连同共享类库的代码合并到一起,从而形成一个可执行的程序。Java语言是不同的,使用Java语言,编译器生成的类文件一般情况下单独保存的,直到它们装载进一个JVM为止,即使是建立一个JAR文件也不会改变这种情况―――JAR文件只是类文件的一个容器。
优于一个分开的步骤,JVM把类装载进内存的时候,链接类成为JVM所要执行的工作的一部分。这样就可以在初始化装载的时候增加一些系统开销,但是也为Java应用程序提供了高级的灵活性。例如,应用程序可以使用直到运行时才知道的实际实现的接口来编写。这种后期绑定(late binding)的方法来装配一个应用程序在Java平台中被广泛使用,servlets就是一个普通的例子。
对于装载类的规则在JVM规范的细节中被清楚的说明了。基本原则是类只有在需要的时候才被装载(或者至少是显示的装载,JVM的这种方法在实际装载过程中有一些灵活性,但是必需保持一个固定的类初始化的顺序)。每个被装载的类可以有其它的它所依赖的类,因此装载过程是递归的。在Listing 2中的类显示了这种递归装载是怎样工作的。这个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 /us