最近面试被问到双亲委派,对于双亲委派和破坏双亲委派的机制之前自己在《深入理解Java虚拟机》中了解过,当时觉得挺简单的一个概念,但是面试官仔细追问下去发现自己这块的只是还是存在一些误区,当时这一个问题可能聊了有20分钟,当然面试后自然就没有下文了😂。
故事要从这张双亲委派模型的类UML图说起:
- 误区1:当时看了类加载器的模型图,以为启动类加载器、扩展类加载器、应用程序类加载器与自定义累加器之间是继承关系,其实“父加载器”与“子加载器”是组合关系,这点其实和父子加载器的命名给人带来的主观想法相悖;
- 误区2:没有理清
findClass()与loadClass()之间的关系和这两个方法具体的作用,破坏双亲委派机制与遵循双亲委派机制与这两个方法的相关性; - 误区3:因为平时工作中没怎么使用过
ClassLoader,没有理清Java中ClassLoader类与Bootstrap Class Loder、Extension Class Loader、Application Class Loader之间的关系;错误的按照类加载器模型图,认为自己实现类加载器需要继承应用程序类加载器以实现自定义类加载器。
误区形成的最主要原因是当时自己看《深入理解Java虚拟机》的时候,其实还是处于知识储备、知识之间的关联性还不够到位的状态,因此今天重新进行梳理一下~
双亲委派模型
什么是双亲委派模型? 双亲委派模型定义了加载器加载类的方式,即当一个加载器收到加载一个类的请求时,首先会委托给父加载器进行处理加载,只有当其父加载器无法加载时,当前加载器才会进行加载;
那么什么时候会触发类加载器进行类的加载呢? 当在程序中出现对于一个类的主动引用时,如果当前类尚未被加载到方法区中时,会触发对引用类的加载;
触发类的加载后类加载器需要做些什么? 虚拟机通过类加载器在Java类加载阶段需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
那么什么时候父加载器无法加载一个类呢? 因为每个加载器加载负责加载的类都是不同的:
- 启动类加载器: 主要加载
<JAVA_HOME>\lib目录下的类,并通过文件名匹配,如rt.jar、tools.jar这类Java核心类库;这个类通过C++语言进行实现,是虚拟机的一部分; - 扩展类加载器: 主要加载
<JAVA_HOME>\lib\ext目录下的扩展类;通过Java语言进行实现,对应的类为sun.misc.Launcher$ExtClassLoader; - 应用程序类加载器: 主要加载用户类路径(
classpath)下的所有类库;通过Java语言进行实现,对应的类为sun.misc.Launcher$AppClassLoader,可以通过ClassLoader类中的getSystemClassLoader()方法获取应用程序类加载器的引用,并且应用程序中如果没有自定义类加载器,则会使用这个默认的类加载器;
双亲委派模型实现
JDK1.2之后,通过ClassLoader::loadClass()实现双亲委派模型,并且我们使用ClassLoader时一般也会通过loadClass()方法进行类的加载:
public abstract class ClassLoader {
// 当前ClassLoader依赖的父加载器,注意父加载器与当前加载器是组合的关系!
private final ClassLoader parent;
// 在构建classLoader时,默认会将系统类加载器AppClassLoader作为当前classLoader的父加载器
// getSystemClassLoader()默认返回AppClassLoader
// 而AppClassLoader的父加载器也是在初始化时设置为 PlatformClassLoader
protected ClassLoader() {
this(checkCreateClassLoader(), null, getSystemClassLoader());
}
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 首先检查是否已经加载过当前类
Class<?> c = findLoadedClass(name);
// 2. 未加载过当前类则进行加载
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2.1 父加载器不为空,则先使用父加载器进行加载
c = parent.loadClass(name, false);
} else {
// 2.2 父加载器为空,则使用启动类加载器进行加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ignore未找到当前类的异常,说明父加载器加载失败
}
if (c == null) {
long t1 = System.nanoTime();
// 2.3 通过当前类加载器进行加载,注意此处是调用了findClass()方法
// 而不是像父类加载器通过loadClass()进行加载
// 因此如果要遵循双亲委派机制,子加载器需要通过覆盖findClass()方法实现自己的加载逻辑
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
}
为什么要遵循双亲委派模型?
主要原因在于通过双亲委派模型组织类加载器之间的关系,使得在Java中的类随着它的类加载器也具备了一种带优先级的层次关系,而这种关系保证了类加载的安全性;
如用户自己定义了一个Object类,那么按照双亲委派模型,首先会通过启动类加载器进行Object类的加载,则自然会加载在<JAVA_HOME>\lib目录下的Obejct.Class类,而不会加载用户在自身classpath下定义的Object类,从而保证了Java核心类库的类不会被错误的加载。
破坏双亲委派模型
遗憾的是,双亲委派模型在面对复杂的世界时,并不能完美的解决所有问题:
上层组件依赖下层组件的问题:
比较常见的是JDBC,因为我们项目使用的数据库可能是Oracle、MySQL、SQL Server等,而不同的数据库因为使用方式的不同提供了不同的JDBC Driver,从而使得应用代码可以通过调用JDBC Driver Interface对不同数据进行增删改查操作;
Connection connection = DriverManager.getConnection(jdbcUrl, username, password);
但是用于获取数据库连接的DriverManager类是位于rt.jar下的,按照双亲委派模型理应通过启动类加载器进行加载,但是这个类又会因为项目使用的数据库类型依赖不同的第三方实现的Driver类,而第三方的是实现类一般是放在用户类路径(classpath)下的,启动类加载器自然无法加载;
JDK1.6通过引入基于ThreadContextClassLoader(线程上下文类加载器)实现的ServiceLoader这个特殊的类加载器用来解决这个问题:
public static <S> ServiceLoader<S> load(Class<S> service) {
// 线程上下文类加载器默认为AppClassLoader
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 通过AppClassLoader加载上层组件中依赖的用户classpath下的包
return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
}
但是线程上下文使用不当可能会导致内存泄漏的问题,具体可以参考这篇文章:线程上下文类加载器ContextClassLoader内存泄漏隐患
缺乏隔离性问题:
对于在一个JVM上会运行多个Web容器的Tomcat,类可见性如果处理不够妥当,会导致Web容器间缺乏合理的隔离性从而带来安全性和容器间依赖组件版本冲突以及无法支持类热更的问题;
-
安全性问题: 按照双亲委派机制,对于同一个JVM下
CLASSPATH指定路径下的所有类和库,都会通过AppClassLoader进行加载,因此CLASSPATH路径下的类可以被所有运行在JVM上的Web容器通过AppClassLoader类加载器进行加载、使用;但是对于运行在JVM上的每个Web应用(servlet容器),容器中的servlet应该只可以访问自身WEB-INF/classes和WEB-INF/lib目录下的类;而如果通过AppClassLoader类加载器加载servlet,那么servlet便可以通过AppClassLoader访问到CLASSPATH下的其他非本容器的类,而这显然违反了servlet容器需要的类隔离性; -
版本冲突: 项目的多个模块间可能存在对于同一个第三方组件不同版本的依赖,而按照双亲委派模型,如果使用
AppClassLoader作为类加载器加载各个模块的依赖类,会导致在CLASSPATH路径下只有一个版本的第三方组件会被加载;PS:类似的还有通过maven直接或间接引入了一个包的多个版本导致版本冲突的问题,也是通过自定义类加载器模型进行解决; -
无法支持热更: 因为按照双亲委派机制,不同模块之间共同依赖了同一个
AppClassLoader,使得模块之间对于所加载的类存在耦合关系,即我们不可以随便卸载并替换一个类,因为并不知道是否还有其他模块依赖这个版本的第三方组件;
解决方法:
为了解决容器间缺乏隔离性带来的一系列问题,Tomcat自定义了自身的类加载器模型:
Tomcat容器通过复写ClassLoader::loadClass()方法自定义了破坏双亲委派模型的WebappClassLoader类加载器,并为每个Web应用都关联了一个WebAppClassLoader,从而实现了Web应用间特殊依赖类的隔离性,即每个应用首先会通过自身该类加载器加载自身WEB-INF/classes、WEB-INF/lib目录下依赖的类库, 只有当加载不到所需要的类时才会交由上层类加载器进行加载(破坏了双亲委派首先交由父类进行加载的规则);
但是Tomcat的每个Web应用间可能也会存在共性的一些依赖,如Servlet规范相关包以及一些工具类包,因此Tomcat的类加载器模型通过父加载器SharedClassLoader进行加载在Web应用间共享的jar包。
而按照该类加载器模型,实现热更只需要动态替换掉Web容器的WebAppClassLoader即可,避免了重启整个Java项目带来的损耗,并不需要担心被替换掉的类加载器所加载的类会不会被运行在同一个JVM上的Web应用引用的问题。
具体代码实现
主要在复写的loadClass()方法中进行自身加载模型的定义,Tomcat自定义类加载器除了上述中定义类加载器模型的目的之外,还有实现对于已加载类的缓存、类的预载入这两个方面;
public abstract class WebappClassLoaderBase extends URLClassLoader {
// ClassLoader对于已加载类的内存缓存
protected final Map<String, ResourceEntry> resourceEntries = new ConcurrentHashMap<>();
@Override
// @param: name,需要加载的类的全限定名
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = null;
// 1. 缓存查询
// 1.1 WebAppClassLoader自身Map缓存
clazz = findLoadedClass0(name);
if (clazz != null) {
return clazz;
}
// 1.2 查询JVM缓存
clazz = findLoadedClass(name);
if (clazz != null) {
return clazz;
}
// 2. 开始类加载
String resourceName = binaryNameToPath(name, false);
ClassLoader javaseLoader = getJavaseClassLoader();
boolean tryLoadingFromJavaseLoader;
try {
// 2.1 首先尝试使用AppClassLoader的父加载器进行加载
// 主要是为了避免Tomcat类目下自定义类覆盖JavaSE的核心类
URL url = javaseLoader.getResource(resourceName);
tryLoadingFromJavaseLoader = (url != null);
} catch (Throwable t) {
tryLoadingFromJavaseLoader = true;
}
if (tryLoadingFromJavaseLoader) {
try {
clazz = javaseLoader.loadClass(name);
if (clazz != null) {
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
// 将加载类的全限定名传入filter()方法判断是否需要委托给父加载器进行加载
boolean delegateLoad = delegate || filter(name, true);
// 2.2 如果需要进行委托加载,则交由AppClassLoader进行类的加载
// 而AppClassLoader是遵循双亲委派模型的
if (delegateLoad) {
try {
// 交由父类AppClassLoader进行加载
clazz = Class.forName(name, false, parent);
if (clazz != null) {
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
// 2.2 不需要进行委托,则破坏双亲委派机制,首先通过WebAppClassLoader加载器加载
// 具体的类加载方法由自身的findClass()方法进行实现
// 这里可以看到,loadClass()方法一般用于实现类加载器模型,
// 而由findClass()方法负责实现具体的类加载手段
try {
clazz = findClass(name);
if (clazz != null) {
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 2.3 如果当前Web应用Class目录下不存在当前需要加载的类
// 则重新按照双亲委派模型使用父加载器进行最终的加载尝试
if (!delegateLoad) {
try {
clazz = Class.forName(name, false, parent);
if (clazz != null) {
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
}
// 2.4 未能成功加载所需要的类,抛出ClassNotFound异常
throw new ClassNotFoundException(name);
}
}
总结与参考(答误区)
- 类加载器的双亲委派模型通过
ClassLoader::loadClass()方法通过组合父加载器实现优先父加载器加载的模型; loadClass()一般用于实现类加载器模型,诸如双亲委派模型,Tomcat类加载器模型等;而findClass()则用于在定义当前类加载器模型下当前类加载器具体的加载类手段;- 自己实现类加载器,如果按照双亲委派模型,则只需要复写
findClass()方法即可;如果需要破坏双亲委派模型,则需要复写loadClass()与findClass()。
主要参考:
《深入剖析Tomcat》 《深入理解Java虚拟机 Edition3》