9.2.2 OSGi:灵活的类加载器架构

9.2.2 OSGi:灵活的类加载器架构

曾经在Java程序社区中流传着这么一个观点:“学习Java EE规范,推荐去看JBoss源码;学习类加载器的知识,就推荐去看OSGi源码。”尽管“Java EE规范”和“类加载器的知识”并不是一个对等的概念,不过,既然这个观点能在部分程序员群体中流传开来,也从侧面说明了OSGi对类加载器的运用确实有其独到之处。

OSGi^1(Open Service Gateway Initiative)是OSGi联盟(OSGi Alliance)制订的一个基于Java语言 的动态模块化规范(在JDK 9引入的JPMS是静态的模块系统),这个规范最初由IBM、爱立信等公司 联合发起,在早期连Sun公司都有参与。目的是使服务提供商通过住宅网关为各种家用智能设备提供服 务,后来这个规范在Java的其他技术领域也有相当不错的发展,现在已经成为Java世界中“事实上”的动 态模块化标准,并且已经有了Equinox、Felix等成熟的实现。根据OSGi联盟主页上的宣传资料,OSGi 现在的重点应用在智慧城市、智慧农业、工业4.0这些地方,而在传统Java程序员中最知名的应用案例 可能就数Eclipse IDE了,另外,还有许多大型的软件平台和中间件服务器都基于或声明将会基于OSGi 规范来实现,如IBM Jazz平台、GlassFish服务器、JBoss 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。

那么,这3个Bundle之间的类加载器及父类加载器之间的关系如图9-2所示。

image-20211125160302086

图9-2 OSGi的类加载器架构

由于没有涉及具体的OSGi实现,图9-2中的类加载器都没有指明具体的加载器实现,它只是一个体现了加载器之间关系的概念模型,并且只是体现了OSGi中最简单的加载器委派关系。一般来说,在OSGi里,加载一个类可能发生的查找行为和委派关系会远远比图9-2中显示的复杂,类加载时可能进行的查找规则如下:

  • 以java.*开头的类,委派给父类加载器加载。
  • 否则,委派列表名单内的类,委派给父类加载器加载。
  • 否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
  • 否则,查找当前Bundle的Classpath,使用自己的类加载器加载。
  • 否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。
  • 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
  • 否则,类查找失败。

从图9-2中还可以看出,在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对象本身,降低为要加载的类名这个级别,目的是从底层避免以上这类死锁出现的可能。

总体来说,OSGi描绘了一个很美好的模块化开发的目标,而且定义了实现这个目标所需的各种服务,同时也有成熟框架对其提供实现支持。对于单个虚拟机下的应用,从开发初期就建立在OSGi上是一个很不错的选择,这样便于约束依赖。但并非所有的应用都适合采用OSGi作为基础架构,OSGi在提供强大功能的同时,也引入了额外而且非常高的复杂度,带来了额外的风险。

[^2]: OSGi R7开始支持JDK 9的JPMS,但只是兼容意义上的支持,并未将两者重合的特性互相融合。譬 如在R7中Bundle仍然是一个标准的JAR包,未封装成Module(即以Unnamed Module的形式存在)。
[^3]: Bug-121737:https://bugs.eclipse.org/bugs/show_bug.cgi?id=121737。