一、类加载机制
Java虚拟机把描述类的数据从Class文件加载进内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这动作的代码模块成为“类加载器”。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载他的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。简单的说就是:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。
JVM的类加载机制有:
- 全盘负责:当一个类加载器加载某个Class时,该Class所依赖和引用的其它的Class也由该类加载器负责载入,除非显示的使用另一个类加载器来载入;
- 双亲委派模型;
- 按需加载:类的加载是按需进行的,只有使用了才会被加载;
- 缓存机制:所有被加载过的Class都会被缓存,当要使用某个Class时,会先去缓存查找,如果缓存中没有才会读取class文件进行加载。
二、双亲委派模型
1、类加载器的分类
从虚拟机的角度来说,只存在两种不同类加载器:
- 启动类加载器(Bootstrap ClassLoader):使用C++语言实现(只限HotSpot),是虚拟机自身的一部分;
- 所有其他的类加载器:由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
从开发人员的角度来看,类加载还可以划分的更细致一些:
- 启动类加载器(Bootstrap ClassLoader):负责将存放在 JAVA_HOME/lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录下也不会加载)。启动类加载器无法被java程序直接引用,如果需要把加载请求委派给启动类加载器去处理,可以直接使用 null 替代;
- 扩展类加载器(Extension ClassLoader):由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 JAVA_HOME/lib/ext 目录下的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库。开发者可以直接使用扩展类加载器;
- 应用程序类加载器(Application ClassLoader):由 sun.misc.Launcher$AppClassLoader 实现。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以也叫做系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用中没有定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
2、双亲委派工作流程
上图中各个类加载器之间的关系称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载(类加载器之间的父子关系一般都使用组合关系来复用父加载器的代码)。
双亲委派模型在JDK1.2 期间被引入并被广泛应用于之后的所有Java程序中,但并不是个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式。
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个请求时,子加载器才会尝试自己去加载。
3、双亲委派好处
- 避免类的重复加载,确保一个类的全局唯一性。Class与加载它的类加载器一起具备了一种带有优先级的层次关系,当父亲已经加载了该类时,就没有必要子ClassLoader 再加载一次;
- 保护程序安全,防止核心API被随意篡改。
4、双亲委派实现
双亲委派模型的实现在 java.lang.ClassLoader 的 loadClass() 方法之中:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的 loadClass() 方法, 如父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。
5、破坏双亲委派模型
到目前为止(JDK8),双亲委派模型有过3次大规模的“被破坏”的情况。
第一次破坏
由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类 java.lang.ClassLoader 则在JDK1.0时代就已经存在,用户去继承 java.lang.ClassLoader 的唯一目的就是为了重写 loadClass() 方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法 loadClassInternal(),而这个方法唯一逻辑就是去调用自己的 loadClass() 。为了兼容这些已有代码,Java设计者引入双亲委派模型时不得不做出一些妥协,在JDK1.2之后的 java.lang.ClassLoader 中添加了一个新的 protected 方法 findClass() ,并引导用户编写类加载逻辑时,尽可能去重写这个方法,而不是在 loadClass() 中编写代码。
第二次破坏
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码继承,调用的API存在。但是如果基础类又要调用回用户的代码,那该么办?
一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时放进去的 rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的setContextClassLoader() 方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
第三次破坏
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简单的说就是机器不用重启,只要部署上就能用。
OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当受到类加载请求时,OSGi将按照下面的顺序进行类搜索:
- 将java.*开头的类委派给父类加载器加载;
- 否则,将委派列表名单内的类委派给父类加载器加载;
- 否则,将 Import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载;
- 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载;
- 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载;
- 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载;
- 否则,类加载器失败。
6、如何破坏双亲委派模型?
- 使用SPI机制;
- 自定义类继承 ClassLoader,作为自定义类加载器,重写 loadClass() 方法,不让它执行双亲委派逻辑,从而打破双亲委派。但是遇到自定义类加载器和核心类重名或者篡改核心类内容,jvm会使用沙箱安全机制,保护核心类,防止打破双亲委派机制,防篡改,如果重名的话就报异常。
三、有哪些破坏双亲委派模型的例子?
1、SPI
spi机制是一种服务发现机制。它通过在 ClassPath 路径下的 META-INF/services 文件夹查找文件,自动加载文件里所定义的类。这一机制为很多框架扩展提供了可能,比如在JDBC中就使用到了SPI机制。
原生的JDBC中 Driver 驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库厂商去实现的。原生的JDBC中的类是放在 rt.jar 包的,是由启动类加载器进行类加载的,在JDBC中的 Driver 类中需要动态加载不同数据库类型的 Driver 类,而 mysql-connector-.jar 中的 Driver 类是用户自己写的代码,那启动类加载器肯定是不能进行加载的,于是乎,这个时候就引入SPI,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。
2、Tomcat
2.1、Tomcat为什么不使用默认的双亲委派模型?
Tomcat 作为一个 web 容器,存在以下使用场景:
- 部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离;
- 部署在同一个 web 容器中相同的类库相同的版本可以共享;
- 容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来;
- 要支持 jsp 的热部署(jsp 文件最终也是编译成 class 文件才能在虚拟机中运行)。
对于第一种和第三种场景,如果使用默认的类加载器机制,是无法加载两个相同类库的不同版本的,默认的类加载器只关注全限定类名,不关注是什么版本的。 第二种场景,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。 第四种场景,要实现 jsp 文件的热更新(jsp 文件其实也就是 class 文件),使用默认类加载器,如果修改了,但类的全限定名还是一样,类加载器会直接取方法区中已经存在的,修改后的 jsp 是不会重新加载的。
2.2、Tomcat 如何实现自己的类加载机制?
Tomcat 自己实现了自己的类加载器:
- CommonLoader:Tomcat最基本的类加载器,加载路径中的 class 可以被Tomcat容器本身以及各个 Webapp 访问;
- CatalinaLoader:Tomcat容器私有的类加载器,加载路径中的 class 对于 Webapp 不可见;
- SharedClassLoader:各个 Webapp 共享的类加载器,加载路径中的 class 对于所有 Webapp可见,但是对于Tomcat容器不可见;
- WebappClassLoader:各个 Webapp 私有的类加载器,加载路径中的 class 只对当前 Webapp可见;
- JspClassLoader:每一个JSP文件对应一个Jsp类加载器。
从图中的委派关系中可以看出:
- CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,从而实现了公有类库的共用;
- CatalinaClassLoader 和 Shared ClassLoader 自己能加载的类则与对方相互隔离;
- WebAppClassLoader 可以使用 SharedClassLoader 加载到的类,但各个WebAppClassLoader 实例之间相互隔离;
- JasperLoader 的加载范围仅仅是这个JSP文件所编译出来的那一个 .Class 文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过再建立一个新的 JasperLoader 来实现JSP文件的热插拔功能。
四、参考资料
《深入理解java虚拟机》
|