J2EE和Oracel9iAS环境中的类加载
作者:Bryan Atsatt 和 Debu Panda
在J2SE、J2EE和J2EE for OC4J中加载类的时候,要尽力消除错误和异常。
如果你曾经花费数小时的时间调试诸如ClassNotFoundException、 NoClassDefFoundError 或
者 ClassCastException的这样一些异常,那么肯定你不是唯一这样做的人。诸如
Oracle9iAS Containers for J2EE(OC4J,由Oracle9iAS提供)这样的J2EE应用服务器,提供多种不同的机制
来指定类的存放场所。这种额外的复杂性常常会导致类加载的错误--而这种错误又很难被诊断出来。
本文着重讲述在标准J2SE环境和更复杂些的J2EE环境,比如OC4J中,类加载是如何进行的。
类加载的基本原理
术语"类加载"指的是找出一个给定类名的字节所在的位置并且将这些字节转换成Java类实例的过程。Java虚拟机(JVM)中所有的java.lang.Class实例都作为一个数组开始,该数组被组织成由JVM规范定义的类文件格式。
类加载是由JVM在启动过程中执行的,紧接着由java.lang.ClassLoader类中的子类执行。这些类加载器提供一种抽象概念,使JVM不需要知道类字节的具体位置就可以将其加载,能够进行本地和远程存储,以及实现动态类生成。
更进一步来说,类加载器提供动态加载能力,这意味着给Java语言提供了极大的可扩展性能,同时,这也是Java移动代码编程的基础。除了可以加载类之外,类加载器也可以加载本地的类(比如.dll文件)。
简而言之,classloader是java.lang.ClassLoader类的子类,负责类的加载。每一个类加载器,都是为了与一个或者多个代码源
(codesource)协同工作而设计的。代码源是一个根位置,从这个位置,类加载器去寻找类。代码源被定义用来表示二进制类文件、必须先被编译的
Java源代码或者即时生成的类的物理存储位址。
在Java应用程序中,有很多使用不同机制加载类的不同类加载器。类加载器能够被设计成使类可以从如下文件中检索:
数据库:在这种情况下,配置包括在特定的数据库中指向正确表所需的所有数据。
运行专有通讯协议的远程服务器。
在一个属性文件中指定了特定搜索顺序的文件系统。
在XML文件中定义的源文件。
必须编译的源代码文件(*.java)。
类加载器名字空间(namespace)。我们注意到每一个类加载器(或者说类加载器子类的实例)都定义了唯一、独立的名字空间。对于一个名字为N的给定
类,只有一个类N的实例可以在一个给定的类加载器中存在。但是同一个类可以被两个不同的类加载器加载。在这种情况下,JVM会认为每一个类由加载它的类加
载器实例而进一步限定。实际上,类的全称由其完全的类名加上它的类加载器组成。
重复加载类会带来一些微妙的、令人沮丧的错误。比如说,如果两个不同的类加载器加载com.acme.Dynamite,那么就会有两个实例,每一个都会
拥有它自己的独立的静态数据。任何将对象从一个类转到另外一个类的尝试都会导致一个ClassCastException异常。举例来说,一个既有
Enterprise JavaBean(EJB)、又有web模块的应用程序,并且在这个EJB模块的EJBObject接口("Cart")在Web
模块WAR文件中是重复的。当Web模块中的servlet使用"Java Naming and Directory Interface"
(JNDI)来查询EJB时,在它转向Cart接口时便得到一个ClassCastException异常。
与其他的加载器不同,产生该异常的原因是Web模块的加载器要首先在本地查找。这样,它得到本地打包的Cart接口,而由JNDI从EJB加载器返回的EJB就会具有Cart接口。
各个类加载器之间的关系。类加载器以这样一种方式紧紧地被联系在一起:对于任何定位一个类的意图,都会有一个特定的类加载器序列被用来实现这一查询。每一
个加载器都有一个相关联的父类加载器。利用这种关系,一个类加载器层次体系可以表现为一种树形结构,其复杂程度不等。从简单的链表到复杂的多分支树。这些
树的一个重要特性就是,它们只能通过一种方式被查询:沿着其父辈向上查询。
这种层次结构的结果就是一种嵌套式的名字空间--在名字空间中类的可见性是由一个类加载器在该树中的位置安排所决定的:各节点沿着树"下行(down)"可以看见其每一个父节点的内容,但是父节点却不能看见其子节点的内容。
这种划分方案有时候会带来一些问题。比如说,一个框架,它动态地加载实现代码(使用Class.frName()方法),在这里,该实现预计是由第三方增
加的。通常第三方代码处于树中的位置要比框架本身低。在这种情况下,框架怎样才能扩展它的范围并获得对这个代码的可视性呢?
答案就是框架显然必须找到一个具有合适的可见性的类加载器,并利用它来加载该实现。Java2提供一个简单但是很重要的机制来处理这种情况。每个Java
线程可以存储一个独立的类加载器实例称为"相关类加载器(context classloader)",它可以通过调用方法
Thread.currentThread().getContextClassLoader()来得到。该实例可以通过该环境来设置以便提供任何需要的
可见性。缺省状态下,该相关类加载器被设置成能获得调用ClassLoader.getSystemClassLoader()的结果。
类的查询。我们已经讨论了各个类加载器之间的关系,现在让我们看看JVM实际上如何实现查询。类加载器使用一种简单的授权模型来查询类。每一个类加载器实
例都有一个相关的父类以及用于以前已加载的类的缓存。当被调用来加载一个类时,类加载器首先在其缓存中寻找,然后,如果没有找到,它就授权其父类去寻找。
如果它的父类也未能找到该类,那么该加载器就力图自己去查找该类。在类加载器树中,这是一个递归的过程,其终点是树的最高层。
查询过程的第一步就是找到一个初始的类加载器。初始的类加载器就是在执行上面所描述的查询过程时所使用的第一个加载器。注意:初始加载器并不一定是真实的加载器。初始加载器的选择是非常重要的一个步骤,它或者是隐式的或者是显示的,如下所述。
隐式的选择也是最通常的情况,并且会在下述情况中使用:
" 对象示例说明。当类A调用new操作符来产生一个B类的实例时,VM(虚拟机)就会选择A的类加载器作为初始类加载器。
" 依赖性解析。如果类A依赖于类B,那么当VM需要加载类B的时候,它就会选择A的类加载器。这个过程是递归的,因此如果B依赖于C,VM就会选择B的类加载器来加载C。注意:对于每一个类来说,可能调用不同的类加载器。
" 动态加载。Class.forName()方法超载;一个版本携带类加载器参数而另一个没有。当没有类加载器被传递过来
时,JVM(Java虚拟机)就会搜寻调用堆栈以找寻第一个非空的类加载器对象。如果什么都没有找到,则使用
ClassLoader.getSystemClassLoader()。
" 对象的无序化。ObjectInputStream.resolveClass()方法调用堆栈的方式类似于Class.forNmae()的方法。
显示的选择尽管不大普遍被采用,但是当应用程序代码使用调用如下方法所得到的结果时,这种选择也会采用:
" 一个类的实例上的getClassLoader();
" ClassLoader.getSystemClassLoader();
" Thread.currentThread().getContextClassLoader();
" 获得一个类加载器的一种应用程序特定的方法
不管是隐式选择还是显示选择,初始加载器都是非常重要,因为它决定着可见性。比如说,如果目标类或者它的所有附属类中的一个只对在该链表上或者一个不同分支中处于更低位置的加载器是可见的,那么产生异常是不可避免的。
一般说来,不太可能替换或者撤销链表中更高层次的类。大多数情况下,这是一件好事。可以想象,由于引入一个重复的被用作某些对象的超类的单独java.lang.Object类会带来多大的破坏。
除非另作说明,否则,在类加载器中的搜寻次序由规定各代码源的次序来确定。
J2SE环境中的类加载
当JVM(Java虚拟机)启动时(在JDK 1.2及其以后的所有版本中),它会形成由三个类加载器组成的初始类加载器层次结构:
引导(bootstrap,也称为原始的)加载器,它负责加载核心Java类。在Sun的Java虚拟机中,-Xbootclasspath选项或
sun.boot.class.path系统属性可被用于指定附加类。这个加载器的独特之处在于它实际上不是java.lang.ClassLoader
的子类,而是由Java虚拟机自身实现的。
扩展(extension)加载器,它负责加载来自JRE扩展目录(jre/lib/ext或者由java.ext.dirs系统属性指定的)中JAR的
类。这为引入除核心Java类以外的新功能提供了一个标准机制。在这个实例上调用方法getParent()总是返回空值,因为引导加载器不是一个真正的
ClassLoader实例。因为默认的扩展目录对所有从同一个JRE安装启用的Java虚拟机都是通用的,所以放入这个目录的JAR文件在所有过程中都
是可见的。
系统(也称为应用程序)加载器,它负责在Java虚拟机被调用时,加载列来自在命令行和/或java.class.path系统属性中的目录和JAR的
类。总能通过静态方法ClassLoader.getSystemClassLoader()找到该加载器。如果没有特别指定,则用户规定的任何加载器都
将该加载器作为它的父加载器。
JDK 1.4增加了一个超越人们认可的各种标准的机制,这些标准不是由Java Community Process (JCP)定义的,而是随
JRE(Java运行时环境)如CORBA一起提供的。这一特性使这些标准的新版本能够替换那些随JRE一起提供的标准。支持这一特性的机器是在VM/引
导加载器中实现的。
下面是另外几个你应该了解的与标准J2SE环境相关的类加载问题。
相关性声明。在JDK 1.2以前的版本中,没有表示一个JAR文件中的类与另一个JAR文件中的各个类之间的相关性的机制。用户必须知道JAR文件包括那些类,并把它们列入类路径中--这通常会导致错误。
Java2中增加了一个新的普通配置机制,它允许JAR文件声明其与其他JAR文件的相关性。该机制是作为捆绑的可选包提供的。还增加了一个新的加载清单
(manifest)属性--类路径(Class-Path),它的值是一个由空格隔开的目录列表和/或JAR文件路径--与JAR文件位置相关的路径。
接受JAR文件的类加载器支持这一特性。
这一特性的一个普通而重要的用途在于:能够使用-jar VM选项执行应用程序。该加载清单属性允许JAR定义它自己的类路径,使用户不再需要通过-cp或-classpath选项声明它。
JDK 1.3引入了主要用于applet的另一个机制,它允许一个JAR声明它与JAR特定版本的相关性,该机制是作为安装可选包提供的。这是对通常安
装在jre/lib/ext目录下的基本JDK功能的扩展。对于在Java插件环境中执行的applet来说,如果JAR不存在,则该机制能够提供可以从
中找到和安装JAR的URL。
相关性解析(Dependency-resolution)问题。类加载功能可以由Java虚拟机自身(例如,使用新运算符的对象示例说明)或应用程序代码即调用Class. forName()来启动。然而,一种特殊情况值得特别注意:相关性解析。
类包括对与其相关的其他类的引用,其中可能有些类未被加载。虽然其中有些引用被延期到方法执行期间的第一次使用时,但这些引用的解析过程都在类加载期间进行。这一缓慢加载缩短了启动时间,但在有些没有预料到的时候会导致错误。
安全性。Java2安全性基于对特定代码授予或拒绝授予特性权限这样的概念,其中粒度单位是URL形式的代码源。类加载器在形成类实例与其代码源的关联中扮演着重要的角色,因此在安全性方面具有至关重要的作用。
J2EE 环境中的类加载
J2EE 环境增加了影响类加载行为的复杂性。尤其是,J2EE规范以一种灵活的方式定义了为类加载增加重要元素的"应用程序"。应用程序是基于组件的、共存但是独立的、可重新加载的。
基于组件。应用程序不是整体式的:而是多个组件(例如,EJB和servlet)的集合,这些组件有预定义的打包(使用JAR,、WAR和
RAR [Resource Adapter Archive]文件)和部署目录结构,以及一个伞状打包结构(EAR文件)。所有这些结构都包含类加
载。
在一个EAR文件中, META-INF/application.xml 文件包括所包含的到每个模块的相关路径。根据打包情况,每个模块是一个预定义的代码源或包括一些预定义的代码源:
JAR 文件(EJB和应用程序-客户端模块)。代码源包括:
" 1. JAR 文件自身。
" 2. META-INF/manifest.mf文件中的所有类路径项,如果存在的话。
" 3. 2中所有JAR的所有加载清单类路径项。
WAR 文件(Web模块)。代码源包括:
" 1. WEB-INF/ classes目录。
" 2. WEB-INF/lib目录中的所有JAR 文件,如果存在的话。
" 3. META-INF/manifest.mf文件中的所有类路径项,如果存在的话。
" 4. 2和3中所有JAR中的所有加载清单类路径项。
RAR 文件。代码源包括:
" 1. 所有的JAR 文件(任何层次上的)。
" 2. 所有JAR中的加载清单类路径项。
注意:EAR文件自身根目录中的任何加载清单文件(和任何类路径项)均被忽略。
共存但是独立的。许多应用程序能够在一个应用服务器中并行存在,但每个应用程序必须在自己独立的名字空间中生存。实际上,这意味着每个应用程序必须由(一个或多个)独立的类加载器实例加载。
是可重加载的。不需停止服务器,便能够重新加载应用程序。对支持这一特性的服务器(例如OC4J)来说,用于给定应用程序的类加载器可能被摒弃和重新被例示说明。这种行为会产生"相同类,不同加载器"的情况,这会导致错综复杂的类加载问题。
模块间的相关性。J2EE设计上的内在特性是servlets和JSP调用 EJB的能力,以及这三者使用资源适配器的能力。应用程序中的各个Web模块
需要互相独立。在EJB 1.x中,只有客户机元素必须是可见的(即,home/remote接口与它们的相关性)。而现在在EJB 2.x
中,local(本地)接口增加了实现类也是可见的这一要求。
J2EE服务器必须为给定的应用程序安排类加载器,以确保每个模块都有满足这些要求的正确可见度。每个加载器都应该包括在各自包中定义的所有代码源。该结构应该确保加载在每个Web模块中的类都是独立的,同时提供每个模块对EJB和资源适配器类的可见性。
应用程序之间的相关性。一般来说,应用程序之间的相关性应该减至最小,J2EE 1.3中没有满足这一点的可用机制。需要相关性的时候,可以从两个方面考虑,这取决于你希望共享代码对所有应用程序是可见的,还是只对某些应用程序可见:
" 对所有应用程序。为了使共享代码对所有应用程序是可见的,需要把这些共享代码放入JRE扩展目录(当启用服务器时,通过-classpath包括它们),或者使用供应商特定的机制。在这一级别上修改代码可能需要重启服务器。
" 对某些应用程序。 要使共享代码只对某些应用程序是可见的,需要在每个应用程序里复制这些共享代码,或者使用供应商特定的配置选项。这儿
需要考虑的一个重要事项是:一个共享类的一个对象是否必须对这些应用程序是可见的。如果是,则复制这些共享程序是无效的,因为该类被复制,而如前所述,虚
拟机认为它们是不同的类。
选择"对某些应用程序是可见的"是很不理想的。因为复制增加了风险和内存占用,还会导致版本问题;而依赖于供应商特定的机制则降低了可移植性。在容器级别上部署是一个选择,但可能导致这些共享代码可见度过高,还可能会限制应用程序重新加载。
一个更好的解决方法是允许其代码遵循以下规则的各应用程序间共享这些代码:
" 1. 它是经过标准化的。
" 2. 它可消除了复制。
" 3. 它仅对应用程序的特定子集是可见的。
" 4. 它完全支持应用程序重新加载。
Web模块内的查找顺序。Servlet 2.3规范要求Web模块内的查找顺序先是WEB-INF/ classes/,再是WEB-INF/lib
/。另外,它还建议改变标准类加载器查找顺序:Web应用程序加载器不应该先查找父链(parent chain),而是应该先从本地查找。这允许打包在
WAR文件中的类能够覆盖安装在更高级加载器链中的代码。
Oracle9iAS (OC4J)中的类加载
像所有的应用服务器一样,Oracle9iAS Containers for J2EE (OC4J)必须扩展J2SE的类加载功能,以便支持J2EE的要求。这部分内容详细讲述OC4J中的类加载。
像在所有的应用服务器里一样,OC4J依赖不属于J2EE规范的各种配置文件。除了控制OC4J的其他特性外,还有两个选项在与类加载相关的这些配置文件中可用,这两个选项都支持附加代码源的规范。因为这些文件是XML格式的,所以这两个类加载选项采用XML标记格式:
"
。一个以分号隔开的目录列表、JAR 文件或者 zip files.Paths,它们之间可以是相关的,或是独立的。
" 。与一样,只是目录被特别对待:包含在路径属性里所列的一个目录中的任何JAR文件或zip文件都被包括在内。
两种类型的配置文件支持这些标记:
1. 服务器范围的。这些文件通常存在于config目录中,并影响所有的应用程序:
" application.xml。支持标记。列在这里的代码源对所有的应用程序都是可见的。
" globabl-web-application.xml。支持 标记。列在这里的代码源被添加到所有的Web应用程序,对该层以上的所
有层都是不可见的。注意:即使每个Web应用程序都使用相同的路径来引用这些代码源,使用这个标记仍会导致类重复,因为每个Web应用程序都有它自己的类
加载器。
2. 应用程序特定的。这些文件与EAR和WAR文件里的标准部署描述符文件存放在一起,并扩展了它们的功能:
" orion-application.xml。扩展application.xml,并支持标记。列在这里的代码源对一个应用程序里的所有模块均是可见的。
" orion-web-xml。扩展web.xml,并支持标记。列在这里的代码源仅对该Web应用程序是可见的。
图1:典型的类加载器树
图1用以下这些加载器显示了一个OC4J实例里的典型类加载器树:
" 系统加载器 包括OC4J和J2EE API类。
" 全局连接器加载器(Global Connectors Loader)包括在全局oc4j-connectors.xml中引用的RAR文件中的所有代码源。
" 全局应用程序加载器(Global Application Loader)包括全局application.xml文件中的任何标记的所有代码源。
" 连接器加载器(Connectors Loader)包括任何应用程序RAR文件中的所有代码源。
" EJB/库加载器(EJB/Libraries Loader)包括application.xml中的任何标记和orion-application.xml文件中的任何标记中的所有代码源。
" Web 应用程序加载器(Web Application Loader) 包括任何WAR文件、orion-web.xml文件的标记以及global-web-application.xml文件的标记中的所有代码源。
应用程序间的共享。OC4J提供一个支持"对一些应用程序"(上面所讨论过的)是可见的机制,在这种机制中,类需要在无需复制的情况下,被两个或更多的应
用程序共享。该机制简单而又灵活:任何应用程序都可将另一应用程序声明为它的父亲。然后,OC4J将父应用程序的一个类加载器安排为孩子应用程序中最高级
加载器的父亲。"父亲"的声明包括在server.xml文件的标记中。例如,如下所示:
J2EE中类加载的共同问题
J2EE中的绝大多数类加载问题与可见性有关,表现为以下几种异常之一。
没有足够的可见性。这种状况发生在所需的类在当前范围内不可见时。这种问题表现为以下几种情况之一:
" ClassNotFoundException。这种异常发生在通过显式加载一个类(例如, Class.forName() 或者ClassLoader.loadClass())的任何方法进行动态加载期间。
" NoClassDefFoundException。这个常见异常出现在代码试图使用新的操作符例示说明一个对象,或先前加载的类的相关性不能被解析时。后一种情况经常被缓慢加载和隐藏的相关性所隐匿。
太大的可见性。这类问题发生在一个类被复制时,表现为:
" ClassCastException。这种异常发生的原因往往是简单而明显的(例如,将一个数据从整型数转换为字符串型)。然而,在"相同的类,不同的加载器"的情况下,源类型和目标类型有相同类名这一事实是令人吃惊和灰心丧气的。
类复制往往发生在以下两种情况下:
通过将相关的JAR文件拷贝到每一个应用程序中,来管理应用程序之间相关性。
当一个应用程序被重新加载时,容器创建一个新的类加载器,并使用它来重新加载应用程序类。然而,在这种情况下,原始类仍然可以被访问。
一般来说,重复类的存在不是一个问题(只是多占用点内存)。为了使一个异常发生,在一个名字空间(类加载器)中创建的实例必须被传递到另一个名字空间中。这可以通过对两个名字空间都是可见的任何储存设备来实现,包括:
静态域
全局集合
JNDI
一些JNDI的实现没有对那些在本地绑定和检索的对象进行串行化。这样,当同一个Java虚拟机里的不同应用程序存储和检索一个对象时,那个实例即被共享,同时发生重复类问题。
被破坏的可见性。这类情况虽然很少发生,但是它表明发生了严重问题。所有这类问题都被统称为错误。
" IncompatibleClassChangeError。这个错误说明一个超类或接口被改变。
" ClassCircularityError。这个错误说明当前类作为它自己的一个超类或超接口存在。
" UnsupportedClassVersionError。这个错误往往发生在加载一个在较近期的JDK版本中编译、而不是在其中运行的一个版本中编译的类时。
" VerifyError。这个错误发生在类中的代码违反JVM规定的某一项约束时。
" ClassFormatError。这个错误说明类文件格式是无效的,这通常是由讹误造成的。
结论
以下对类加载的原则进行一个概括:
" 声明相关性。使相关性清楚明白。在将你的应用程序迁移到另外一个环境中时,隐蔽的或未知的相关性将会被遗留下来。
" 对相关性进行分组。 确保所有的相关性在同一级别或更高级别上是可见的。如果你必须移动一个库,则请确保所有的相关性仍然是可见的。
" 最小化可见性。相关性库应该被放在满足所有相关性的最低可见性级别上。
" 共享库。避免复制库。使用父属性来在整个应用程序集中共享类。采用application.xml全局文件中的标记在所有应用程序之间共享类。
" 保持配置是可移植的。按以下的顺序选择配置选项:
标准J2EE选项;
能够在你的EAR文件中表示的选项;
服务器级别的选项;
J2SE扩充选项。
使用正确的加载器。如果你调用Class.forName(),那么请总是显示地传递由
Thread.currentThread().getContextClassLoader返回的加载器。如果你正在加载一个属性文件,则请使用
Thread.currentThread().getContextClassLoader().getResourceAsStream()。
我们希望你读完这篇文章后,觉得受益匪浅,并开始颇有成效地在J2EE应用程序中应用这些独到见解。
Bryan Atsatt 是Oracle Java Platform组技术团队中的一名咨询成员。 Debu Panda (debabrata.panda@oracle.com) 是负责Oracle9iAS产品管理的主要产品经理。