纯原创长文-深入理解Java类加载(双亲委派机制及其破坏 JavaSPI和DubboSPI的区别和联系)

  网络上一搜关于Java类结构,类加载,动态代理,JavaSPI和DubboSPI等问题,都有很多答案,大多数都是复制粘贴,没有哦干货,其实这些知识点之间存在密切的联系,在看《深入理解JVM》的时候,学习到Class加载知识的时候,书中讲解了双亲委派模型及其缺陷(我认为这个也不算缺陷,因为设计双亲委派模型的时候,并没有SPI扩展的概念,只是这种加载规则不能满足后来的SPI),以及Java是如何兼容SPI的加载方式的,因为手头也正在做基于Dubbo的项目,之前只了解Dubbo的SPI扩展使用,并未深究原理,正好拿来进行对比,百度到的介绍DubboSPI的时候总会先介绍JavaSPI并阐述其缺点,例如不能按需实例化,不支持IOC,线程不安全等,首先声明:个人认为,这些不是JavaSPI的缺点,JavaSPI和DubboSPI解决的不是一个问题,所以根本不用拿来比较,文章中也会对这一点进行详细解释。既然这些知识点存在密切联系,那我们就逐个知识点开始介绍吧,希望这样读者看完能有个完整的知识结构,好了,现在就开始吧。

  类加载流程:

    准确来说是类的加载流程,是整个类生命周期里的一部分,类的整个生命周期包括:加载(注意区分类加载和加载过程的区别),验证,准备,解析,初始化,使用,卸载这7个阶段。

    我们这里讨论的类加载流程包含前边的加载,验证,准备,解析,初始化这四个阶段。其中验证,准备,解析也可以统称为链接阶段。

    加载阶段:这里指的是把.class文件加载到虚拟机的过程,JVM对.class文件的来源没有严格的限制,这也是各种动态技术的基础保证,例如动态代理,热加载等技术(这里读者感兴趣可以了解下Java的OSGI),总之这个步骤把文件加载到了虚拟机中,供后面的步骤使用

    验证阶段:这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,包括文件格式验证,元数据验证,字节码验证,符号引用验证,关于验证其实细节非常麻烦,比较简单的是格式校验,会按照我们之前介绍的Class格式校验,符号引用验证会在后边的解析阶段进行,其他的验证非常复杂与我们本文的核心内容关系不大,读者有兴趣请自行扩展。

    准备阶段:这个阶段会为类中定义的静态变量(static int a = 123)分配内存空间并赋初始值,这些都是在方法区中进行分配,并且只是初始值,此时a的值为0而不是123,因为0是int类型的零值,而把123幅给a是在类的初始化阶段

    解析阶段:解析阶段是将符号引用转为直接引用的过程,"符号引用"指的是用一组符号来描述引用的目标,只要是符合Class文件定义的字面量就可以,不能寻址到内存中真正引用的对象。"直接引用"是直接指向内存中引用对象的指针,只要变为了直接引用,那么引用的对象一定存在内存中。虚拟机规范对解析阶段的发生没有明确的要求,虚拟机可以根据需要自行判断,只要在使用该引用前转为直接引用就可以,解析包含类和接口的解析,字段解析,方法解析,以与本文内容相关的类的解析为例子,假设当前解析的代码在类A中,A类中引用了B类,那么就会把B的权限定类名交给A的类加载器去加载,这里要注意,由A触发B加载的话,会由触发方的类加载器去加载,这是重点,要考!

    初始化阶段:这时候才会真正给静态变量去赋值,以及执行静态代码块中的内容,最终生成Class对象在方法区中,这时候,一个类的定义就算比较完整了。

  双亲委派模型:

    JDK提供三个类加载器:

    BootStrapClassLoader:负责加载<JAVA_HOME>\lib目录下的class文件,这个类是C语言实现,我们只能看到它的一些本地方法,下边代码中我们会看到

    Launcher$ExtClassLoader:负责加载<JAVA_HOME>\lib\ext目录下或者java.ext.dir变量配置的目录下的class文件,java实现

    Launcher$AppClassLoader负责加载用户classpath下的class文件

    其他的加载器:

    ServiceLoader:SPI使用约定加载class path:META-INF/接口全限定名 文件内容指定的类

    我们来看一下这三个类的关系,注意,这里不是继承关系,而是由成员变量parent来定义的的父子关系:

    

 

    当应用启动的时候,首先创建JVM,此时主要是使用BootstrapClassLoader和扩展类加载器加载JVM的基础类,之所以叫基础类,一是因为这些类是支撑JVM运行的保证,还有一方面就是很多类都是要被用户的类继承作为父类的。其中就包括后面我们要重点关注的一个类DriverManager。

    那么,双亲委派模型是什么呢?

    双亲委派模型其实就是Java定义的一个类加载规范,或者说类加载器规范,当一个类要被加载时候,首先会交给他自己的加载器,我们还用类A来举例,A是用户编写的一个类,当A被加载的时候,首先是负责加载用户classpath下的AppClassLoader加载器进行加载,但是,AppClassLoader并不会直接进行加载,为了避免重复加载,先会去缓存中查找是不是已经加载过了,如果没有加载过,会判断有没有parent,有的话会交给父加载器进行加载,父加载器也是同样的逻辑,这样一直向上委派,知道某个加载器在缓存中查找到或者没有父加载器了,才会尝试去加载这个类。根据这个逻辑,A第一次加载最终会委派给启动类加载器进行加载,但是我们提到,启动类加载器扫面的范围并不包括用户的classpath路径,所以加载不到,当父加载器加载不到的时候,又回依次尝试回到下级加载器进行加载,如果所有的加载器都加载不到的情况下,最终会抛出ClassNotFound异常。下面我们来看下代码是怎么实现的:

 

 

 

 

 

 

 

   通过以上的代码片段和文字描述,读者应该对类加载器的双亲委派加载流程有了一定的理解,那么,为什么要设计这个好像有点反常识的机制呢?

  上文中形容JDK提供的类的时候用了基础这个形容词,以及为什么是基础的,举例来说,我们大家都熟知的java.lang.Object类,是所有类继承的父类,一个类就算没有显示的继承任何类,也会在class文件中体现出继承自这个基础类,所以,虚拟机必须要保证这个类是来自于JDK提供的,设想,如果我们在classpath下也定义了全限定类名为java.lang.Object的类,当加载的时候,如果直接由负责加载用户文件的AppClassLoader加载的话,也是能加载成功的,只不过内存中就会存在两个Object类,一个是由启动类加载的,一个是由系统加载器加载的,这两个类可以同时存在,但是使用的时候就混乱了,不知道该用哪个,为了避免这种混乱和保护JVM自身,才诞生了这种向上委派的机制,保证JVM需要的基础类不会被用户修改。

  打破双亲委派-JavaSPI:

  这种方式看起来比较完美的解决了问题,但是随着Java的流行,越来越多的基础类被纳入到了JDK中,其中就包含DriverManager,这是JDBC规范的实现类,但是JDK并不是做数据库的产品,而是定义了标准的接口让数据库厂商来实现,这种方式就叫做SPI Service Provider Interface 服务提供接口,但是这种模式就和向上委派的逻辑产生了冲突,因为我们知道,数据库厂商的具体实现一半都是被我们引入用户classpath下的,而基础类是定义在jdk里的由启动类加载器加载的,当加载到DriverManager的时候,按照SPI的规范使用ServiceLoader去加载用户路径下META-INF下以基础接口全限定为文件名的文件,内容是实现类的全限定类名,之前在说加载的过程中提到了,如果一个类触发了另一个类的加载,那么会使用触发类的加载器进行加载,这里就是启动类加载器,但是很明显,实现类在classpth下而启动类加载器不会加载这个路径,就导致无法加载,在这种情况下,在Thread类里添加了属性contextClassLoader,这不是一种新的ClassLoader,子线程创建时会从父线程中继承这个属性值,如果父线程没有设置,那么默认就是AppClassLoader,ServiceLoader load的时候就会通过这个加载器进行对SPI扩展实现类的加载,同时,Serviceloader继承了Iterator,在遍历的时候,就会对实现类进行实例化,看代码实现:

 

 

 

 

 这样,在SPI加载时,没有使用默认的双亲委派加载器而是使用ServiceLoader加载,解决了这个问题

同样,网上百度的答案诟病JavaSPI的一点就是会实例化所有的扩展实现类,但是仔细想想,把ServiceLoader调用交给厂商来控制还是让用户显示调用,都不合适,另外,如果你不使用的话,为什么你要引入到classpath下呢?所以,说这个问题的,要么没动脑筋想,要么是没理解使用方式。

    我们来简单看一个例子:DriverManager,直接看代码:DriverManager在启动类加载器的加载范围内,前面说过,在加载的初始化阶段,会执行类的静态方法块,我们来看看到底是怎么加载的

 

 

就是通过ServiceLoader使用AppClassLoader加载实现类,然后迭代对实现类进行实例化

 

 

 在找个实现类的例子,看看实现类初始化会发生什么:Mysql Driver,同样初始化的时候执行静态方法,注册到DriverManager

 

  DubboSPI:

    说完了Java的SPI,再来说说总是被拿来一起对比的DubboSPI,首先,DubboSPI的背景和JavaSPI的背景不一样,要解决的问题也不一样,SPI解决的是定义借口,加载实现的问题,而Dubbo的SPI则完全是为了基于Dubbo框架进行功能扩展的问题,我们来看看Dubbo的几个扩展点有Proxy,Registry,Cluster,Protocol,Exchange,Transport,Serilize,这些扩展点都是面向基于Dubbo框架的开发者,为了满足特定的需求进行定制化开发的扩展点,并且Dubbo框架也有自身的实现,在这种情况下,没有必要实例化全部的实现类并且代码编写者明确的知道在什么时机需要某个特定的实现类,某种意义上来说,对于Dubbo框架的扩展者,实现就写在扩展后的框架中,自己明确知道需要什么实现类,自然可以在合适的地方手动调用ExtensionLoad进行加载实例化特定的实现类,因此,DubboSPi解决的是框架扩展的问题,而JavaSPI解决的是解耦接口和实现类的问题。

    至于Dubbo SPi的具体实现,没什么特别的,也是加载约定目录下的文件,没了类加载器的问题(都是使用AppclassLoader),实现了按需加载,另外值得一提的是还实现了IOC和AOP的逻辑,这部分了解SpringIOC解决循环依赖和三层依赖的同学应该都大概了解了,就不赘述了,不了解的同学可以看我的这篇文章,有详细的Bean加载管理过程:https://www.cnblogs.com/yust/p/16079365.html

    另外,还有很多答案说DubboSPI对比JavaSPI的有点有IOC和AOP,这些能力,在最开始的类加载流程就保证了,请读者动脑思考。

    本文纯属个人理解,能力有限,欢迎大家讨论交流,未经个人允许禁止转载。

posted @ 2022-04-20 21:10  有一个小梦想  阅读(293)  评论(0编辑  收藏  举报