Java学习记录--委派模型与类加载器

最近在读许令波的深入分析Java Web技术内幕一书,对于学习Java以来一直有的几个疑惑得到了解答,遂记录下来.

我的疑问

  1. 双亲委派模型(实际上是一个翻译错误,英文为parent delegation,只是一个父委托模型)是什么?如何实现?为什么这样实现?
  2. 热加载的技术原理是什么?
  3. ClassLoader如何实现动态加载jar,实现插件模式系统?
    下面跟着教程来寻找这些答案.

ClassLoader与委派模型

ClassLoader体系

ClassLoader顾名思义是类加载器(准确来说为JVM平台类加载器抽象父类),主要功能负责将Class加载到JVM中,其所使用的加载策略叫做双亲委派模型.其主要有如下方法

  1. defineClass 负责把class文件从字节流解析为JVM能够识别的Class对象,这意味着只要能拿到对应的Class字节流就可以完成对象实例化,注意该Class对象在使用前必须resolve.(这种加载方式也是动态代理实现的基础,直接从内存中生成的class二进制流制造出来一个类)
  2. findClass 自定义规则时复写的方法,通常与defineClass一起使用,找到一个class文件,然后defineClass解析后生成Class对象,
  3. loadClass JVM所调用的加载方法,该方法会在findLoadedClass,loadClass(String)都没找到时调用findClass寻找Class对象,然后根据resolve的flag来决定是否链接.
  4. resolveClass​​​ 链接一个Class对象,在这个操作之后才可以使用该Class对象

JVM平台提供三个ClassLoader:

  • Bootstrap ClassLoader,由C++实现的ClassLoader,不属于JVM平台,由JVM自己控制,主要加载JVM自己工作所需要的类,当类加载器的parent为null时会使用Bootstrap ClassLoader​去加载,其也不再双亲委派模型中承担角色.
  • ExtClassLoader,JVM在sun.misc.Launcher中主动实例化的类加载器,主要加载System.getProperty(“java.ext.dirs”)对应的目录下的文件(具体源码中可以看到),同时也是AppClassLoader的父类
  • AppClassLoader,由ExtClassLoader为parent创建出来的,同样为sun.misc.Launcher的内部类,主要加载System.getProperty(“java.class.path”)下的类,这个目录就是我们经常用到的classpath,包括当前应用以及jre相关jar包路径.

那么不算Bootstrap ClassLoader,JVM体系的ClassLoader结构如下

ClassLoader作为抽象父类其实是一装饰者模式中的Decorator角色,AppClassLoader本质上是对ExtClassLoader的增强处理,再看初始化方式可以简化为

1
Launcher.AppClassLoader.getAppClassLoader(Launcher.ExtClassLoader.getExtClassLoader())

是不是和IO套用初始化很像?其实双亲委派按照我的理解本质上就是装饰者模式的应用,使用组合代替了继承只不过这个被装饰者叫做parent,思想上一致只是用法的不同.
另外这张图也说明如果想要实现自己的ClassLoader,只需要继承java.net.URLClassLoader,然后自定义加载逻辑即可.

加载流程

加载

查找Class字节流,然后根据这个创建Java类的过程,这里要注意对于数组来说并没有对应的字节流,是由JVM直接生成,因此加载只适用于数组以外的Class文件流。
接下来重点看loadClass()方法,该方法为加载class二进制文件的核心方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//当父加载器不存在的时候会尝试使用BootStrapClassLoader作为父类
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
//c为null则证明父加载器没有加载到,进而使用子类本身的加载策略`findClass()`方法
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

当父类不存在时调用java.lang.ClassLoader#findBootstrapClass使用BootStrapClass加载,该方法是一个native方法,也进一步说明了Bootstrap Classloader与JVM体系的ClassLoader没什么父子类关系.完全独立.

链接

链接的目的是把上述加载的类合并到JVM当中,使之可以运行,链接过程又分为验证 -> 准备 -> 解析三个流程.
验证主要是确保加载的流符合JVM所定义的规范.
准备阶段主要是为静态字段分配内存,以及一些额外的预处理,
解析阶段主要是把编译期生成的符号引用变成转换为实际引用,所谓的符号引用是由于编译期无法确定其他类是否被加载,因此当该类使用外部类的字段或者方法时,是预先生成符号引用存放在Class文件的常量池中,如果对应符号引用的类还未被加载,那么会触发其加载流程,但不一定会触发其解析流程。

初始化

类加载过程的初始化主要针对静态字段,因为一个类只能被初始化一次,因此也确保了静态字段只被初始化一次,具体流程如下:

  1. final修饰的静态基本变量以及字符串:JVM会直接将其标记为常量值,直接完成初始化。
  2. 其他静态对象以及静态代码块:JVM会将其抽取到 <cinit>方法中,然后执行该方法初始化静态字段。这是类的初始化流程。

另外JVM规定以下情况会触发类的初始化:

  1. 虚拟机启动时初始化用户的主类
  2. 使用new指令时,初始化对应的类
  3. 调用静态方法时,初始化静态方法所在的类
  4. 调用静态字段时,初始化静态字段所在的类
  5. 子类初始化会触发父类初始化
  6. 一个接口定义了default方法,那么直接或者间接实现该方法的类初始化会触发接口的初始化
  7. 使用反射API调用时,初始化这个类
  8. 初次调用MethodHandle时,初始化MethodHandle所在的类,这个是lambda延迟执行的原理。

类加载中异常

  • ClassNotFoundException:一般是反射调用类,触发类加载时找不到相关的类抛出异常。
  • NoClassDefFoundError:一般显示引用一个类,比如new关键词,但是类却加载不到导致的异常。一般是由于ClassNotFoundException类加载找不到但又显示引用了该类触发该异常。与ClassNotFoundException的区别就是是否显示引用了该类

提问解答

那么开始回答问题
1. 双亲委派模型是什么?
上述加载流程是 使用parent加载器加载类 -> parent不存在使用BootStrapClassLoader加载 -> 加载不到则使用子类的加载策略,这里要注意BootStrapClassLoader是由C++实现的JVM内部的加载工具,其没有对应的Java对象,因此不在这个委派体系中,类加载器本质上是装饰者模式组合思想的应用.

那么双亲是什么? 看ClassLoader的注释就能发现这只是个翻译问题parent->双亲,明明是单亲委派,装饰者模式是单类增强委托.
RednaxelaFX关于这点的证实

2. 委派模型如何实现?
委派模型从设计模式角度来看是一种组合设计,双亲委派这里更像是使用桥接模式实现的委托机制,由继承图可以发现ExtClassloaderAppClassloader处于同一层级,其内部又可以通过持有对应的private final ClassLoader parent达到桥接委派的目的。

3. 为什么使用委派模型?
回答这个问题要先了解Java中是如何判定两个类是同一个类状况,如下段官方所说,也就是全类名(包括包名)相同并且他们的类加载器相同,那么两个对象才是等价的.

1
2
3
At run time, several reference types with the same binary name may be loaded simultaneously by different class loaders. 
These types may or may not represent the same type declaration.
Even if two such types do represent the same type declaration, they are considered distinct.

对于Object类因为父加载器先加载所以能保证对于所有Object的子类其所对应的Object都是由同一个ClassLoader所加载,也就保证了对象相等. 简单来说委托类优先模式保证了加载器的优先级问题,让优先级高的ClassLoader先加载,然后轮到优先级低的.

热加载的技术原理

热部署对于开发阶段的实用性极高,利用Jrebel等工具可以极大的节省应用调试时间.关于热加载技术可以参考文章http://www.hollischuang.com/archives/606,
对于一个被ClassLoader加载到内存的类来说,再次加载的时候就会被findLoadedClass()方法所拦截,其判断该类已加载,则不会再次加载,那么热加载的技术本质是要替换到已加载的类.

对于Spring Boot devtools的restart技术,其是使用了两个ClassLoader,对于开发者所写的类使用自定义的ClassLoader,对于第三方包则使用默认加载器,那么每当代码有改动需要热加载时,丢弃自定义的ClassLoader所加载的类,然后重新使用其加载,如此做到了热部署.

对于Jrebel使用的貌似是修改类的字节码方式,具体不是很懂也就不讨论了.

对于Tomcat,其热部署技术是每次清理之前的引用,然后创建一个新的ClassLoaderWebClassLoader来重新加载应用,这个加载使得永久代中对象增多,那么清理要求是full GC,这个是不可控的,所以也就导致了Tomcat热部署频繁会触发java.lang.OutOfMemoryErrorPermGen space这个bug.

ClassLoader如何实现动态加载jar,实现插件模式系统?

ClassLoader的委派模型使得很容易扩展自定义的类加载器,那么基本步骤 定义自己的类加载器 -> 加载指定jar -> 创建所需要的应用实例,大概代码如下.

1
2
3
4
5
6
7
8
9
10
String jarPath = "/Users/niuli/workspace/quding-git/quding-study/helloworld/target/hello-world-1.0-SNAPSHOT.jar";
URL jarUrl = new File(jarPath).toURI().toURL();
//加载该jar
URLClassLoader loader = new URLClassLoader(new URL[]{jarUrl},Thread.currentThread().getContextClassLoader());
//获取插件Class对象
Class helloClass = loader.loadClass("com.itoolshub.hello.HelloWorld");
//创建该对象
IHelloWorldService helloWorldService = (IHelloWorldService) helloClass.newInstance();
//调用方法
helloWorldService.sayHello();

另外插件模式的话一般还会有一些配置文件plugin.xml,告诉系统主要对外提供服务的类是什么以及一些默认配置等.不过大概思路都是大同小异.

另外既然有装载也就有卸载,卸载的必要条件是以下三个外,另外类是装载在永久代,那么卸载的触发也就是full GC才会去清理永久代中没有被强引用指向的类.

  1. 该类所有的实例都已经被GC。
  2. 加载该类的ClassLoader实例已经被GC。
  3. 该类的java.lang.Class对象没有在任何地方被引用。

补充题目

双亲委派模型中,从顶层到底层,都是哪些类加载器,分别加载哪些类?
Bootstrap ClassLoader: JVM自身需要的一些类,当Classloader中parent为null时执行该加载器尝试加载
ExtClassLoader: 主要加载java.ext.dirs
AppClassLoader; 主要加载java.class.path下的类,包括用户定义的类
纠正双亲委派模型,实际上就是装饰者模式应用.

双亲委派模型破坏举例
1.双亲委派模型是JDK1.2发布的模式,在这之前开发者是重写loadClass()这个方法实现自定义加载逻辑,该方法中又是双亲委派模型的关键算法,那么重写完全可以破坏该模型.
2.Java的SPI机制

1
2
3
4
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}

SPI一些官方接口是定义在rt.jar中的,那么其所使用的是BootstrapClassloader,然而我们引入的第三方实现却是AppClassloader所管理,那么这里的问题就是BootstrapClassloader无法加载AppClassloader所管理的内容,也就是双亲委派无法逆序执行.那么想要逆序就需要破坏这一约束.利用Thread中上下文加载器来实现,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是AppClassloader,那么在BootstrapClassloader使用SPI加载时,则会利用线程上下文加载器委托AppClassloader加载其实现类,那么这一过程与双亲委派相反,是破坏双亲委派原则的一种做法.

1
2
3
4
5
6
7
try {
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
// 设置为AppClassloader
Thread.currentThread().setContextClassLoader(this.loader);

3.热部署,热加载技术等都是破坏了双亲委派模型.

Java--Java中的四种引用
Mybatis源码分析(四)--TypeHandler的解析