如今 Java 应用程序面临一个问题:它们惟一可用的容器是 Java 虚拟机(Java virtual machine,JVM)进程自身。需要多个 JVM 来彼此隔离 Java 应用程序,这会带来两个主要的负面影响。第一个是每次 JVM 调用所花费的启动时间,第二个是每个 JVM 所需的内存占用。考虑到这些开销以及不能在一个 JVM 中隔离应用程序,很显然需要进行一些基础性工作才能解决这些问题。答案就是共享类。在本文中,IBM Java Technology Center Development Team 成员 Lakshmi Shankar、Simon Burns 和 Roshan Nichani 讨论了 JVM 中共享类背后的概念、它们的工作原理以及用户如何利用这种技术。他们还讨论了这种技术的几种当前实现以及在将来会有什么样的应用。
要真正彼此隔离 Java 应用程序,实质上需要多个 JVM,然而启动成本和内存占用使这种方式不那么理想。而共享类可以同时解决这两个问题。在多 JVM 环境中,共享类通过将一组核心系统类装载到共享内存中,可以在多个 JVM 中共享这些类。这些共享类放到内存的一个共享区域中,它们在这里对所有 JVM 都是保持一致的。结果,共享类只需要在第一次使用时装载到内存中,这消除了在以后每次 JVM 调用时装载它们的固定成本,并减少了每个 JVM 中的内存占用。
IBM 在 z/OS 平台上实现了共享类技术。Apple Computer Inc. 在 Mac OS X 上实现了名为 Java Shared Archive(JSA)的一种共享类,而 Sun 在 J2SE 1.5 版中引入了基于 JSA 技术的 Class Data Sharing (CDS)。让我们分析一下这些实现是如何工作的。
IBM 的实现
自 J2SE 1.3.1 以来,IBM 就在 z/OS 平台上提供了共享类技术的实现。这种实现是通过让一个主(或称 master) JVM 将核心系统类装载到共享内存完成的,那么这到底是什么意思呢?
分解堆
内存分为共享堆和 Java 堆。主 JVM 将系统堆(即共享堆)分配到共享内存中,这里是放置系统类的地方。系统堆在主 JVM 的生存周期中一直存在,并且不会受到垃圾收集(GC)的影响。每一个后续(或者worker)JVM 附加到这个系统堆上,如图 1 所示,并为自己的 Java 堆分配非共享内存,它会受垃圾收集的影响。Java 堆包含特定于每一个 worker JVM 运行的应用程序的非共享类和所有实例化的对象。
图 1. 共享类分解堆
共享类装载器
每个 worker JVM 都可以通过将类放到共享类装载器的 classpath 中而将它们装载到共享堆中。共享类与普通类的装载方式一样??使用 parent-delegation 模式。
层次结构中的每一个类装载器检查其缓存,确定这个类是否已经装载。假如还没有装载,那么类装载器就向其父类装载器传递一个检查装载请求,这样一直上溯到层次结构顶部的 primordial 或者 bootstrap 类装载器。假如没有在任何缓冲区中发现这个类,那么每一个类装载器都会试图从自己的存储库中装载这个类,假如成功,就返回这个类。否则,它将请求传递给层次结构中下面的装载器。这种模型保证了首先检查最受信任的存储库,并防止信任程度低的代码通过采用与核心 API 成员相同的名字代替受信任的核心 API 类。
假如类是 primordial 类或者定义的类装载器是共享类装载器,那么类对象将在共享堆中创建,并且类标记为共享类。图 2 显示了 bootstrap 类装载器位于类装载器层次结构的顶部,并负责装载核心 API 中的类。这些类是信任程度最高的。扩展类装载器装载 extensions 目录中的标准扩展 JAR 文件中的类。共享应用程序类装载器可以用于共享用户或者应用程序类。
图 2. 类装载器层次结构
但是在这种实现中,类到底是如何由多个 JVM 共享的呢?
假定 JVM 1 装载了 java/lang/String,这是一个由 bootstrap 类装载器装载的系统类。假如 JVM 2 想要装载 java/lang/String,由于它不能访问 JVM 1 的 bootstrap 类装载器缓存,所以它必须使用自己的 bootstrap 类装载器重新装载这个类。在这个例子中,JVM 不共享任何类,如图 3 所示。
图 3. 类没有跨 JVM 共享
因此,最好让 JVM 共享相同的类,如图 4 所描绘的。
图 4. 跨 JVM 共享的类
要解决这个问题,通过创建一个名为 namespace 的全局类缓存,将类缓存的概念加以扩展。每个 JVM 的类装载器必须在这个 namespace 上注册。当共享类装载器装载一个类时,它被同时放到本地类缓存和 namespace 中(如图 5 所示)。这样做使得其他 JVM 中的类装载器(在 namespace 上注册的)不用装载它就可以访问这个类。
图 5. Namespace 跨 JVM 共享
保护域
类装载器有一个或者多个代码源对象(从其中装载类的 JAR 文件或者目录)。这些对象用于创建保护域,它被传递给 defineClass() 方法调用。使用共享类的其他 JVM 将需要这个信息,但是不能共享保护域,因为它们包含本地信息。为了解决这个问题,将代码源放到系统堆中。打包信息也需要共享。
竞争条件
由于 JVM 会读取和写入共享数据,需要有一种方法处理竞争条件。最简单的方式是使用全局块。不过,出于性能和伸缩性的原因,应当谨慎地使用它们。
一种避免锁住所有 JVM 的方法是使用开放式原子更新(optimistic atomic updates)。例如,在装载一个类时,类装载器将检查其 namespace (在检查其自己的本地类缓存后)。假如不能找到这个类,那么它就会装载它。装载后,它会自动检查其他 JVM 没有装载这个类,然后更新 namespace。
全局与本地数据
类中有些信息(如名字)在所有 JVM 中都是一样的,而另一些信息要求是本地的,如装载这个类的类装载器。 每个 JVM 都需要有装载这个类时需要它生成的那一部分的本地副本,如图 6 所示。JVM 中类的阴影区域是本地副本。系统中类的非阴影区域是全局部分。
图 6.共享全局数据与共享本地数据
共享类时偶然会出现的一个问题是,当一个 JVM 更新一个类时(比如通过修改静态字段),所有其他 JVM 都会看到这种改变。这种操作是不希望的,它会造成不可预料的结果。为了保证隔离性,每个 JVM 都有每个共享类的所有静态字段的副本。
JIT 编译代码
当属于共享类的代码由即时(just-in-time,JIT) 编译器编译时,它是自动共享的。这意味着不管由哪个 JVM 编译代码,所有 JVM 都会获得性能上的好处(而只有一个承担 JITing 的开销)。
启动器程序
IBM 的实现需要一个启动器(launcher),以便控制 JVM 的创建。这个启动器必须由用户以本机代码编写。如清单 1 显示了一个示例启动器的伪代码:
清单 1. 启动器伪代码
{
create Master JVM (and store returned token*);
while(work to do) {
create a Worker JVM passing in token from Master JVM;
do work on Worker JVM;
terminate Worker JVM;
}
terminate Master JVM;
}
* token returned from the Master JVM is the address of the shared heap.
Apple 的实现
Apple 的共享类技术实现是 Mac OS X 中的 Java 共享档案(Java shared archive,JSA)。
实质上,JSA 是一个内存映射到共享内存的文件,可以让多个进程(即多个 JVM )访问它。安装了 Java Runtime Environment (JRE) 后,用系统 JAR 文件中的类创建 JSA。这些核心类的内部表示存储在文档中。这个数据是静态的,因此永远也不会改变,这意味着可以共享这些类而不会有任何隔离问题。它还意味着这些类永远不会被垃圾收集。因为 Apple JVM 使用一般性 GC,因此必须保护这些类不被收集。这种保护是通过引入 immortal 对象的概念来实现的,JSA 中的所有类都指定为 immortal。
这种技术在默认情况下是可用的,使用它不需要编写任何非凡的程序??如 IBM 的实现所需要的启动器。这种实现的另一个好处是系统重启后也可以享受它的好处,不局限于特定 JVM 的生存周期。不过,它的缺点是局限于一组核心类,没有提供共享应用程序类或者 JIT 代码的能力。
Sun 的实现
Sun 的共享类技术的实现称为类数据共享(Class Data Sharing,CDS),是 J2SE 1.5 中的新功能。CDS 基于 Apple 的 JSA 实现。
与 JSA 类似, CDS 使用一个只读的内存映射文档文件。这个文件包含核心系统类的内部表示,并在启动时映射到每个 JVM 的 Java 堆中。CDS 文件既可以由 Sun JRE 安装器创建,也可以像 Sun 的 CDS 文档中所描述的那样手工创建。
同样,主要的好处是启动时的成本节省和降低内存占用。启动时间之所以减少是因为核心类不是用传统的类装载机制(即 JVM 一次映射所有核心类,而不是单独装载每一个类)装载的。所使用的内存之所以减少是因为 JVM 共享了只读类数据,而不用每一个 JVM 占有自己的副本。同时,因为类不是用传统机制装载的,所以 JVM 不会由处理任何未使用的方法。
在撰写本文的时候,Sun 的实现只限于共享核心系统类,并且不答应共享应用程序级的类或者 JIT 代码。应用程序相对于它所使用的核心类越小,启动时节省越多。
根据 Sun 的 CDS 文档,在将来共享类功能将扩展到应用程序级的类,以改进大型应用程序的启动成本。
共享类的当前应用
Java 平台的用户已经利用了共享类技术。本节描述当前部署的一些应用领域。
事务环境
共享类(不管是系统类还是应用程序类)对事务环境(如 CICS 和 DB2)中的 JVM 启动和内存占用可以有显著的效果,在这里每个事务或者应用程序都包装在单独的 JVM 中。
z/OS 2.3 上的 Customer Information Control System Transaction Server (CICS TS) 是运行 Java 事务和应用程序的主要商业产品之一(目前使用了 IBM JVM 的共享类技术)。CICS TS 2.3 引入了共享类缓存工具,它将共享类功能扩展为它所控制的 JVM 池(称为它的 JVMset)。
CICS 使用自定义启动器程序来控制 master JVM 以及所有 worker JVM 的启动,它们需要服务请求来运行 Java 组件。除了系统堆中的共享类,它们还可以共享 worker JVM 中的非凡“中间件类”和一些“应用程序类”,如图 7 所示。这个共享类缓存工具为 CICS 客户提供了很多好处。例如,Java 类是由 master JVM 装载的,每个 CICS 区域(当 CICS 启动时) 装载一次而不是每个 JVM 一次,从而在 JVMset 中减少所有 worker 的类装载成本。通过在缓冲区中保留每个类的一个副本而不是每个 worker JVM 堆一个副本,共享类缓存工具还减少了 JVMset 的总体存储需求。
图 7. CICS 中的共享类
在处理大量事务时,由更快的启动和更有效的内存处理而获得的好处会显著增加。图 8 中第一个图显示了共享和非共享 CICS 环境中一个简单事务的启动时间。第二个图显示整体 JVM 存储成本,也分为共享和非共享 CICS 环境。可以明显地看出当 JVM 的数量增加时,每个 JVM 所需要的内存减少了(与非共享比较)。
图 8. 使用共享类的 CICS 性能图
桌面系统
在 Apple Mac OS X 平台上运行的 Java 应用程序可以自动从这种技术中获得好处,Sun J2SE 1.5 用户也会从中受益。
共享类将来可能的应用
共享类技术有可能为基于 Java 平台的其他技术的用户带来极大的好处。本节重点介绍一些可能的应用。
普及环境
谈到运行 Java 应用程序,更小的内存占用对普及计算环境(如 PDA 和移动电话)会有显著的影响。这种环境是由非凡的 VM、JIT 编译器和 J2ME 中的类构成的。不过,当普及设备变得越来越普遍时,需要多 JVM 的可能性会增加。使用包含核心系统类的内存映射文件会显著节省内存,这对于普及设备是很要害的。
网格计算
在网格计算环境中,像 CPU 时间和内存容量这样的资源决定了所发生的成本,优化通常是有好处的。
使用共享类可以为网格提供者提供更好的运行 Java 程序的能力,因此它们可以同时为用户运行更多的任务。它还使客户每次使用的成本更低,这使它们具有更高的价值,为供给商提供更大的竞争优势。
Java 应用程序
对于复杂的 J2EE 应用程序,如 IBM WebSphere,可能会装载数千个类。假如核心系统类和 WebSphere 类是共享的(而不是由每个 JVM 在使用前装载),那么应用程序启动时间可以有显著缩短。这种好处加上内存占用减少可以使 WebSphere 上运行的各个应用程序受益。
对于 Java 用户,让应用程序更快地启动的同时保持更低的内存占用肯定是有好处的。假如在启动 Java 应用程序(如 Eclipse)时核心类已经装载,那么这些应用程序可以共享核心类,因而不用在启动时单独装载它们。这种方式对于使用 Swing 或者 AWT 应用程序非凡有用,这些应用程序已经由于启动缓慢和占用大量内存而受到报抱怨了。
结束语
本文提供了对 Java 共享类技术的概述和一般性介绍。我们展示了不同的共享类和它们所提供的好处,如 Java 应用程序的启动时间更快和内存占用更小。我们还分析了可以利用共享类的当前和未来的一些技术。
所有需要关注启动时间和内存占用的 Java 应用程序都可以通过共享类技术获益。当前的实现有局限性,如有限的能力或者不能共享应用程序类(或者这两者)。假如能够解决这些问题,那么更多用户会从这种技术中受益,使 Java 应用程序更有吸引力。