JVM系列之类加载流程-自定义类加载器

2,772 阅读13分钟

JVM系列之类加载流程-自定义类加载器

老实说,类加载流程作者还是比较熟悉而且有实战经验的,因为有过一次自定义类加载器的实战经验(文章最后会和大家分享),虽然大部分小伙伴觉得这部分对coding没什么实际意义,如果你一直写CRUD并且用现有的高级语言业务框架,我可以告诉你,确实没什么用。但话说回来,你如果想多了解底层,并且在类加载时做一些手脚,那么这一块就很有必要学了。很多框架都是利用了类加载机制里的动态加载特性来搞事情,像比较出名的OSGI模块化(一个模块一个类加载器),JSP(运行时转换为字节流让加载器动态加载),Tomcat(自定义了许多类加载器用来隔离不同工程)...这里就不一一列举了。本文还是先把类加载流程先讲一讲,然后分享一下作者的一次自定义类加载的经验心得,概要如下:

文章结构
1 类加载的各个流程讲解
2 自定义类加载器讲解
3 实战自定义类加载器

1. 类加载的各个流程讲解

作者找了下网上的图,参考着自己画了一张类生命周期流程图:

类的生命周期图
类的生命周期图

注意点:图中各个流程并不是严格的先后顺序,比如在进行1加载时,其实2验证已经开始了,是交叉进行的。

加载

加载阶段说白了,就是把我们编译后的.Class静态文件转换到内存中(方法区),然后暴露出来让程序员能访问到。具体展开:

  • 通过一个类的全限定名来获取定义此类的二进制字节流(可以是.class文件,也可以是网络上的io,也可以是zip包等)
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中(HotSpot的实现其实就是在方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

验证

加载阶段获得的二进制字节流并不一定是来自.class文件,比如网络上发来的,那么如果不进行一定的格式校验,肯定是不能加载的。所以验证阶段实际上是为了保护JVM的。对于一般Javaer来说,俺们都是.java文件编译出来的.class文件,然后转换成相应的二进制流,没啥危害。所以不用太关心这一部分。

准备

准备阶段主要是给static变量分配内存(方法区中),并设置初始值。
比如: public static Integer value =1;在准备阶段的值其实是为0的。需要注意的是常量是在准备阶段赋值的:
public static final Integer value =1 ;在准备阶段value就被赋值为了1;

解析

解析阶段就更抽象了,稍微说一下,因为不太重要,有两个概念,符号引用直接引用。说的通俗一点但是不太准确,比如在类A中调用了new B();大家想一想,我们编译完成.class文件后其实这种对应关系还是存在的,只是以字节码指令的形式存在,比如 "invokespecial #2" 大家可以猜到#2其实就是我们的类B了,那么在执行这一行代码的时候,JVM咋知道#2对应的指令在哪,这就是一个静态的家伙,假如类B已经加载到方法区了,地址为(#f00123),所以这个时候就要把这个#2转成这个地址(#f00123),这样JVM在执行到这时不就知道B类在哪了,就去调用了。(说的这么通俗,我都怀疑人生了).其他的,像方法的符号引用,常量的符号引用,其实都是一个意思,大家要明白,所谓的方法,常量,类,都是高级语言(Java)层面的概念,在.class文件中,它才不管你是啥,都是以指令的形式存在,所以要把那种引用关系(谁调用谁,谁引用谁)都转换为地址指令的形式。好了。说的够通俗了。大家凑合理解吧。这块其实不太重要,对于大部分coder来说,所以我就通俗的讲了讲。

初始化

这一块其实就是调用类的构造方法,注意是类的构造方法,不是实例构造函数,实例构造函数就是我们通常写的构造方法,类的构造方法是自动生成的,生成规则:
static变量的赋值操作+static代码块
按照出现的先后顺序来组装。
注意:1 static变量的内存分配和初始化是在准备阶段.2 一个类可以是很多个线程同时并发执行,JVM会加锁保证单一性,所以不要在static代码块中搞一些耗时操作。避免线程阻塞。

使用&卸载

使用就是你直接new或者通过反射.newInstance了.
卸载是自动进行的,gc在方发区也会进行回收.不过条件很苛刻,感兴趣可以自己看一看,一般都不会卸载类.

2. 自定义类加载器讲解

2.1 类加载器

类加载器,就是执行上面类加载流程的一些类,系统默认的就有一些加载器,站在JVM的角度,就只有两类加载器:

  • 启动类加载器(Bootstrap ClassLoader):由C++语言实现(针对HotSpot),负责将存放在<JAVA_HOME>/lib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中。
  • 其他类加载器:由Java语言实现,继承自抽象类ClassLoader。如:
    • 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>/lib/ext目录或java.ext.dirs系统变量指定的路径中的所有类库。
    • 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
    • 自定义类加载器,用户根据需求自己定义的。也需要继承自ClassLoader.

2.2 双亲委派模型

如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。见下图:

双亲委派模型
双亲委派模型

需要注意的是,自定义类加载器可以不遵循双亲委派模型,但是图中红色区域这种传递关系是JVM预先定义好的,谁都更改不了。双亲委派模型有什么好处呢?举个例子,比如有人故意在自己的代码中定义了一个String类,包名类名都和JDK自带的一样,那么根据双亲委派模型,类加载器会首先传递到父类加载器去加载,最终会传递到启动类加载器,启动加载类判断已经加载过了,所以程序员自定义的String类就不会被加载。避免程序员自己随意串改系统级的类。

2.3 自定义类加载器

上面说了半天理论,我都有点迫不及待的想上代码了。下面看看如何来自定义类加载器,并且如何在自定义加载器时遵循双亲委派模型(向上传递性).其实非常简单,在这里JDK用到了模板的设计模式,向上传递性其实已经帮我们封装好了,在ClassLoader中已经实现了,在loadClass方法中:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查是否已经加载过。
        Class c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                //2 .如果没有加载过,先调用父类加载器去加载
                    c = parent.loadClass(name, false);
                } else {
                // 2.1 如果没有加载过,且没有父类加载器,就用BootstrapClassLoader去加载
                c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                //3. 如果父类加载器没有加载到,调用findClass去加载
                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;
    }
}

从上面代码可以明显看出,loadClass(String, boolean)函数即实现了双亲委派模型!整个大致过程如下:

  1. 检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
  2. 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);).或者是调用bootstrap类加载器来加载。
  3. 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。默认的findclass毛都不干,直接抛出ClassNotFound异常,所以我们自定义类加载器就要覆盖这个方法了。
  4. 可以猜测:ApplicationClassLoader的findClass是去classpath下去加载,ExtentionClassLoader是去java_home/lib/ext目录下去加载。实际上就是findClass方法不一样罢了

由上面可以知道,抽象类ClassLoader的findClass函数默认是抛出异常的。而前面我们知道,loadClass在父加载器无法加载类的时候,就会调用我们自定义的类加载器中的findeClass函数,因此我们必须要在loadClass这个函数里面实现将一个指定类名称转换为Class对象.
如果是是读取一个指定的名称的类为字节数组的话,这很好办。但是如何将字节数组转为Class对象呢?很简单,Java提供了defineClass方法,通过这个方法,就可以把一个字节数组转为Class对象啦~

defineClass:将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组.

protected final Class<?> defineClass(String name, byte[] b, int off, int len)
        throws ClassFormatError  {
        return defineClass(name, b, off, len, null);

上面介绍了自定义类加载器的原理和几个重要方法(loadClass,findClass,defineClass),相信大部分小伙伴还是一脸蒙蔽,没关系,我先上一副图,然后上一个自定义的类加载器:

自定义类加载器方法调用流程图
自定义类加载器方法调用流程图

样例自定义类加载器:

import java.io.InputStream;
public class MyClassLoader extends ClassLoader
{
    public MyClassLoader()
    {
    }
    public MyClassLoader(ClassLoader parent)
    {
        //一定要设置父ClassLoader不是ApplicationClassLoader,否则不会执行findclass
        super(parent);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException
    {
    //1. 覆盖findClass,来找到.class文件,并且返回Class对象
        try
        {
            String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
            InputStream is = getClass().getResourceAsStream(fileName);
            if (is == null) {
            //2. 如果没找到,return null
                return null;
            }
            byte[] b = new byte[is.available()];
            is.read(b);
            //3. 讲字节数组转换成了Class对象
            return defineClass(name, b, 0, b.length);
        }
        catch (Exception e)
        {
            e.printStackTrace();
        }
        return null;
    }
}

稍微说一下:
其实很简单,继承ClassLoader对象,覆盖findClass方法,这个方法的作用就是找到.class文件,转换成字节数组,调用defineClass对象转换成Class对象返回。就这么easy..
演示下效果:

        MyClassLoader mcl = new MyClassLoader();
        Class<?> c1 = Class.forName("Student", true, mcl);
        Object obj = c1.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println(obj instanceof Student);

返回结果:
sun.misc.Launcher$AppClassLoader@6951a712
true

        MyClassLoader mcl = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent());
        Class<?> c1 = Class.forName("Student", true, mcl);
        Object obj = c1.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println(obj instanceof Student);

返回结果:
MyClassLoader@3918d722
false

重点分析:
第一个代码和第二个代码唯一一点不同的就是在new MyClassLoader()时,一个传入的ClassLoader.getSystemClassLoader().getParent();(这个其实就是扩展类加载器)

  1. 当不传入这个值时,默认的父类加载器为Application ClassLoader,那么大家可以知道,在这个加载器中已经加载了Student类(ClassPath路径下的Student类),我们在调用Class.forName时传入了自定义的类加载器,会调用自定义类加载器的loadClass,判断自己之前没有加载过,然后去调用父类的(ApplicationClassLoader)的loadClass,判断结果为已经加载,所以直接返回。所以打印ClassLoader为AppClassLoader.
    验证默认父类加载器为ApplicationClassLoader:

         MyClassLoader mcl = new MyClassLoader();
         System.out.println(mcl.getParent().getClass());

    打印结果:class sun.misc.Launcher$AppClassLoader

  2. 当我们传入父类加载器为扩展类加载器时,当调用父类(扩展类加载器)的loadeClass时,由于扩展类加载器只加载java_home/lib/ext目录下的类,所以classpath路径下的它不能加载,返回null,根据loadClass的逻辑,接着会调用自定义类加载器findClass来加载。所以打印ClassLoader为MyClassLoader.

  3. instanceof返回true的条件是(类加载器+类)全部一样,虽然这里我们都是一个Student类,一个文件,但是由两个类加载器加载的,当然返回false了。
  4. 在JVM中判断一个类唯一的标准是(类加载器+.class文件)都一样.像instanceof和强制类型转换都是这样的标准。
  5. 注意,这里所说的父类类加载器,不是以继承的方式来实现的,而是以成员变量的方式实现的。当调用构造函数传入时,就把自己的成员变量parent设置成了传入的加载器。
  • 课外衍生:这里作者是遵循了双亲委托模型,所以覆盖了findClass,没有覆盖loadClass,其实loadClass也是可以覆盖的,比如你覆盖了loadClass,实现为"直接加载文件,不去判断父类是否已经加载",这样就打破了双亲委托模型,一般是不推荐这样干的。不过小伙伴们可以试着玩玩.

自定义类加载器就给大家说完了,虽然作者感觉已经讲清楚了,因为无非就是几个方法的问题(loadClass,findClass,defineClass),但还是给大家几个传送门,可以多阅读阅读,相互参阅一下:
www.cnblogs.com/xrq730/p/48…
www.importnew.com/24036.html

3. 实战自定义类加载器

其实上面基本已经把自定义类加载器给讲清楚了,这里和大家分享一下作者一次实际的编写自定义类加载器的经验。背景如下:
我们在项目里使用了某开源通讯框架,但由于更改了源码,做了一些定制化更改,假设更改源码前为版本A,更改源码后为版本B,由于项目中部分代码需要使用版本A,部分代码需要使用版本B。版本A和版本B中所有包名和类名都是一样。那么问题来了,如果只依赖ApplicationClassLoader加载,它只会加载一个离ClassPath最近的一个版本。剩下一个加载时根据双亲委托模型,就直接返回已经加载那个版本了。所以在这里就需要自定义一个类加载器。大致思路如下图:

双版本设计图
双版本设计图

这里需要注意的是,在自定义类加载器时一定要把父类加载器设置为ExtentionClassLoader,如果不设置,根据双亲委托模型,默认父类加载器为ApplicationClassLoader,调用它的loadClass时,会判定为已经加载(版本A和版本B包名类名一样),会直接返回已经加载的版本A,而不是调用子类的findClass.就不会调用我们自定义类加载器的findClass去远程加载版本B了。

顺便提一下,作者这里的实现方案其实是为了遵循双亲委托模型,如果作者不遵循双亲委托模型的话,直接自定义一个类加载器,覆盖掉loadClass方法,不让它先去父类检验,而改为直接调用findClass方法去加载版本B,也是可以的.大家一定要灵活的写代码。

结语

好了,JVM类加载机制给大家分享完了,希望大家在碰到实际问题的时候能想到自定义类加载器来解决 。Have a good day .