Apache Tomcat 类加载机制

Java 在刚刚诞生的时候就提出了一个非常著名的宣传口号:“一次编写,到处运行。”它体现了 Java 语言的平台无关性,主流的操作系统都支持 Java 的运行,比如:Windows、Linux 以及 Mac OS 系统。不同的操作系统都有其对应的虚拟机。这些虚拟机和所有平台都统一使用同一种存储格式 – 字节码。

只要把程序文件(不止 Java 语言)编译成虚拟机能够识别的字节码,都可以在虚拟机上面运行。所以在 Java 语言之外,还有基于 Java 虚拟机运行的其它语言,如 Groovy、Scala 等。Java 虚拟机并不和某种语言绑定,它只与虚拟机定义识别的特定的二定制 Class 文件所关联。只要这种语言能够编译成 Java 虚拟机能够接受的 class 文件,都可以在虚拟机上面运行。

1、类的加载

当我们定义一个 Hello.java 类时,通过 javac 或者打包工具 maven 可以把 Hello.java 文件编译成 Hello.class 文件,这样虚拟机就可以识别它。就可以在虚拟机进行运行了。
Apache Tomcat 类加载机制_第1张图片
那么什么时候会加载一个类呢?也就是说什么时候虚拟机才会就 加载我们的 .class 文件呢?

答案就是在我们的代码需要这个类的时候,虚拟机就会去加载这个类。

这样就可以理解了 Spring boot 里面,在自动加载的这个类里面有很多飘红的类,而不影响这个类正常的运行了。


Spring Boot 的 Web starter 默认会依赖 Tomcat 相关的 Java 包,所以Spring boot 默认会使用 Tomcat 作为 Servlet 容器。在项目运行中它并不会引用 Jetty 或者 Undertow,所以即使它的类中引用了 Jetty 或者 Undertow 相关的依赖也不会影响服务正常的运行。如果你想使用 Jetty 或者 Undertow 的话,只需要排除 Tomcat 的依赖,然后依赖 Jetty 或者 Undertow。

2、类加载过程

类从加载到虚拟机内存中开始,到从内存卸载为止。它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载。其中验证、准备、解析统称为连接。
Apache Tomcat 类加载机制_第2张图片

2.1 加载

“加载” 是 “类加载” 过程的一个阶段,在加载阶段,虚拟机主要完成以下 3 件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代码的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为程序员可以使用系统的类加载器加载,还可以使用自己的类加载器加载。我们在最后一部分会详细介绍这个类加载器。在这里我们只需要知道类加载器的作用就是上面虚拟机需要完成的三件事,仅此而已就好了。

2.2 验证

验证的主要作用就是确保被加载的类的正确性。也是连接阶段的第一步。说白了也就是我们加载好的.class文件不能对我们的虚拟机有危害,所以先检测验证一下。他主要是完成四个阶段的验证:

(1)文件格式的验证:验证.class文件字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面包含的数据信息、在这里可以不用理解)。

(2)元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。

(3)字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出威海虚拟机安全的事。

(4)符号引用验证:它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。

对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证。

2.3 准备

准备阶段主要为类变量分配内存并设置初始值。这些内存都在方法区分配。在这个阶段我们只需要注意两点就好了,也就是类变量和初始值两个关键词:

(1)类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到java堆中,

(2)这里的初始值指的是数据类型默认值,而不是代码中被显示赋予的值。比如

public static int value = 1; //在这里准备阶段过后的value值为0,而不是1。赋值为1的动作在初始化阶段。

当然还有其他的默认值。

注意,在上面value是被static所修饰的准备阶段之后是0,但是如果同时被final和static修饰准备阶段之后就是1了。我们可以理解为static final在编译器就将结果放入调用它的类的常量池中了。

2.4 解析

解析阶段主要是虚拟机将常量池中的符号引用转化为直接引用的过程。什么是符号应用和直接引用呢?

  • 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要是能无歧义的定位到目标就好,就好比在班级中,老师可以用张三来代表你,也可以用你的学号来代表你,但无论任何方式这些都只是一个代号(符号),这个代号指向你(符号引用)
  • 直接引用:直接引用是可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。和虚拟机实现的内存有关,不同的虚拟机直接引用一般不同。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

2.5 初始化

这是类加载机制的最后一步,在这个阶段,java程序代码才开始真正执行。我们知道,在准备阶段已经为类变量赋过一次值。在初始化阶端,程序员可以根据自己的需求来赋值了。一句话描述这个阶段就是执行类构造器< clinit >()方法的过程。

在初始化阶段,主要为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

①声明类变量是指定初始值

②使用静态代码块为类变量指定初始值

JVM初始化步骤

1、假如这个类还没有被加载和连接,则程序先加载并连接该类

2、假如该类的直接父类还没有被初始化,则先初始化其直接父类

3、假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

  • 创建类的实例,也就是new的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如 Class.forName(“com.shengsiyuan.Test”))
  • 初始化某个类的子类,则其父类也会被初始化

Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用 java.exe命令来运行某个主类
好了,到目前为止就是类加载机制的整个过程,但是还有一个重要的概念,那就是类加载器。在加载阶段其实我们提到过类加载器,说是在后面详细说,在这就好好地介绍一下类加载器。

3、Java SE 标准类加载器

通过上面大家应该明白了整个类加载从触发时机到初始化的过程,下面就和大家说一下类加载器的概念。因为实现上述过程,必须依靠类加载器来实现的。

3.1 类加载器

在 Java SE 标准类加载器有以下几个类加载器。

  • Bootstrap ClassLoader,也就是启动类加载器,它是由虚拟机内部实现的。主要的功能是负责加载安装在我们机器上%JAVA_HOME%/jre/lib 目录下的的核心库。
  • Extension ClassLoader,也就是扩展类加载器,是指 Sun 公司实现的sun.misc.Launcher$ExtClassLoader类Java 提供的一个标准的扩展机制用于加载除核心类库外的 Jar 包,它会加载%JAVA_HOME%/jre/lib/ext 目录下的 Jar 包来支持你的系统的运行。
  • Application ClassLoader,也就是应用程序类加载器,是指 Sun 公司实现的sun.misc.Launcher$AppClassLoader。这个加载器就是负责去加载 Classpath 环境变量所指定的路径中的类。其实可以简单的理解成加载你写好的 Java 代码。这个类加载器就是负责加载你写的那些类到内存当中。

在Java的日常应用程序开发中,类的加载是由上述 3 种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,需要注意的是,Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式即把请求交由父类处理,它一种任务委派模式,下面我们进一步了解它。

3.2 双亲委派模式

JVM 的类加载器是有父子级层级结构的,就是说启动类加载器是最上层的,扩展类加载器在第二层,第三层是应用程序类加载器,最后一层是自定义类加载器。如下图所示:
Apache Tomcat 类加载机制_第3张图片
假设你的应用程序加载器需要加载一个类,他首先会委派给自己的父类加载器去加载,最终会传递到顶级的类加载器去加载。如果父类加载器在自己负责加载的范围内,没有找到这个类,就会下推加载权力给自己的子类加载器。

听完上面的比较官方的解决是不是很茫然,那我们就一个小例子来说明一下。

比如你写了一个 HelloWorld 类,这个时候应用程序类加载器会问自己的父加载器,也就是扩展类加载器,你能加载到这个类吗?

然后扩展类加载器直接问它的父类,启动类加载器,你能加载到这个类吗?

启动类加载器是顶级类加载器,所以它就需要在 Java 的安装目录下去找这个类,发现没有找到这个类,就会把加载类的权利下发给扩展类加载器。然后扩展器类加载器在它的加载目录里面也没有找到这个类,就会把加载类的权力下发给应用程序类加载器。此时应用程序在自己的类加载器中能够找到这个类,所以就会由应用程序加载 HelloWorld 这个类,完成类的加载过程。

3.3 双亲委派的好处

采用双亲委派模式的是好处是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次。

其次是考虑到安全因素,Java 核心 api 中定义类型不会被随意替换,假设你在应用程序类加载器定义一个java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心 Java API 发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

Apache Tomcat 类加载机制_第4张图片
可能你会想,如果我们在classpath路径下自定义一个名为java.lang.SingleInterge类(该类是胡编的)呢?该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器加载,最终会通过系统类加载器加载该类。但是这样做是不允许,因为java.lang是核心API包,需要访问权限,强制加载将会报出如下异常

java.lang.SecurityException: Prohibited package name: java.lang

3.4 自定义类加载器

Java 虚拟机并没有规定在类加载的时候并没有规定加载源。所以你可以从网络、数据库或者其它地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于 Java程序之中。从最基础的 Applet、JSP 到相对复杂的 OSGI 技术,都使用了 Java 语言运行期类加载的特性。

如果你也有需求,可以实现自己的类加载器。实现思路是新建一个类继承自 java.lang.ClassLoader, 重写它的findClass 方法。具体操作可以参考 Tomcat 内部的类加载器,也可以在网络上自行搜索。

4、Tomcat 类加载器

应用服务器通常会自行创建类加载器来实现更灵活的控制,这一方面是对规范的实现(Servlet 规范要求每个 Web 应用都有一个独立的类加载器实例),另一方面也有架构层面的考虑)

  • 隔离性:Web 应用类库相互隔离,避免依赖库或者应用包相互影响。假如我们有两个 Web 应用,一个采用了 Spring 2.5,另一个采用的是 Spring 4.0,而应用服务器使用一个类加载器加载,那么 Web 应用将会由于 Jar 包覆盖而导致无法成功启动。
  • 灵活性:既然 Web 应用之间的类加载器相互独立,那么我们就能只针对一个 Web 应用进行重新部署,此时该 Web 应用的类加载器将会重新创建,而不会影响其他 Web 应用。如果采用一个类加载器,显示无法实现。因为只有一个类加载器的时候,类之间的依赖是杂乱无章的,无法完整的移除某个 Web 应用的类。
  • 性能:由于每个 Web 应用都有一个类加载器,因此 Web 应用在加载类时,不会搜索其它 Web 应用包含的 Jar 包,性能自然高于应用服务器只一个类加载器的情况。

当然,Tomcat 的类加载设计也体现了几个架构要素,我们先来看一下 Tomcat 的类加载方案:
Apache Tomcat 类加载机制_第5张图片
我们可以看到,除了每个 Web 应用的类加载器外,Tomcat 也提供了 3 个基础的类加载器和 Web 应用类加载器,而这 3 个类加载器指向的路径和包列表均可以帽 catalina.properties 配置。

  • Common:以 System 为父类加载器,是位于 Tomcat 应用服务器顶层的公用类加载器。其路径为 common.loader ,默认指向 $CATALINA_HOME/lib 下的包。
  • Cataliba:以 Common 为父加载器,是用于加载 Tomcat 应用服务器的类加载器,其路径为 server.loader,默认为空。此时 Tomcat 使用 Common 类加载器加载应用服务器。
  • Shared:以 Common 为父加载器,是所有 Web 应用的父加载器,其路径为 shared.loader,默认为空。此时 Tomcat 使用 Common 类加载器作为 Web 应用的父加载器。
  • Web 应用:以 Shared 为父加载器,加载 /WEB-INF/classes 目录下的未压缩的 Class 和资源文件以及 WEB-INF/lib 目录下的 Jar 包。如前所述,该类加载器只对当前 Web 应用可见,对其他 Web 应用均不可见。

尽管默认情况下,这 3 个基础类加载器是同一个,但是我们可以通过配置创建 3 个不同的类加载器,使用它们各司其职。

首先, Common 类加载器负责加载 Tomcat 应用服务器内部和 Web 应用均可见的类,例如 Servlet 规范想着包和一些通用的工具包。
其实, Catalina 类加载器负责加载只有 Tomcat 应用服务器内部可见的类,这些类对 Web 应用不可见。如 Tomcat 的具体实现类,因为我们的 Web 应用最好与服务器松耦合,故不应该依赖应用服务器的内部。
再次,Shared 类加载器负责加载 Web 应用共享的类,这些 Tomcat 服务器不会依赖。

既然 Tomcat 提供了这个特性,那么我们什么时候可以考虑使用呢?举个例子,如果我们想实现自己的会话存储方案,而且该方案依赖了一些第三方包,我们不希望这些包对 Web 应用可见(因为可能会存在包冲突之类的问题,也可能我们的 Web 应用根本不需要这些包)。此时,我们可以配置 server.loader,创建独立的 Catalina 类加载器。

最后, Tomcat 服务器 $CATALINA_HOME/bin 目录下的包作为启动入口由 System 类加载器加载。通过将这几个启动包剥离, Tomcat 简化了应用服务器的启动,同时增加了灵活性。

接下来,我们从架构层面讨论一下 Tomat 的类加载器方案。下面几点是对上述架构分析补充。

  • 共享。Tomcat 通过 Common 类加载器实现了 Jar 包在应用服务器以及 Web 应用之间共享,通过 Shared 类加载器实现了 Jar 包在 Web 应用之间的共享,通过 Cataliba 类加载器加载服务器依赖的类。这样最大程序上实现了 Jar 包的共享,而且又确保了不会引入过多无用的包。
  • 隔离性。这里的隔离性区别于前者,指服务器与 Web 应用的隔离。理论上,除去 Servlet 规范定义的接口外,我们的 Web 应用不应依赖服务器的任何实现类,这样才有助于 Web 应用的可移植性。正因如此,Tomcat 支持通过 Catalina 类加载器加载服务器依赖的包(尽管 Tomcat 默认并没有这么做),以便应用服务器与 Web 应用更好地隔离。

5、Web 应用类加载器

我们都知道 Java 默认的类加载机制是双亲委派模式:

  • (1) 从缓存中加载。
  • (2) 如果缓存中没有,则从父类加载器中加载
  • (3) 如果父类加载器没有,则从当前类加载器加载。
  • (4) 如果没有,则抛出异常

Tomcat 提供的 Web 应用类加载器与默认的委派模式稍有不同。当进行类加载时,除 JVM 基础类库外,它会首先尝试通过当前类加载器加载,然后才进行委派。 Servlet 规范相关 API 禁止通过 Web 应用类加载器加载,因此,不要在 Web 应用中包含这些包。

所以, Web 应用类加载器默认加载顺序如下:

  • (1) 从缓存加载
  • (2 )如果没有,则从 JVM 的 Bootstrap 类加载器加载
  • (3) 如果没有,则从当前类加载器加载(按照 WEB-INF/classes、WEB-INF/lib 的顺序)
  • (4) 如果没有,则从类加载器加载,由于父类加载器采用默认的委派模式,所以加载顺序为 System、Common、Shared

Tomcat 提供了 delegate 属性用于控制是否采用 Java 双亲委派模式,默认是 false(不启用)。当配置为 true 时, Tomcat 将使用 Java 默认的委派模式,即按如下顺序加载。

  • (1) 从缓存中加载。
  • (2) 如果没有,从 JVM 的 Bootstrap 类加载器加载。
  • (3) 如果没有,则从父类加载器加载(System、Common、Shared。
  • (4) 如果没有,则从当前类加载器加载。

除了可以通过 delegate 属性控制是否启用 Java 的委派模式外, Tomcat 还可以通过 packageTriggerDeny 属性只让某系包路径采用 Java 的委派模式, Web 应用类加载器对于符合 packageTriggerDeny 指定包路径的类强制采用 Java 的委派模式。

Tomcat 通过该机制实现为 Web 应用中的 Jar 包覆盖服务器提供包的目的。如上所述,Java 核心类库、Servlet 规范相关类库是无法覆盖的,此外 Java 默认提供的诸如 XML 工具包,由于位于 JVM 的 Bootstrap 类加载器也无法覆盖,只能通过 endorsed 的方式实现

引用文档:

  • 深入理解Java类加载器(ClassLoader)
  • Java的ClassLoader加载机制
  • 《深入理解Java虚拟机_JVM高级特性与最佳实践 第2版》
  • 《Tomcat 架构解析》

你可能感兴趣的:(JVM,Tomcat)