深入JVM类加载器机制,值得你收藏

先来一道题,试试水平

public static void main(String[] args) {                             
    ClassLoader c1 = ClassloaderStudy.class.getClassLoader();
    ClassLoader c1Parent = ClassloaderStudy.class.getClassLoader().getParent();
    ClassLoader c1ParentParent =   ClassloaderStudy.class.getClassLoader()
                                .getParent().getParent();
    ClassLoader currentThreadClassloader = Thread.currentThread()
                                .getContextClassLoader();                            
    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();

    //下面打印的结果是什么?
    System.out.println(c1);
    System.out.println(c1Parent);
    System.out.println(c1ParentParent);
    System.out.println(c1 == currentThreadClassloader);
    System.out.println(c1 == systemClassLoader);
}

上面的打印结果你猜对了吗?

/D:/github/java_common/target/classes/
sun.misc.Launcher|AppClassLoader@18b4aac2
sun.misc.Launcher|ExtClassLoader@1a86f2f1
null
true
true

类加载器都有哪些

JVM类加载器总共有三种,每种类加载器的职责和实现上都不一样,不同的类加载器负责不同类路径的加载,列表如下:

  1. 根类加载器 (BootstrapClassloader)
  2. 扩展类加载器 (ExtensionClassloader)
  3. 系统类加载器 (ApplicationClassloader)

BootstrapClassloader主要负责Java的核心类加载(jre/lib/..),用C++实现的,它并没有继承Classloader,通常它也叫做引导类加载器,设计到虚拟机的实现细节,不允许开发者直接获取到根类加载器的引用,在执行java的命令中使用-Xbootclasspath选项来扩展根类加载器的加载路径或者重新指定路径

  1. -Xbootclasspath: 完全取代基本核心的Java class 搜索路径.不常用,否则要重新写所有Java 核心class
  2. -Xbootclasspath/a: 后缀在核心class搜索路径后面.常用!!
  3. -Xbootclasspath/p: 前缀在核心class搜索路径前面.不常用,避免引起不必要的冲突.

比如我在ide中的配置,我需要配置的cldrdata.jar在核心class搜索路径的后面,所以配置代码如下
-Xbootclasspath/a:D:/sdk/jdk8/jre/lib/ext/cldrdata.jar

//获取根类加载器的加载的路径
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for(URL url : urls){
    System.out.println(url.toExternalForm());
}

ExtensionClassloader主负责jre的扩展目录jar加载(jre/ext/...),或者你可以通过一个属性来指定(java.ext.dirs)哪个目录被扩展类加载器加载,它是由Java语言实现的,下面代码可以获取扩展类加载器加载的路径,开发人员可以使用这个类加载器,它的实现代码位置位于sun.misc包下,这个类是继承java.lang.Classloader类的,如下:
sun.misc.Launcher$ExtClassLoader

System.out.println(System.getProperty("java.ext.dirs"));
//当然你也可以通过这个方法指定扩展类加载器加载的路径
System.setProperty("java.ext.dirs","value");

ApplicationClassloader主要负责加载用户类路径(classpath)所指定的类,开发人员可以使用这个类加载器,你可以通过属性(java.class.path)获取由该类加载器加载的路径,同时你可以通过这个属性设置该类加载器加载的路径,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器,这个类也是继承java.lang.Classloader类的,系统类加载器的源码实现地址如下:
sun.misc.Launcher$AppClassLoader

JVM的类加载器机制是什么

JVM的类加载机制主要分为三种,这三种类加载机制相互配合,保证了JVM类加载的完整性,正确性,可扩展性。当然也有缺点。

  1. 全盘负责,但某个class文件被一个类加载器加载的时候,该class文件所依赖的class和所引用的class文件都将由这个类加载器进行加载。除非你显示的用代码来使用另一个类加载器来操作。
  2. 双亲委托,当类加载器加载一个class文件时,总是先询问自己的父类加载器是否能够加载这个class文件,如果自己的父类加载器可以加载,那么就交给父类加载器,如果父类不可以,则自己加载。
  3. 缓存机制,缓存机制保证所有被加载过的class文件都会被缓存,当程序中需要使用某个class文件时先从缓存中获取,缓存中没有时才会加载,这样会保证一个class文件只会被加载一次。

一单一个类被加载到JVM中,就不会被再次加载了,在JVM中类的唯一标识是加载该类的类加载器加上该类的全限定类名。在JAVA中类的标识是类的全限定类名,这两个是有点不一样的,大家记住了。

类加载机制的优点

这样的类加载机制使得类加载有了层次和优先级的关系,这种关系可以避免类的重复加载,可以保证类加载的安全(Java核心API不被随意替换),例如类java.lang.Integer,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都会委派给启动类加载器进行加载,因此Integer类在程序的各种类加载器环境中都是同一个类。否则,你想一想,如果用户自己写了一个名为java.lang.Integer的类,并放在程序的classpath中,那系统中将会出现多个不同的Integer类,Java类型体系中最基础的行为也无法保证,Integer将会被多个不同的类加载器加载,应用程序也会变得一片混乱。

类加载机制的缺点

上面描述的类加载机制看似完美,但真的如此吗?双亲委派机制总共被破坏过三次,这正是它的缺点所导致的结果,我们一一来看。
第一次:双亲委派模型时出现在jdk1.2版本的,但在jdk1.2之前呢?java.lang.ClassLoader是在1.0时候就存在的,面对已经存在的用户自定义类加载器的实现代码,Java开发者们是这样的设计的,jdk1.2的时候在Classloader类中添加了一个方法(findClass),如果你看过源代码,你会发现这个这个抛出了一个异常throws ClassNotFoundException,为什么呢?1.2之前,开发者们去继承Classloader的唯一目的就是重新loadClass()方法使得使用自定义的类加载器,那么1.2之后,使用了双亲委派模型,开发者需要重写的是findClass()方法,因为在loadClass()方法中,如果父加载器加载失败后,会调用子类的findClass()方法,这样就保证了双亲委派模型,这就是也是双亲委派模型的实现。

第二次:Java应用程序中一般都是上层调用下层,核心API总是被作为最底层来提供服务,它们总是基础,那么有没有可能基础调用上层,比如Integer类调用开发人员写的Java类呢,这是有可能的事情,一个典型的例子就是JNDI,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC等.

第三次:由于用户对程序的动态性的追求导致的(模块化动态部署,升级,卸载),例如OSGI的出现。在OSGI环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构,OSGi 是目前动态模块系统的事实上的工业标准,它适用于任何需要模块化、面向服务、面向组件的应用程序,蚂蚁的SOFA中间件就是用了OSGI,SOFA已经开源了, 自行了解OSGI。

类加载机制的实现

在Classloader的源代码中,我们看如下代码:

public Class loadClass(String name)  {
    return loadClass(name, false);
}

loadClass又调用了本类的一个重载方法,代码如下

//resolve 这个字段表示加载类时是否进行链接操作,默认否
protected Class loadClass(String name, boolean resolve){
    //判断该类是否已经加载
    Class c = findLoadedClass(name);
    if (c == null) {
          try {
             //如果父类加载器不为空,调用父类的loadClass方法
              if (parent != null) {
                c = parent.loadClass(name, false);
              } else {
                  c = findBootstrapClassOrNull(name);
              }
          } catch (ClassNotFoundException e) {
             //如果父类加载失败
          }
          //调用子类的findClass方法
          if (c == null) {
              c = findClass(name);
          }
    }
   //如果为true,则进行链接操作
   if (resolve) {
         resolveClass(c);
   }
   //返回加载后的字节码
   return c;
}

开发人员我们常用的类加载的方法主要有两种,这两种方法有一些区别,我们一起来看看他们区别在哪里呢?
Class.forName(String className),Classloader.loadClass(String className)
这两个方法的入参都是类的全限定类名,两个方法都被重载了,重载后的如下方法如下,我们可以看到,重载方法入参都有boolean参数,前者默认值是true,代表默认进行初始化,后者默认值时flase,代表默认不进行链接操作。这下清楚了吧。

private static native Class forName0(String name, boolean initialize,
                                        ClassLoader loader,
                                        Class caller)
protected Class loadClass(String name, boolean resolve)                                   

再来说说URLClassloader的作用,URLClassloader继承了Classloader,它提供了什么新的作用吗,其实Ext和App的Classloader是继承了URLClassloader的,一般动态加载类都是直接用Class.forName()这个方法,但这个方法只能创建程序中已经引用的类,并且只能用包名的方法进行索引,比如Java.lang.String,不能对一个.class文件或者一个不在程序引用里的.jar包中的类进行创建。URLClassLoader提供了这个功能,它让我们可以通过以下几种方式进行加载:   

  • 文件: (从文件系统目录加载)   
  • jar包: (从Jar包进行加载)   
  • Http: (从远程的Http服务进行加载)

线程上下文类加载器,其实上面已经提到了这个设计的引入的作用,Java 应用的线程的上下文类加载器默认就是系统上下文类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到,比如在dubbo中的核心实现就是SPI,那么就会用到线程上下文类加载器。

//核心就是下面,省略了安全代码
public void setContextClassLoader(ClassLoader cl) {
    contextClassLoader = cl;
}
public ClassLoader getContextClassLoader() {
    return contextClassLoader;
}

什么是类隔离能力,怎么实现

假设小明遇到的问题如下,你的项目中需要引入两个三方组件:消息中间件(A)和和微服务中间件(B),组件A需要依赖guava19.0,组件B需要依赖guava23.0,因为guava19.0和guava23.0 是不兼容的,怎么办?

作为开发者,遇到这种包冲突问题,如果不借助类隔离框架,只能耗费精力升级到统一版本

所谓类隔离就是应用程序中不同的包使用不同的类加载进行加载,比如消息中间件使用M类加载器加载,微服务使用N类加载器加载,这样guava19.0和guava.23会被不同的类加载加载从而实现jar通途解决。看到这可能有人问了guava不久被加载两次了啊?上面说过,JVM类加载中类加载的唯一标识是类加载+类的全限定类名。

蚂蚁金服开源了一个框架SOFAArk,他是一个轻量级的Java类加载隔离框架,使用Java语言进行开发的。他的原理就是通过独立的类加载器加载相互冲突的三方依赖包,从而做到隔离包冲突,怎么实现呢?
原因是Ark Plugin,它是 SOFAArk 框架定义的一种特殊的JAR包文件格式,在遇到包冲突时,用户可以使用Maven插件将若干冲突包打包成Plugin,运行时由独立的 PluginClassLoader加载,从而解决包冲突.

还有Tomcat的应用间的类加载隔离能力,比如:在一个Tomcat内部署多个应用,甚至多个应用内使用了某个类似的几个不同版本,但它们之间却互不影响。这是如何做到的,原因是当一个应用启动的时候,会为其创建对应的WebappClassLoader(本质是上下文类加载器),细节不在这里说了。

用一张图总结

深入JVM类加载器机制,值得你收藏_第1张图片

扫描下方二维码,关注公众号:技术人技术事 ,阅读更多精彩文章,一起交流。

深入JVM类加载器机制,值得你收藏_第2张图片

你可能感兴趣的:(深入JVM类加载器机制,值得你收藏)