#|第9章 类加载及执行子系统的案例与实战

【#|第9章 类加载及执行子系统的案例与实战】book:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》

文章目录

    • 9.1 概述
    • 9.2 案例分析
      • 9.2.1 Tomcat:正统的类加载器架构
      • 9.2.2 OSGi:灵活的类加载器架构
      • 9.2.3 字节码生成技术与动态代理的实现
      • 9.2.4 Backport工具:Java的时光机器
    • 9.3 实战:自己动手实现远程执行功能

9.1 概述 在Class文件格式与执行引擎这部分里,用户的程序能直接参与的内容并不太多,Class文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能。
9.2 案例分析 比较经典,所以记录一下。
9.2.1 Tomcat:正统的类加载器架构
主流的Java Web服务器,如Tomcat、Jetty、WebLogic、WebSphere或其他笔者没有列举的服务器,都实现了自己定义的类加载器,而且一般还都不止一个。因为一个功能健全的Web服务器,都要解决如下的这些问题:
  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的需求,比如两个不同的应用程序可能会依赖同一个第三方类库的不同版本。
  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。例如用户可能有10个使用Spring组织的应用程序部署在同一台服务器上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费。
  • 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。许多主流的Java Web服务器自身也是使用Java语言来实现的。因此服务器本身也有类库依赖的问题,一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
  • 支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能。热部署。
在Tomcat目录结构中,可以设置3组目录(/common/、/server/和/shared/,但默认不一定是开放的,可能只有/lib/目录存在)用于存放Java类库,另外还应该加上Web应用程序自身的“/WEBINF/*”目录,一共4组。把Java类库放置在这4组目录中,每一组都有独立的含义,分别是:
  • 放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。
  • 放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。
  • 放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
  • 放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如下图所示。
#|第9章 类加载及执行子系统的案例与实战
文章图片

灰色背景的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的方式重新启用原来完整的加载器架构。
9.2.2 OSGi:灵活的类加载器架构
OSGi中的每个模块(称为Bundle)与普通的Java类库区别并不太大,两者一般都以JAR格式进行封装,并且内部存储的都是Java的Package和Class。但是一个Bundle可以声明它所依赖的Package(通过Import-Package描述),也可以声明它允许导出发布的Package(通过Export-Package描述)。在OSGi里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库的可见性能得到非常精确的控制,一个模块里只有被Export过的Package才可能被外界访问,其他的Package和Class将会被隐藏起来。
在今天,通常引入OSGi的主要理由是基于OSGi架构的程序很可能(只是很可能,并不是一定会,需要考虑热插拔后的内存管理、上下文状态维护问题等复杂因素)会实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停用、重新安装然后启用程序的其中一部分。
OSGi之所以能有上述诱人的特点,必须要归功于它灵活的类加载器架构。OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。例如,某个Bundle声明了一个它依赖的Package,如果有其他Bundle声明了发布这个Package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖。
可以举一个更具体些的简单例子来解释上面的规则,假设存在Bundle A、Bundle B、BundleC3个模块,并且这3个Bundle定义的依赖关系如下所示。
  • Bundle A:声明发布了packageA,依赖了java.*的包;
  • Bundle B:声明依赖了packageA和packageC,同时也依赖了java.*的包;
  • Bundle C:声明发布了packageC,依赖了packageA。
    那么,这3个Bundle之间的类加载器及父类加载器之间的关系如下图所示。
    #|第9章 类加载及执行子系统的案例与实战
    文章图片
由于没有涉及具体的OSGi实现,图9-2中的类加载器都没有指明具体的加载器实现,它只是一个体现了加载器之间关系的概念模型,并且只是体现了OSGi中最简单的加载器委派关系。一般来说,在OSGi里,加载一个类可能发生的查找行为和委派关系会远远比图9-2中显示的复杂,类加载时可能进行的查找规则如下:
  • 以java.*开头的类,委派给父类加载器加载。
  • 否则,委派列表名单内的类,委派给父类加载器加载。
  • 否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
  • 否则,查找当前Bundle的Classpath,使用自己的类加载器加载。
  • 否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。
  • 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
  • 否则,类查找失败。
在OSGi中,加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成一种更为复杂的、运行时才能确定的网状结构。总体来说,OSGi描绘了一个很美好的模块化开发的目标,而且定义了实现这个目标所需的各种服务,同时也有成熟框架对其提供实现支持。对于单个虚拟机下的应用,从开发初期就建立在OSGi上是一个很不错的选择,这样便于约束依赖。但并非所有的应用都适合采用OSGi作为基础架构,OSGi在提供强大功能的同时,也引入了额外而且非常高的复杂度,带来了额外的风险。
9.2.3 字节码生成技术与动态代理的实现
我们选择其中相对简单的动态代理技术来讲解字节码生成技术是如何影响程序运作的。
许多Java开发人员都使用过动态代理,即使没有直接使用过java.lang.reflect.Proxy或实现过java.lang.reflect.InvocationHandler接口,应该也用过Spring来做过Bean的组织管理。如果使用过Spring,那大多数情况应该已经不知不觉地用到动态代理了,因为如果Bean是面向接口编程,那么在Spring内部都是通过动态代理的方式来对Bean进行增强的。动态代理中所说的“动态”,是针对使用Java代码实际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点编码工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。
9.2.4 Backport工具:Java的时光机器
Java世界里,每一次JDK大版本的发布,都会伴随着规模不等或大或小的技术革新,而对Java程序编写习惯改变最大的,肯定是那些对Java语法做出重大改变的版本,譬如JDK 5时加入的自动装箱、泛型、动态注解、枚举、变长参数、遍历循环(foreach循环);譬如JDK 8时加入的Lambda表达式、Stream API、接口默认方法等。
JDK的每次升级新增的功能大致可以分为以下五类:
  • 对Java类库API的代码增强。
  • 在前端编译器层面做的改进。这种改进被称作语法糖,如自动装箱拆箱。
  • 需要在字节码中进行支持的改动。如JDK 7里面新加入的语法特性——动态语言支持,就需要在虚拟机中新增一条invokedynamic字节码指令来实现相关的调用功能。
  • 需要在JDK整体结构层面进行支持的改进。典型的如JDK 9时引入的Java模块化系统。
  • 集中在虚拟机内部的改进。
上述的5类新功能中,逆向移植工具能比较完美地模拟了前两类,从第3类开始就逐步深入地涉及了直接在虚拟机内部实现的改进。
Retrotranslator的作用是将JDK 5编译出来的Class文件转变为可以在JDK 1.4或1.3上部署的版本,它能很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持JDK 5中新增的集合改进、并发包及对泛型、注解等的反射操作。Retrolambda的作用与Retrotranslator是类似的,目标是将JDK 8的Lambda表达式和try-resources语法转变为可以在JDK5、JDK 6、JDK 7中使用的形式,同时也对接口默认方法提供了有限度的支持。
9.3 实战:自己动手实现远程执行功能 这一部分,请自行阅读原书。

    推荐阅读