从类加载角度分析包冲突

667 阅读5分钟
冲突的背景

在实际业务场景中, 包冲突一般发生在C/S架构中,  但并不是所有C/S架构都是,特指那些Client和Server都是JVM应用的场景. 如Tomcat, Spark, Flink.

以大数据环境为🌰, 无论是Spark还是Flink, 其本质都是一个具有分布式计算能力的并且具有一定特性功能的容器, 实际场景中, 一般而言, 我们会将我们的业务逻辑封装到一个具体的Client中, 然后无论是离线方式, 还是在线方式,根本上, 都是将一个包提交到Server(Spark Runtime, Flink Runtime)中去执行. 

需要注意的是, Runtime其实本质上就是一个JVM容器, 它会遵循JVM的双亲委托类加载机制. 所以在Runtime初始化的那一刻, 就会载入很多当前环境需要依赖到的包, 这些包中的类会作为缓存内容存储到特定的空间(如静态常量池, Meta Namespace Area)以做复用. 然后, 在具体客户端载入运行的时候, 也会加载所需的包, 缓存类到特定的空间.

这里需要指出的是, JVM是根据ClassLoader标识+类的完整限定名(包名+类名)来进行key-value操作的. 也就是说, 在容器初始化和客户端加载的时候, 通过ClassLoader+包名+类名的方式会将所需要的类都加载到内存空间中, 后续如果要使用就通过ClassLoader+包名+类名方式检索, 如果能检索都目标类, 则直接返回, 如果检索不到, 则通过当前ClassLoader进行加载, 加入缓存并被使用.


双亲委托机制

简单来说, 原理就是当前ClassLoader想要初始化一个类的时候, 先委托父ClassLoader进行加载,  父ClassLoader如果有则返回, 如果没有则委托给它的父ClassLoader进行加载 ,直到找到目标的Class. 具体过程如下:

1. 当前ClassLoader首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。 每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了.

 2. 当前ClassLoader的缓存中没有找到被加载的类的时候,委托父ClassLoader去加载,父ClassLoader采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到Bootstrap ClassLoader. 

 3. 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回.


ClassLoader的种类

Bootstrap ClassLoader:

最顶层的类加载器, 由C++语言实现,主要负责加载/JAVA_HOME/lib路径下的核心类库, 包括rt.jar, resources.jar, charsets.jar等, 第一个加载

Extention ClassLoader:

扩展类加载器, 主要用于加载/JAVA_HOME/lib/ext路径下的类, 第二个加载

App ClassLoader:

应用类库, 主要用于加载当前应用的classpath下的类, 第三个加载


为什么要使用双亲委托机制

其实原因很简单, 主要是为了类的复用. 举个🌰, Boostrap ClassLoader会默认加载所有核心类, 如String, 当进入当前的App ClassLoader后, 如果要使用String类, 自己再去加载一遍就有点多此一举了. 这时候如果要用String的话, 先问父ClassLoader, 父ClassLoader会以同样的方式询问, 直到找到Bootstrap ClassLoader询问并递归返回. 这就是双亲委托的全过程.


脱离双亲委托机制的情况

在Java设计中, SPI(Service Provider Interface),本质上就是脱离了双亲委托的管控. 举个🌰, JDBC接口, 是在jdk中就已经定义好了. 所以它是由Bootstrap ClassLoader进行加载的. 但是jdk仅仅是做了一层定义, 属于一个领域协议. 具体的实现由各个公司来实现. 当将某个公司的协议驱动包载入的时候, 如MySQL驱动, 按常理来说, Connection类应该会直接检索到Bootsrap ClassLoader中得到. 但是实际上, 数据库驱动的调用会在当前的Thread, 指定一个自定义的ClassLoader, 这个ClassLoader并不是以Bootsrap ClassLoader为根加载器. 所以它能理所当然的找到驱动包下的Connection对象了.


能否自定义实现一个String, 而不使用系统的String

理论上是可以的. 但是必须是非java开头的包名才可以. 因为JVM规定了java开头的包名都由Bootstrap ClassLoader加载.(待验证)


那么,  包冲突是怎么来的呢?

上述的加载过程, 我们做个假设, Server需要用到org.google.guava.a类, 所以引入mvn依赖

<dependencies>
        <!--guava依赖-->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>27.0.1</version>
        </dependency>
</dependencies>

而Client需要用到org.google.guava.b类, 所以mvn引入依赖

<dependencies>
        <!--guava依赖-->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>23.6.1</version>
        </dependency>
</dependencies>

此时, 两个包的版本不一致, 23与27相比, 改变应该会很多, 很容易就会出现b类在27版本的包下被废弃或者被rename的情况. 根据上述场景, Runtime只会加载27.0.1的包, 如果想调用b类,就会报类访问错误NoClassDefFoundError和ClassNotFoundException两种错误. 这两个错误的根本原因都是在指定classpath下找不到所需要的类导致的.那么这二者的区别是什么呢?


NoClassDefFoundError和ClassNotFoundException的区别

他们都与classpath有关, NoClassDefFoundError发生在JVM动态运行时, 根据所提供的类名在classpath中查找, 如果找不到则抛出java.lang.NoClassDefFoundError的错误.ClassNotFoundException发生在编译时, 在classpath中找不到对应的类而发生的错误.


如何解决包冲突呢?

1.善用mvn dependency:tree命令

通过将完整的包依赖树构建, 能够很清晰的分析出哪些包可能存在包冲突的可能, 重点观察版本是否一致. 如果是Spark或者Flink环境, 则需要了解本身的Runtime所引用的包与客户端的包在依赖版本上是否有冲突, 如果有则以Runtime环境的为准

2.善用idea自带的分析工具

这里写图片描述

这里写图片描述

这里写图片描述