📄 java 编程的动态性,第 1 部分:类和类装入.htm
字号:
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
...
</CODE></PRE></TD></TR></TBODY></TABLE></P>
<P><SPAN class=atitle3>二进制类文件的内幕</SPAN><BR>清单 1 显示的二进制类表示中首先是“cafe
babe”特征符,它标识 Java 二进制类格式(并顺便作为一个永久的 — 但在很大程度上未被认识到的 — 礼物送给努力工作的 <I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">barista</I>,他们本着开发人员所具备的精神构建
Java 平台)。这个特征符恰好是一种验证一个数据块<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">确实</I>声明成 Java
类格式的一个实例的简单方法。任何 Java 二进制类(甚至是文件系统中没有出现的类)都需要以这四个字节作为开始。</P>
<P>该数据的其余部分不太吸引人。该特征符之后是一对类格式版本号(本例中,是由 1.4.1 javac 生成的次版本 0 和主版本 46 —
用十六进制表示就是 0x2e),接着是常量池中项的总数。项总数(本例中,是 26,或
0x001a)后面是实际的常量池数据。这里放着类定义所用的所有常量。它包括类名和方法名、特征符以及字符串(您可以在十六进制转储右侧的文本解释中识别它们),还有各种二进制值。</P>
<P>常量池中各项的长度是可变的,每项的第一个字节标识项的类型以及对它解码的方式。这里我不详细探究所有这些内容的细节,如果感兴趣,有许多可用的的参考资料,从实际的
JVM
规范开始。关键之处在于常量池包含对该类所用的其它类和方法的所有引用,还包含了该类及其方法的实际定义。常量池往往占到二进制类大小的一半或更多,但平均下来可能要少一些。</P>
<P>常量池后面还有几项,它们引用了类本身、其超类以及接口的常量池项。这些项后面是有关字段和方法的信息,它们本身用复杂结构表示。方法的可执行代码以包含在方法定义中的<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">代码属性</I>的形式出现。用 JVM
的指令形式表示该代码,一般称为<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">字节码</I>,这是下一节要讨论的主题之一。</P>
<P>在 Java 类格式中,<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">属性</I>被用于几个已定义的用途,包括已提到的字节码、字段的常量值、异常处理以及调试信息。但是属性并非只可能用于这些用途。从一开始,JVM
规范就已经要求 JVM
忽略未知类型的属性。这一要求所带来的灵活性使得将来可以扩展属性的用法以满足其它用途,例如提供使用用户类的框架所需的元信息,这种方法在 Java
派生的 C# 语言中已广泛使用。遗憾的是,对于在用户级利用这一灵活性还没有提供任何挂钩。</P>
<P><A name=2><SPAN
class=atitle2>字节码和堆栈</SPAN></A><BR>构成类文件可执行部分的字节码实际上是针对特定类型的计算机 — JVM —
的机器码。它被称为<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">虚拟</I>机,因为它被设计成用软件来实现,而不是用硬件来实现。每个用于运行
Java 平台应用程序的 JVM 都是围绕该机器的实现而被构建的。</P>
<P>这个虚拟机实际上相当简单。它使用堆栈体系结构,这意味着在使用指令操作数之前要先将它们装入内部堆栈。指令集包含所有的常规算术和逻辑运算,以及条件转移和无条件转移、装入/存储、调用/返回、堆栈操作和几种特殊类型的指令。有些指令包含立即操作数值,它们被直接编码到指令中。其它指令直接引用常量池中的值。</P>
<P>尽管虚拟机很简单,但实现却并非如此。早期的(第一代)JVM 基本上是虚拟机字节码的解释器。这些虚拟机实际上<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">的确</I>相对简单,但存在严重的性能问题
— 解释代码的时间总是会比执行本机代码的时间长。为了减少这些性能问题,第二代 JVM 添加了<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">即时</I>(just-in-time,JIT)转换。在第一次执行
Java 字节码之前,JIT 技术将它编译成本机代码,从而对于重复执行提供了更好的性能。当代 JVM
的性能甚至还要好得多,因为使用了适应性技术来监控程序的执行并有选择地优化频繁使用的代码。</P>
<P><A name=3><SPAN class=atitle2>装入类</SPAN></A><BR>诸如 C 和 C++
这些编译成本机代码的语言通常在编译完源代码之后需要链接这个步骤。这一链接过程将来自独立编译好的各个源文件的代码和共享库代码合并起来,从而形成了一个可执行程序。Java
语言就不同。使用 Java 语言,由编译器生成的类在被装入到 JVM 之前通常保持原状。即使从类文件构建 JAR 文件也不会改变这一点 — JAR
只是类文件的容器。</P>
<P>链接类不是一个独立步骤,它是在 JVM 将这些类装入到内存时所执行作业的一部分。在最初装入类时这一步会增加一些开销,但也为 Java
应用程序提供了高度灵活性。例如,在编写应用程序以使用接口时,可以到运行时才指定其实际实现。这个用于组装应用程序的<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">后联编</I>方法广泛用于 Java
平台,servlet 就是一个常见示例。</P>
<P>JVM 规范中详细描述了装入类的规则。其基本原则是只在需要时才装入类(或者至少看上去是这样装入 — JVM
在实际装入时有一些灵活性,但必须保持固定的类初始化顺序)。每个装入的类都可能拥有其它所依赖的类,所以装入过程是递归的。清单 2
中的类显示了这一递归装入的工作方式。<CODE>Demo</CODE> 类包含一个简单的 <CODE>main</CODE> 方法,它创建了
<CODE>Greeter</CODE> 的实例,并调用 <CODE>greet</CODE> 方法。<CODE>Greeter</CODE>
构造函数创建了 <CODE>Message</CODE> 的实例,随后会在 <CODE>greet</CODE> 方法调用中使用它。</P>
<P><B>清单 2. 类装入演示的源代码</B><BR>
<TABLE cellSpacing=0 cellPadding=5 width="100%" bgColor=#cccccc
border=1><TBODY>
<TR>
<TD><PRE><CODE>
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);
}
}
</CODE></PRE></TD></TR></TBODY></TABLE></P>
<P>在 <CODE>java</CODE> 命令行上设置参数 <CODE>-verbose:class</CODE>
会打印类装入过程的跟踪记录。清单 3 显示了使用这一参数运行清单 2 程序的部分输出:</P>
<P><B>清单 3. -verbose:class 的部分输出</B><BR>
<TABLE cellSpacing=0 cellPadding=5 width="100%" bgColor=#cccccc
border=1><TBODY>
<TR>
<TD><PRE><CODE>
[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]</CODE></PRE></TD></TR></TBODY></TABLE></P>
<P>这只列出了输出中最重要的部分 — 完整的跟踪记录由 294 行组成,我删除了其中大部分,形成了这个清单。最初的一组类装入(本例中是 279
个)都是在尝试装入 <CODE>Demo</CODE> 类时触发的。这些类是每个 Java 程序(不管有多小)都要使用的核心类。即使删除
<CODE>Demo main</CODE> 方法的所有代码也不会影响这个初始的装入顺序。但是不同版本的类库所涉及的类数量和名称都不同。</P>
<P>在上面这个清单中,装入 <CODE>Demo</CODE> 类之后的部分更有趣。这里的顺序显示了只有在准备创建
<CODE>Greeter</CODE> 类的实例时才会装入该类。不过,<CODE>Greeter</CODE> 类使用了
<CODE>Message</CODE> 类的静态实例,所以在可以创建 <CODE>Greeter</CODE> 类的实例之前,还必须先装入
<CODE>Message</CODE> 类。</P>
<P>在装入并初始化类时,JVM 内部会完成许多操作,包括解码二进制类格式、检查与其它类的兼容性、验证字节码操作的顺序以及最终构造
<CODE>java.lang.Class</CODE> 实例来表示新类。这个 <CODE>Class</CODE> 对象成了 JVM
创建新类的所有实例的基础。它还是已装入类本身的标识 — 对于装入到 JVM 的同一个二进制类,可以有多个副本,每个副本都有其自己的
<CODE>Class</CODE> 实例。即使这些副本都共享同一个类名,但对 JVM 而言它们都是独立的类。</P>
<P><SPAN class=atitle3>非常规(类)路径</SPAN><BR>装入到 JVM 的类是由<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">类装入器</I>控制的。JVM
中构建了一个<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">引导程序</I>类装入器,它负责装入基本的
Java
类库类。这个特殊的类装入器有一些专门的特性。首先,它只装入在引导类路径上找到的类。因为这些是可信的系统类,所以引导程序装入器跳过了对常规(不可信)类所做的大量验证。</P>
<P>引导程序不是唯一的类装入器。对于初学者而言,JVM 为装入标准 Java 扩展 API 中的类定义了一个<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">扩展</I>类装入器,并为装入一般类路径上的类(包括应用程序类)定义了一个<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">系统</I>类装入器。应用程序还可以定义它们自己的用于特殊用途(例如运行时类的重新装入)的类装入器。这样添加的类装入器派生自
<CODE>java.lang.ClassLoader</CODE>
类(可能是间接派生的),该类对从字节数组构建内部类表示(<CODE>java.lang.Class</CODE>
实例)提供了核心支持。每个构造好的类在某种意义上是由装入它的类装入器所“拥有”。类装入器通常保留它们所装入类的映射,从而当再次请求某个类时,能通过名称找到该类。</P>
<P>每个类装入器还保留对父类装入器的引用,这样就定义了类装入器树,树根为引导程序装入器。在需要某个特定类的实例(由名称来标识)时,无论哪个类装入器最初处理该请求,在尝试直接装入该类之前,一般都会先检查其父类装入器。如果存在多层类装入器,那么会递归执行这一步,所以这意味着通常不仅在装入该类的类装入器中该类是<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">可见</I>的,而且对于所有后代类装入器也都是可见的。这还意味着如果一条链上有多个类装入器可以装入某个类,那么该树最上端的那个类装入器会是实际装入该类的类装入器。</P>
<P>在许多环境中,Java 程序会使用多个应用程序类装入器。J2EE 框架就是一个示例。该框架装入的每个 J2EE
应用程序都需要拥有一个独立的类装入器以防止一个应用程序中的类干扰其它应用程序。该框架代码本身也将使用一个或多个其它类装入器,同样用来防止对应用程序产生的或来自应用程序的干扰。整个类装入器集合形成了树状结构的层次结构,在其每个层次上都可装入不同类型的类。</P>
<P><SPAN class=atitle3>装入器树</SPAN><BR>作为类装入器层次结构的实际示例,图 1 显示了 Tomcat
servlet 引擎定义的类装入器层次结构。这里 Common 类装入器从 Tomcat 安装的某个特定目录的 JAR
文件进行装入,旨在用于在服务器和所有 Web 应用程序之间共享代码。Catalina 装入器用于装入 Tomcat 自己的类,而 Shared
装入器用于装入 Web 应用程序之间共享的类。最后,每个 Web 应用程序有自己的装入器用于其私有类。</P>
<P><B>图 1. Tomcat 类装入器</B><BR><IMG height=146 alt="Tomcat 类装入器"
src="Java 编程的动态性,第 1 部分:类和类装入.files/tomcat-loaders.gif" width=165
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"></P>
<P>在这种环境中,跟踪合适的装入器以用于请求新类会很混乱。为此,在 Java 2 平台中将
<CODE>setContextClassLoader</CODE> 方法和 <CODE>getContextClassLoader</CODE>
方法添加到了 <CODE>java.lang.Thread</CODE>
类中。这些方法允许该框架设置类装入器,使得在运行每个应用程序中的代码时可以将类装入器用于该应用程序。</P>
<P>能装入独立的类集合这一灵活性是 Java 平台的一个重要特性。尽管这个特性很有用,但是它在某些情况中会产生混淆。一个令人混淆的方面是处理
JVM 类路径这样的老问题。例如,在图 1 显示的 Tomcat 类装入器层次结构中,由 Common 类装入器装入的类决不能(根据名称)直接访问由
Web 应用程序装入的类。使这些类联系在一起的唯一方法是通过使用这两个类集都可见的接口。在这个例子中,就是包含由 Java servlet 实现的
<CODE>javax.servlet.Servlet</CODE>。</P>
<P>无论何种原因在类装入器之间移动代码时都会出现问题。例如,当 J2SE 1.4 将用于 XML 处理的 JAXP API
移到标准分发版中时,在许多环境中都产生了问题,因为这些环境中的应用程序以前是依赖于装入它们自己选择的 XML API 实现的。使用 J2SE
1.3,只要在用户类路径中包含合适的 JAR 文件就可以解决该问题。在 J2SE 1.4 中,这些 API
的标准版现在位于扩展的类路径中,所以它们通常将覆盖用户类路径中出现的任何实现。</P>
<P>使用多个类装入器还可能引起其它类型的混淆。图 2 显示了<I
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">类身份危机(class identity
crisis)</I>的示例,它是在两个独立类装入器都装入一个接口及其相关的实现时产生的危机。即使接口和类的名称和二进制实现都相同,但是来自一个装入器的类的实例不能被认为是实现了来自另一个装入器的接口。图
2 中通过将接口类 <CODE>I</CODE> 移至 System 类装入器的空间就可以解除这种混淆。类 <CODE>A</CODE>
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -