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的增强处理,再看初始化方式可以简化为

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

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

加载流程

加载

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

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中是如何判定两个类是同一个类状况,如下段官方所说,也就是全类名(包括包名)相同并且他们的类加载器相同,那么两个对象才是等价的.

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 -> 创建所需要的应用实例,大概代码如下.

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机制

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加载其实现类,那么这一过程与双亲委派相反,是破坏双亲委派原则的一种做法.

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.热部署,热加载技术等都是破坏了双亲委派模型.

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏与神兽党一起成长

使用commons-pool管理FTP连接

在封装一个FTP工具类文章,已经完成一版对FTP连接的管理,设计了模板方法,为工具类上传和下载文件方法的提供获取对象和释放对象支持。

1322
来自专栏Django Scrapy

JavaScript注释规范

2113
来自专栏码洞

深度学习Java之内存模型【译】

Java的内存模型定义了Java虚拟机如何和计算机物理内存进行交互。Java虚拟机是一体化的计算机模型,所以它自然也包含了内存模型。

831
来自专栏java学习

1.3java的运行原理

java的运行原理 这里我们简单分析一下我们的第一个应用程序,其中涉及到很多没有接触过的概念,大家可先阅读以下,以后会详细讲解。重点是理解java的运行原理。 ...

3584
来自专栏陈树义

Java并发编程:线程控制

在上一篇文章中(Java并发编程:线程的基本状态)我们介绍了线程状态的 5 种基本状态以及线程的声明周期。这篇文章将深入讲解Java如何对线程进行状态控制,比如...

4069
来自专栏desperate633

Java线程通信(Thread Signaling)利用共享对象实现通信忙等(busy waiting)wait(), notify() and notifyAll()信号丢失(Missed Sign

线程通信的目的就是让线程间具有互相发送信号通信的能力。 而且,线程通信可以实现,一个线程可以等待来自其他线程的信号。举个例子,一个线程B可能正在等待来自线程A...

922
来自专栏闻道于事

Java多线程详解

每个运行的程序就是一个进程,当一个程序运行时,内部可能包含了多个顺序执行流,每个顺序执行流就是一个进程。

1293
来自专栏xingoo, 一个梦想做发明家的程序员

MFC常用的类详细介绍

常用的MFC类 CRuntimeClass结构 在CRuntimeClass结构中定义了类名、对象所占存储空间的大小、类的版本号等成员变量及动态创建对象、派生关...

1995
来自专栏我是攻城师

深入理解Java类加载器机制

Java里面的类加载机制,可以说是Java虚拟机核心组件之一,掌握和理解JVM虚拟机的架构,将有助于我们站在底层原理的角度上来理解Java语言,这也是为什么我们...

2532
来自专栏对角另一面

lodash源码分析之Hash缓存

本文为读 lodash 源码的第四篇,后续文章会更新到这个仓库中,欢迎 star:pocket-lodash

2067

扫码关注云+社区

领取腾讯云代金券