深入理解Java类加载器机制

前言

Java里面的类加载机制,可以说是Java虚拟机核心组件之一,掌握和理解JVM虚拟机的架构,将有助于我们站在底层原理的角度上来理解Java语言,这也是为什么我们学习一个新的知识时,如果不理解原理全靠死记硬背,我相信过不了几天便会忘记的一干二净。

Java是一门跨平台的语言,而JVM虚拟机则在这中间扮演了非常重要的角色,对于我们编写的.java文件,在编译期间会被转换成二进制的class文件,我们也叫做bytecode(字节码),那么这些class文件是如何被加载进JVM虚拟机里面,又是如何被执行呢?

这就引入了今天我们文章要重点分析的知识之Java类加载器,在此之前我们重新来回顾下JVM的执行架构,借用网上的一张图片,可以非常直观的帮助我们了解:

Java虚拟机的核心由三个重要的组件构成:

(1)类加载系统

(2)运行时数据区域

(3)执行引擎

在这里面我们需要重点理解和掌握的包括,类加载机制,运行时数据区域,及执行引擎里面的GC回收器的算法和原理。

运行时数据区域在前面文章已经介绍过,gc算法和原理打算放下一篇文章单聊,本篇文章我们重点介绍类加载器机制。

文章开头我们提到过我们写的java源码文件,在编译后会转成二进制的字节码的class文件,如果我们想要使用它们,那么必须通过类加载器加载处理之后才能使用。

为什么需要类加载器

从广义的概念上Java语言里面只有两种类加载器:

(1)Bootstrap CLassloder(引导类加载器)

(2)User Define Classloader(用户自定义的类加载器)

引导类加载器是本身就是JVM规范的一部分,它与OS平台有关,依赖于OS的实现方式加载类型(包括Java API的类和接口),所以在Java里面引导类加载器只能是native实现的,尽管它是所有类加载器的父加载器,但它却不是Java实现的,所以Java里面引导加载器返回的是null。

Java的引导加载器是严格封闭的,因为其作用就是负责加载Java核心的基础库如rt.jar等,这里面就包含了我们常用的java.lang.xxx等相关类,引导类加载的库保证了类型安全,如果你想自定义一个Long类来替换Java基础库的Long类几乎是做不到的。

而自定义的类加载器机制则提供了非常灵活的扩展机制,允许我们自定义加载器来实现一些特殊的功能。

为什么需要自定义类加载器?

这里列举几种场景:

(1)加密。对字节码加密,Java的类文件可以被很容易反编译,为了提高安全性,我们再编译的时候可以加入加密算法,改变二进制文件的编码,然后在定义专门的来加载器来加载加密后文件,在加载之前解密二进制字节码,在加载,这样就可以提高安全性。

(2)以非标准的方式加载类文件。 比如我们的类文件存放在数据库,FTP,或者在从某个网站上下载。

(3)在运行时候动态的去系统外部加载运行一个类。

(4)在同一个应用中,通过类加载器实现环境或者资源的隔离。

(5)通过类加载器实现灵活的可插拔机制。

Java类加载器的双亲委派机制

从上面可以看到自定义类加载器的强大之处,在我们要实现自定义的类加载器之前,我们需要先了解下Java里面的类加载器是如何加载类的。

Java里面的ClassLoader类是实现自定义类加载器的关键,ClassLoader类是一个抽象类,其提供了自定义类加载器的通用描述,其主要的子类如下:

ClassLoader 

    SecureClassLoader

        URLClassLoader

           ExtClassLoader

           AppClassLoader

根据Java平台的具体实现,实际的类加载器顺序如下:

这里大家需要注意一点,类加载器的顺序并不是所谓的继承关系,其实是逻辑组合关系。

前面提到过引导类加载器是所有加载器的前提,尽管Java语言里面不存在具体的这个类,因为其与操作系统有关,所以是native方法实现。但其却是Java里面所有类加载器名副其实的父加载器,其加载的资源路径是:

%JAVA_HOME%/jre/lib

接着我们看ExtClassLoader加载器的,加载路径是

%JAVA_HOME%/jre/lib/ext或者是java.ext.dirs属性里面配置的路径

最后是AppClassLoader加载器,其加载的资源路径是:

当前的classpath的路径

通过上面的分析,我们能够看到其实类加载器的本质是,加载了什么路径下的资源文件,对于上面的几个类加载的路径,我们可以在Java虚拟机启动类Launcher源码中找到答案:

其中引导类加载器的路径是:

System.getProperty("sun.boot.class.path");

ExtClassLoader类加载器的路径是:

System.getProperty("java.ext.dirs")

最后AppClassLoader类加载器的路径是:

System.getProperty("java.class.path")

通过下面这个测试方法,就可印证:

public static void showClassLoaderForeachPath(){

        System.out.println();
        //BoostrapClassLoader
        String[] split=System.getProperty("sun.boot.class.path").split(":");
        for(String data:split){
            System.out.println(data);
        }

        System.out.println("===================");
        //ExeClassLoader
        String[] split1=System.getProperty("java.ext.dirs").split(":");
        for(String data:split1){
            System.out.println(data);
        }




        System.out.println("===================");
        //AppClassLoader
        String[] split2=System.getProperty("java.class.path").split(":");
        for(String data:split2){
            System.out.println(data);
        }

        System.out.println("================");
    }

接着我们随便定义一个测试类,看看该类的加载器的情况:

public static  void showClassLoaderPath(){

        System.out.println(ClassLoaderTest.class.getClassLoader());
        System.out.println(ClassLoaderTest.class.getClassLoader().getParent());
        System.out.println(ClassLoaderTest.class.getClassLoader().getParent().getParent());
        System.out.println("------------------------------------");
        System.out.println(int.class.getClassLoader());
        System.out.println(Long.class.getClassLoader());

    }

输出结果:

sun.misc.Launcher$AppClassLoader@511d50c0
sun.misc.Launcher$ExtClassLoader@5e481248
null
------------------------------------
null
null

可以看到我们自定义的类都是由AppClassLoader这个类加载器加载的,而AppClassLoader是谁由加载呢?

在第二行代码地方能看到是ExtClassLoader加载的,注意这里再次强调类加载器层次非继承关系。

然后我们接着看ExtClassLoader类加载器的父类,发现输出的是null,这在前面已经说了引导加载器是native实现的,所以在Java里面是访问不到的所以是null。

到这里,我们的疑问点集中在为什么类加载器非继承关系,因为在上面的类图里面AppClassLoader与ExtClassLoader是平级兄弟关系,那么为什么说AppClassLoader是由ExtClassLoader作为父类加载器呢?

答案就在源码中,首先看下ClassLoader这个抽象类的构造函数:

//1 
  protected   ClassLoader() {
        this(checkCreateClassLoader(), getSystemClassLoader());
    }

//2
 protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }
//3
    private ClassLoader(Void unused, ClassLoader parent) {
        this.parent = parent;
        if (ParallelLoaders.isRegistered(this.getClass())) {
            parallelLockMap = new ConcurrentHashMap<>();
            package2certs = new ConcurrentHashMap<>();
            domains =
                Collections.synchronizedSet(new HashSet<ProtectionDomain>());
            assertionLock = new Object();
        } else {
            // no finer-grained lock; lock on the classloader instance
            parallelLockMap = null;
            package2certs = new Hashtable<>();
            domains = new HashSet<>();
            assertionLock = this;
        }
    }

我们发现无参和一参的构造函数都是调用二参构造函数,二参构造函数的第二个参数恰恰就是指定的父类加载器,如果使用的是无参构造函数,默认调用是:

public static ClassLoader getSystemClassLoader() {
        initSystemClassLoader();
        if (scl == null) {
            return null;
        }
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            checkClassLoaderPermission(scl, Reflection.getCallerClass());
        }
        return scl;
    }

接着看initSystemClassLoader这个方法,这个方法里面有个关键的地方在于调用了sun.misc.Launcher之后,从这个类里面获取了ClassLoader实例:

sun.misc.Launcher l = sun.misc.Launcher.getLauncher();
            if (l != null) {
                Throwable oops = null;
                scl = l.getClassLoader();
                }

接着我们看下Launcher类的构造方法时如何定义的:

public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
            //1
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
           //2
            this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }


        //........
        }

重点看第二个地方,设置AppClassLoader的父加载器是ExtClassLoader,而ExtClassLoader没有设置,取系统的初始值就是null,此外在赋值完毕之后又把AppClassLoader的实例,赋值了给所有默认的其他自定义的类加载器的父加载器,所以如果我们自定义了一个类加载器,那么它的父加载器如果不指定就是AppClassLoader。

ClassLoader类有几个重要的方法如下:

loadClass() 使用双亲委托加载类的方法 .

defineClass()  将一个字节流转成Class类实例 .

findClass()  从加载器路径搜寻需要处理的类.

findLoadedClass() 查询某个类是否已经被加载过.

getResourceAsStream() 读取一个资源文件转成InputStream

知道了类加载器的层级关系和其主要的方法,那么当我们加载一个类的时候类加载器是如何工作的呢?

重点在于ClassLoader类的loadClass方法,我们看下其源码:

public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

调用了重载的方法:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        进行加锁,防止并发.
        synchronized (getClassLoadingLock(name)) {
            先判断该类是否已经加载过,如果已经加载直接返回.
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    如过没有加载且存在父加载器.
                    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
                }

                    最终仍然没有查询到,就从自己的加载路径加载.
                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;
        }
    }

总结一下:

(1)自定义类加载器或者当前的类加载器先判断该类是否已经加载过,如果加载过 直接返回,否则就委托父加载器进行加载。

(2)父加载器重复(1)步骤,先判断是否加载过,如果加载过直接返回 否则,继续递归重复(1)步骤

(3)如果父加载器为null,那么会委托引导类加载器进行查询,如果已经加载过,那么直接返回,否则就在当前类加载器的路径下面查询,如果仍然找不到就返回上一级,上一级也就执行同样的步骤。

(4)最终都没有找到,会在自定义的类加载器路径下面查找,如果找到了就返回,否则就抛出相关的类找不到异常。

整个查询流程如下,借用网友的一张图非常清晰:

从上面可以看到,委托动作从下到上,而查询动作则从上到下,当然这里面有一层优化,就是从下到上的时会先判断该类是否已经被加载过,如果加载过就直接返回,没必要继续向上委托,这就是经典的双亲委托模型。

双亲委托的模型的意义与破坏

首先双亲委托模型并不是强制约束,而是 Java设计者推荐给开发者的类加载器实现方式,在Java 的世界中大部分的类加载器都遵循这个模型。双亲委派模型对于保证Java程序的安全稳定运作很重要,其最大的意义就在于提升了Java平台运行的安全稳定性,为什么这么说?

因为双亲委托模型使得Java类随着它的类加载器一起具备了带有优先级的层次关系,在加载一个类的时候,如果准守这个模型,那么必定会先从处于模型最顶端的引导类加载器查询加载,因此就能保证对于一些基础类如Object,在不同的类加载器环境中使用的都是同一个类,但如果没有这个模型,比如黑客定义了一个Object类,或者说你自己定义了多个Object类,那么在使用时候会加载多份,那系统中将会出现多个不同的Object类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱和非常不安全。

如何打破双亲委托模型?

在具体实现的时候,是可以选择准守双亲模型或者不准守,如果选择准守就尽量不要动loadClass方法的逻辑,而只需要重写findClass方法即可,但如果继承了ClassLoader类,并重写了loadClass的委托逻辑,不再是像上委托查询,改为其他任何的查询加载模式,那么这种行为就能破坏双亲委托模型。

你说你想自定义一个java.lang.String类?那么能不能做到? 答案是可以的,但不推荐这么干,通过自定义一个类加载器,然后破坏双亲委托模型,最后在重写defineClass方法(在这个native方法里面也有检查限制),绕过Java语言的各种限制,是可以达到目标的,但其实这里面存在很大安全隐患的,对于java开头的包里面的基础数据类型是没有任何理由去破坏的,这种行为属于破坏双亲委托模型的最顶级行为。尽管他们的包名和类名都一样,但自定义类加载器的String类与JVM内置的String类仍然是不相等的,因为他们属于不同的类加载器加载的。

上面说的是顶级的破坏案例,当然还有一些是因为双亲委派模型自身的不足导致的。

在双亲委派模式下:

ClassLoader A -> App class loader -> Extension class loader -> Bootstrap class loader

最左边的类也就是最底层的类,可以访问到顶层的类加载的类,但是反过来却不行,但在实际开发情况下,可能会遇到,顶级的加载器需要回调低级加载器加载的实现类。为了克服这个问题,双亲委派模型中又引入了ThreadContextClassLoader,可以通过Thread的setContextClassLoader和getContextClassLoader获取底层的加载器,从而通过底层加载器来加载该类来避免这个问题。

举个常见的例子: Java里面的SPI机制,或者java.sql的驱动实例化的例子,他们的核心接口都是由Java的引导类加载器加载的,但是他们的实现却是各个厂商提供的或者根据约定设置的,这种情况下引导类加载器是看不到底层加载器的(classpath)的类的,所以只能通过底层加载器本身来加载,这个时候相当于顶层加载器需要使用底层加载器加载的类,从而间接的破坏了双亲委托模型,相当于走了后门。

另外一种破坏双亲委托模型的例子是热加载模式,为了解决不停机或者不停服务更新应用,典型的应用场景是在OSGI里面,默认情况下对于已经加载的类双亲委派模型是不会重新再加载的,但这样就意味着更新了不会被及时感知,如果需要做到动态更新,那么对于已经加载的类也必须再次进行加载,并且要处理好旧实例与新实例状态数据拷贝问题,这种模式也是破坏了双亲委派机制。

最后我们再思考一个问题,为什么默认情况下java的类加载系统分为3级?

这3个类加载器在前面已经看到过,分别是引导类,扩展类,应用类加载器,简单的说这么设计的目的是为了安全性,这三层分级分别代表了不同的信任程度,比如Java的核心包安全级别最高,其次是ext扩展包,最后是应用级别的类。前面说过,如果你在应用级别定义了一个java开头的包类型,那么通常情况是不会生效的,即使它可以编译通过,java的双亲委派模型会做各种检查,防止各种试图替换其核心的数据类型的动作。

类加载系统的阶段流程与类的生命周期

前面写了很多,其实大部分都是关于双亲委派模型本身的利弊或者意义,按照一个类的完整的生命周期,大概分为下面几个阶段:

(1) 初始化阶段

在生命周期的开始阶段又分三个过程分别是load,link,initialize

(2) 中期阶段

在生命周期的中间阶段包括对象实例化,垃圾收集和终结的过程

(3) 销毁阶段

在生命周期的尾部阶段,也就是虚拟机退出时类需要被unload卸载。

按照上面的几个阶段,我们刚才仅仅介绍了初始化阶段的load过程,

load过程可以简单认为是通过指定类的全限定名,将磁盘上或者任意位置上二进制的class文件解析成了java的内部的数据结构并存储在了堆内存中的方法区,之后又创建了Class类的实例来代表该类型。

然后是link步骤,这个过程又分为3个子不走,首先通过verify检验class文件的格式是否符合JVM实现规范,然后在prepare步骤,会给类的静态字段赋默认值,并分配内存空间,这个步骤并不会执行任何Java代码,仅仅给静态字段赋默认值。resolve步骤是可选的,这一步会将类里面的符号引用替换为真实引用。当然这个步骤也可以延迟触发,在实例化之后程序真正引用时再执行也是可以的。举个例子:

class X
{
    static{   System.out.println("init class X..."); }

    int foo(){ return 1; }

    Y bar(){ return new Y(); }
}

X类引用了Y,如果relove=true,那么在X类里面出现的所有引用都会被load,如果relove=false,那么Y类不会在这个时候被load,而是会等到真正用到的时候才会被load,这算是一种延迟加载的策略。

最后到初始化阶段initialize对应的jvm底层调用的clinit指令,这个时候会执行静态块以及对静态字段赋值我们指定的默认值。

当第一次使用某个类的时候,才会触发某个类的初始化行为,这里有六种情况:

(1)有new操作符出现的时候,或者隐式的条件,包括反射,克隆,反序列化等。

(2)调用了类的静态方法

(3)使用了某个类的通过static修饰的类,字段或者接口(final除外,因为final语句是编译时常量,其初始化在编译时就确定了)

(4)通过反射调用类里面的相关方法

(5)子类初始化会触发父类初始化

(6)执行了类本身的main方法

对于load,link,initalize的顺序,必须是顺序的,也就是或一个类要被初始化,那么它必须被link,如果一个类想要被link,那么它必须先被load。

类加载器对于加载过的类会缓存起来,如果在load期间出现了异常或者问题并不会主动抛出,必须得等到该类第一次使用的时候才会抛出,假如这个类永远没有被使用,那么这个异常也永远不会发生。

前面这些步骤分析完仅仅是代表这个类已经具备了使用的条件,开始阶段已经准备完毕,下面是使用阶段:

这个阶段主要是类的实例化和实例的初始化,实例化一个类通常有下面几种方法,使用new创建,反射newInstance创建,cone创建,还有getObject的deserializing创建,在调用了实例化之后,底层其实调用的是init指令,会先对成员变量赋值,执行构造块,最后才执行构造函数。

我们在应用程序中,可以给对象分配内存,但是却不能显式的回收内存,这一工作就是通常由JVM的垃圾回收器来回收利用,在回收内存时,我们可以通过对象的finalize方法来做一些善后工作,对于回收掉的对象仍然是可以再次使用的,这一点需要注意。

最后关于类的卸载阶段,我们也需要简单了解一下,因为加载的类和接口是需要占用内存资源的,如果无限制的存放,那么必然会耗尽程序的内存,所以有必要对不用的对象进行回收,通常情况下由引导类加载器加载的对象和类是不会被回收的,因为这些是Java程序运行的基础,通常需要回收的是由AppClassLoader或者我们自定义的类加载器加载的类和对象,那么如何回收? 背后还是GC垃圾回收器的功劳,简单的说这个类没有显式的应用或者不存在可达路径的时候就认为这个资源无效了,这个时候就可以卸载该类来回收资源。

学完上面这些知识, 我们再来看一个问题,下面这两种写法有什么区别?

Class.forName("SomeClass"); 

ClassLoader.getSystemClassLoader().loadClass("SomeClass");

第一种使用的是反射了一个类,第二种是使用类加载器加载了一个类。

反射的底层执行方法是:

Class.forName(className, true, currentLoader)

第二个方法代表要不要初始化该类的静态变量和执行静态块。

而类加载器的loadClass方法底层执行的是:

loadClass(name, false)

第二个参数与初始化无关,仅仅是加载的时候是否需要执行relove解析符号引用为直接引用。

所以使用反射的话默认是执行了类加载器初始化阶段的三个步骤包括load,link,initialize。

而直接使用ClassLoader.loadClass()方法仅仅是执行类加载器初始化阶段的两个步骤包括load,link,但是并没有执行intialize步骤,这一点需要注意。

典型的例子就是使用JDBC驱动的时候, 我们是通过反射初始化的。如果是通过类加载器加载的类是没有初始化的。

关于类的初始化顺序

掌握了类的加载机制之后,我们再来理解Java类的初始化顺序就非常简单了,如果一个子类继承了父类,在实例化子类的时候,整个顺序如下:

父类静态块.
子类静态块.
父类成员变量初始化.
父类构造块. 
父类构造函数. 
子类成员变量初始化.
子类构造块.
子类构造函数.

感兴趣的朋友可以自行验证,这里我就不再粘贴代码了,如果需要可以到我的github上下载。

总结

本文主要介绍了Java类加载器的相关知识,并深入的分析了双亲委派机制的特点,意义,以及它的不足和如何破坏双亲委派模型,此外还详细分析了类的整个生命周期所经历的步骤,最后介绍了使用反射加载类和类加载器加载类的不同之处,掌握类的加载机制对于理解Java语言有很大的帮助,是每一位Java开发者进阶的必经之路。

参考资料:

https://blog.csdn.net/briblue/article/details/54973413

https://www.artima.com/insidejvm/ed2/lifetype6.html

https://javatutorial.net/jvm-explained

https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-5.html#jvms-5.1

原文发布于微信公众号 - 我是攻城师(woshigcs)

原文发表时间:2018-09-24

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

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏WindCoder

Java基础小结(三)

以上这些类是传统遗留的,在Java2中引入了一种新的框架-集合框架(Collection)

651
来自专栏博岩Java大讲堂

Java集合--非阻塞队列(ConcurrentLinkedQueue基础)

3896
来自专栏木木玲

设计模式 ——— 状态模式

1192
来自专栏CSDN技术头条

用 Webhook+Python+Shell 编写一套 Unix 类系统监控工具

本文来自作者 Alinx 在 GitChat 上分享 「用 Webhook+Python+Shell 编写一套 Unix 类系统监控工具」

2384
来自专栏Janti

JVM活学活用——类加载机制

类的实例化过程 ---- 有父类的情况 1. 加载父类静态     1.1 为静态属性分配存储空间并赋初始值     1.2 执行静态初始化块和静态初始化...

4038
来自专栏码洞

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

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

801
来自专栏后台全栈之路

图文并茂解释内存池原理

在 C 语言的动态申请内存技术中,相比起 alloc/free 系统调用,内存池(memory pool)优点很多。

8686
来自专栏屈定‘s Blog

Java学习记录--委派模型与类加载器

最近在读许令波的深入分析Java Web技术内幕一书,对于学习Java以来一直有的几个疑惑得到了解答,遂记录下来.

1417
来自专栏对角另一面

lodash源码分析之Hash缓存

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

2057
来自专栏java学习

1.3java的运行原理

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

3524

扫码关注云+社区

领取腾讯云代金券