Tomcat:正统的类加载器架构

主流的Java Web服务器,如Tomcat、Jetty、WebLogic、WebSphere或其他笔者没有列举的服务器,

都实现了自己定义的类加载器,而且一般还都不止一个。因为一个功能健全的Web服务器,都要解决

如下的这些问题:

· 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的

需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求每个类库在一个服务

器中只能有一份,服务器应当能够保证两个独立应用程序的类库可以互相独立使用。

· 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求与前面一

点正好相反,但是也很常见,例如用户可能有10个使用Spring组织的应用程序部署在同一台服务器

上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒

不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟

机的方法区就会很容易出现过度膨胀的风险。

· 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。目前,有许多主流的Java

Web服务器自身也是使用Java语言来实现的。因此服务器本身也有类库依赖的问题,一般来说,基于安

全考虑,服务器所使用的类库应该与应用程序的类库互相独立。

·支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能。我们知道JSP文件最终要被编译

成Java的Class文件才能被虚拟机执行,但JSP文件由于其纯文本存储的特性,被运行时修改的概率远大

于第三方类库或程序自己的Class文件。而且ASP、PHP和JSP这些网页应用也把修改后无须重启作为一

个很大的“优势”来看待,因此“主流”的Web服务器都会支持JSP生成类的热替换。


由于存在上述问题,在部署Web应用时,单独的一个ClassPath就不能满足需求了,所以各种Web服

务器都不约而同地提供了好几个有着不同含义的ClassPath路径供用户存放第三方类库,这些路径一般

会以“lib”或“classes”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常每一

个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。

在Tomcat目录结构中,可以设置3组目录(/common/*、/server/*和/shared/*,但默认不一定是开放

的,可能只有/lib/*目录存在)用于存放Java类库,另外还应该加上Web应用程序自身的“/WEB

INF/*”目录,一共4组。把Java类库放置在这4组目录中,每一组都有独立的含义,分别是:

·放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。

·放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。

·放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。

·放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现。

灰色背景的3个类加载器是JDK(以JDK 9之前经典的三层类加载器为例)默认提供的类加载器,

这3个加载器的作用在第7章中已经介绍过了。而Common类加载器、Catalina类加载器(也称为Server类

加载器)、Shared类加载器和Webapp类加载器则是Tomcat自己定义的类加载器,它们分别加

载/common/*、/server/*、/shared/*和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和JSP类

加载器通常还会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应

一个JasperLoader类加载器。

Common类加载器能加载的类都可以被Catalina类加载器和Shared

类加载器使用,而Catalina类加载器和Shared类加载器自己能加载的类则与对方相互隔离。WebApp类

加载器可以使用Shared类加载器加载到的类,但各个WebApp类加载器实例之间相互隔离。而

JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class文件,它存在的目的就是为了被

丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新

的JSP类加载器来实现JSP文件的HotSwap功能。

本例中的类加载结构在Tomcat 6以前是它默认的类加载器结构,在Tomcat 6及之后的版本简化了默

认的目录结构,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader项后才会

真正建立Catalina类加载器和Shared类加载器的实例,否则会用到这两个类加载器的地方都会用

Common类加载器的实例代替,而默认的配置文件中并没有设置这两个loader项,所以Tomcat 6之后也

顺理成章地把/common、/server和/shared这3个目录默认合并到一起变成1个/lib目录,这个目录里的类库

相当于以前/common目录中类库的作用,是Tomcat的开发团队为了简化大多数的部署场景所做的一项

易用性改进。如果默认设置不能满足需要,用户可以通过修改配置文件指定server.loader和share.loader

的方式重新启用原来完整的加载器架构。

OSGi:灵活的类加载器架构

OSGi中的每个模块(称为Bundle)与普通的Java类库区别并不太大,两者一般都以JAR格式进行

封装[2],并且内部存储的都是Java的Package和Class。但是一个Bundle可以声明它所依赖的Package(通

过Import-Package描述),也可以声明它允许导出发布的Package(通过Export-Package描述)。在OSGi

里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库

的可见性能得到非常精确的控制,一个模块里只有被Export过的Package才可能被外界访问,其他的

Package和Class将会被隐藏起来。

以上这些静态的模块化特性原本也是OSGi的核心需求之一,不过它和后来出现的Java的模块化系

统互相重叠了,所以OSGi现在着重向动态模块化系统的方向发展。在今天,通常引入OSGi的主要理由

是基于OSGi架构的程序很可能(只是很可能,并不是一定会,需要考虑热插拔后的内存管理、上下文

状态维护问题等复杂因素)会实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停

用、重新安装然后启用程序的其中一部分,这对大型软件、企业级程序开发来说是一个非常有诱惑力

的特性,譬如Eclipse中安装、卸载、更新插件而不需要重启动,就使用到了这种特性。

OSGi之所以能有上述诱人的特点,必须要归功于它灵活的类加载器架构。OSGi的Bundle类加载器

之间只有规则,没有固定的委派关系。例如,某个Bundle声明了一个它依赖的Package,如果有其他

Bundle声明了发布这个Package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类

加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某

个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖。

另外,一个Bundle类加载器为其他Bundle提供服务时,会根据Export-Package列表严格控制访问范

围。如果一个类存在于Bundle的类库中但是没有被Export,那么这个Bundle的类加载器能找到这个类,

但不会提供给其他Bundle使用,而且OSGi框架也不会把其他Bundle的类加载请求分配给这个Bundle来

处理。

假设存在Bundle A、Bundle B、BundleC3个模块,并且这3个Bundle定义的依赖关系如下所示。

·Bundle A:声明发布了packageA,依赖了java.*的包;

·Bundle B:声明依赖了packageA和packageC,同时也依赖了java.*的包;

·Bundle C:声明发布了packageC,依赖了packageA。

类加载时可能进

行的查找规则如下:

·以java.*开头的类,委派给父类加载器加载。

·否则,委派列表名单内的类,委派给父类加载器加载。·否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载。

·否则,查找当前Bundle的Classpath,使用自己的类加载器加载。

·否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。

·否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。

·否则,类查找失败。

在OSGi中,加载器之间的关系不再是双亲委派模型的树形结构,而是已

经进一步发展成一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更优

秀的灵活性的同时,也可能会产生许多新的隐患。笔者曾经参与过将一个非OSGi的大型系统向Equinox

OSGi平台迁移的项目,由于项目规模和历史原因,代码模块之间的依赖关系错综复杂,勉强分离出各

个模块的Bundle后,发现在高并发环境下经常出现死锁。我们很容易就找到了死锁的原因:如果出现

了Bundle A依赖Bundle B的Package B,而Bundle B又依赖了Bundle A的Package A,这两个Bundle进行类

加载时就有很高的概率发生死锁。具体情况是当Bundle A加载Package B的类时,首先需要锁定当前类

加载器的实例对象(java.lang.ClassLoader.loadClass()是一个同步方法),然后把请求委派给Bundle B的

加载器处理,但如果这时Bundle B也正好想加载Package A的类,它会先锁定自己的加载器再去请求

Bundle A的加载器处理,这样两个加载器都在等待对方处理自己的请求,而对方处理完之前自己又一

直处于同步锁定的状态,因此它们就互相死锁,永远无法完成加载请求了。Equinox的Bug List中有不

少关于这类问题的Bug [3],也提供了一个以牺牲性能为代价的解决方案——用户可以启用

osgi.classloader.singleThreadLoads参数来按单线程串行化的方式强制进行类加载动作。在JDK 7时才终于

出现了JDK层面的解决方案,类加载器架构进行了一次专门的升级,在ClassLoader中增加了

registerAsParallelCapable方法对可并行的类加载进行注册声明,把锁的级别从ClassLoader对象本身,降

低为要加载的类名这个级别,目的是从底层避免以上这类死锁出现的可能。

字节码生成技术与动态代理的实现

动态代理中所说的“动态”,是针对使用Java代码实

际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点编码工作量,而是实

现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系

后,就可以很灵活地重用于不同的应用场景之中。

public class DynamicProxyTest {
	interface IHello {
		void sayHello();
	}
	static class Hello implements IHello {
		@Override
		public void sayHello() {
			System.out.println("hello world");
		}
	}
	static class DynamicProxy implements InvocationHandler {
		Object originalObj;
		Object bind(Object originalObj) {
			this.originalObj = originalObj;
			return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), 	originalObj.getClass().getInterfaces(), this);
		}
		@Override
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable 		{
			System.out.println("welcome");
			return method.invoke(originalObj, args);
		}
	}
	public static void main(String[] args) {
		IHello hello = (IHello) new DynamicProxy().bind(new Hello());
		hello.sayHello();
	}
}


运行结果如下:
welcome
hello world

在上述代码里,唯一的“黑匣子”就是Proxy::newProxyInstance()方法,除此之外再没有任何特殊之

处。这个方法返回一个实现了IHello的接口,并且代理了new Hello()实例行为的对象。跟踪这个方法的

源码,可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤

并不是我们关注的重点,这里只分析它最后调用sun.misc.ProxyGenerator::generateProxyClass()方法来完

成生成字节码的动作,这个方法会在运行时产生一个描述代理类的字节码byte[]数组。如果想看一看这

个在运行时产生的代理类中写了些什么,可以在main()方法中加入下面这句:

System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

加入这句代码后再次运行程序,磁盘中将会产生一个名为“$Proxy0.class”的代理类Class文件,反

编译后可以看见如代码:

package org.fenixsoft.bytecode;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
public final class $Proxy0 extends Proxy
	implements DynamicProxyTest.IHello
	{
	private static Method m3;
	private static Method m1;
	private static Method m0;
	private static Method m2;

	public $Proxy0(InvocationHandler paramInvocationHandler)
	throws
	{
		super(paramInvocationHandler);
	}

	public final void sayHello()
	throws
	{
		try
		{
			this.h.invoke(this, m3, null);
			return;
		}
		catch (RuntimeException localRuntimeException)
		{
			throw localRuntimeException;
		}
		catch (Throwable localThrowable)
		{
			throw new UndeclaredThrowableException(localThrowable);
		}
	}
	// 此处由于版面原因,省略equals()、hashCode()、toString()3个方法的代码
	// 这3个方法的内容与sayHello()非常相似。
	static
	{
		try
		{
			m3 = Class.forName("org.fenixsoft.bytecode.DynamicProxyTest$IHello").getMethod("sayHello", new Class[0]);
			m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[] { Class.forName("java.lang.Object") });
			m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
			m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
			return;
		}
		catch (NoSuchMethodException localNoSuchMethodException)
		{
			throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
		}
		catch (ClassNotFoundException localClassNotFoundException)
		{
			throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
		}
	}
}

这个代理类的实现代码也很简单,它为传入接口中的每一个方法,以及从java.lang.Object中继承来

的equals()、hashCode()、toString()方法都生成了对应的实现,并且统一调用了InvocationHandler对象的

invoke()方法(代码中的“this.h”就是父类Proxy中保存的InvocationHandler实例变量)来实现这些方法的

内容,各个方法的区别不过是传入的参数和Method对象有所不同而已,所以无论调用动态代理的哪一

个方法,实际上都是在执行InvocationHandler::invoke()中的代理逻辑。

这个例子中并没有讲到generateProxyClass()方法具体是如何产生代理类“$Proxy0.class”的字节码

的,大致的生成过程其实就是根据Class文件的格式规范去拼装字节码,但是在实际开发中,以字节为

单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用

户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。

从字节码的角度来看,枚举仅仅是一个继承于java.lang.Enum、自动生成了values()和valueOf()方法的普通Java类而已。