java类加载和双亲委派模型浅说

前言

之前被问到双亲委派模型和如果没有此模型会导致什么问题,我去,一下子懵了,但下来细想,这不就是问的此模型的优势嘛,我竟然没有答出来,应该是我紧张了,自以为把此模型掌握的很扎实,实则不然,所以再来整理一下相关的知识点。

一、类加载器

说到双亲委派模型,不得不提一下类加载器,再说类加载器之前不得不说类加载的分类和类加载机制。

1.1 类加载机制的基本特征

类加载机制通常有三个基本特征:
A、双亲委派模型:首先说明并不是所有类加载都遵循这个模型。有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的SPI机制,用户可以在标准API框架上提供自己的实现,JDK也需要提供些默认的参考实现。比如,java中JNDI,JDBC,文件系统等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
B、可见性:子类加载器可以访问父类加载器加载的类型,凡是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
C、单一性:由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。

1.2 类加载的分类

类的加载分为:显式加载 VS 隐式加载
class文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式。
• 显式加载指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName("java.lang.String")this.getClass().getClassLoader().loadClass()加载class对象。
• 隐式加载是通过虚拟机自动加载到内存中,而不是直接在代码中调用ClassLoader的方法加载class对象,如在加载某个类的class文件时,该类的class文件中引用了另一个类的对象,此时额外引用的类将通过JVM自动加载到内存中。
在日常开发以上两种方式一般会混合使用。

代码示例一

package com.fanhf.javastudy.classloader;

/**
* @author fanhf
* @Description 显式和隐式加载
* @date 2021-02-07 14:22
*/
public class UserTest{
	User user = new User();//隐式加载
	public static void main(String[] args){
		try{
			//显式加载方式1
			Class clazz = Class.forName("com.fanhf.javastudy.classloader.User");
			//显式加载方式2
			Class<?> aClass = ClassLoader.getSystemClassLoader().loadClass("com.fanhf.javastudy.classloader.User");
		}catch(ClassNotFoundException e){
			e.printStackTrace();
		}
	}
}
// User类
class User{
    static {
       System.out.println("我是User类的初始化");
	}
}

1.3 类加载器

JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构如下情况:

启动类加载器
BootStrap ClassLoader
扩展类加载器
Extension ClassLoader
应用程序类加载器
Application ClassLoader
用户自定义类加载器1
User ClassLoader
用户自定义类加载器2
User ClassLoader

除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器。
不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用

A、启动类加载器(引导类加载器,Bootstrap ClassLoader)

• 这个类加载使用C/C++语言实现的,嵌套在JVM内部
• 它用来加载java的核心库(JAVA_HOME/jre/lib/rt.jar或sun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。
• 并不继承自java.lang.ClassLoader,没有父加载器。
• 出于安全考虑,Bootstrap启动类加载器只加载包名为:java、javax、sun等开头的类
• 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。

使用-XX:+TraceClassLoading参数得到。
在上面的代码示例一中,加入-XX:+TraceClassLoading的启动参数,可以看到
在这里插入图片描述
此处省略许多类…
在这里插入图片描述
此处省略许多类…
在这里插入图片描述
代码示例一中,加载了504个类,包括以下包的类
java.lang、java.util、java.io、java.nio、java.net、java.security、
sun.misc、sun.reflect、sun.nio、sun.usagetracker、sun.launcher

B、扩展类加载器(Extension ClassLoader)

• java语言编写,由sun.misc.Launcher$ExtClassLoader实现。
• 继承于ClassLoader类
• 父类加载器为启动类加载器

从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

类加载器继承关系如下图:
在这里插入图片描述
ClassLoader的孩子们:
在这里插入图片描述
Launcher类中的实现
在这里插入图片描述

C、应用程序类加载器(系统类加载器,AppClassLoaer)

• java语言编写,由sun.misc.Launcher$AppClassLoader实现
• 继承于ClassLoader类
• 父类加载器为扩展类加载器
• 它负责加载环境变量classpath或系统属性 java.class.path指定路径下的类库
• 应用程序中的类加载器默认是系统类加载器
• 他是用户自定义类加载器的默认父加载器

通过ClassLoader的getSystemClassLoader() 方法可以获取到该类加载器
在这里插入图片描述

D、自定义类加载器

自定义类加载器可以实现应用隔离,比如Tomcat,Spring等中间件和组件框架都在内部实现了自定义的加载器。自定义类加载器通过需要继承自ClassLoader,此类中的loadClass方法中的逻辑就是双亲委派模型的实现,继承ClassLoader后,在jdk1.2之后,不建议去覆盖loadClass方法,而是在findClass方法中实现,findClass就是在loadClass中调用的,当loadClass方法中父类加载器加载失败,就会调用自己写的findClass方法来完成类的加载,这样就可以保证自定义的类加载器也符合双亲委派模型。

通过看源码得知,ClassLoader是一个抽象类,很多方法是空的没有实现,比如findClass()、findResource()等。而URLClassLoader这个实现为这些方法提供了具体的实现。并新增了URLClassPath类协助取得Class字节码流等功能。在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

1.4 类加载器的必要性

一般情况下,java开发人员并不需要在程序中显式的使用类加载器,但是了解类加载器的加载机制却很重要。从以下几点说明:
• 避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时手足无措。只有了解类加载器的加载机制才能够在出现异常的时候快速的根据错误异常日志定位问题。
• 需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要和类加载器打交道了。
开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑。

赠送面试题:Class.forName()与ClassLoader.loadClass()

• Class.forName():是一个Class类中在静态方法,属于显式加载,最常用的是Class.forName(String className);
根据传入的类的全限定名返回一个Class对象。该方法在将Class文件加载到内存的同时,会执行类的初始化。如:Class.forName(“com.fanhf.javastudy.classloader.ClassLoaderTest”);

• ClassLoader.loadClass():这是一个实例方法,属于隐式加载,需要一个ClassLoader对象来调用此方法。该方法将Class文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化。该方法因为需要得到一个ClassLoader对象,所以可以根据需要指定使用哪个类加载器,如:ClassLoader cl=……;
cl.loadClass(“com.fanhf.javastudy.classloader.ClassLoaderTest”)

二、双亲委派模型

简单说完了类加载器和类加载机制,步入正题说一下双亲委派模型

2.1 概述

类加载器用来把类加载到java虚拟机中。从JDK1.2开始,类的加载过程采取双亲委派机制,这种机制能更好的保证java平台的安全。如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载器任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。
如下图:
在这里插入图片描述
然而,java虚拟机规范中并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议使用这种方式。在tomcat中,缺省的类加载器接到一个类加载任务,会自行加载,加载失败才会委托给它的超类进行加载,这也是Servelet规范推荐的一种做法。

2.2 双亲委派模型的实现

双亲委派机制在java.lang.ClassLoader.loadClass(String,boolean)接口中体现。
重点分析一下loadClass方法

/*
*
* <p> If the class was found using the above steps, and the
* <tt>resolve</tt> flag is true, this method will then invoke the {@link
* #resolveClass(Class)} method on the resulting <tt>Class</tt> object.
* 
* 翻译为:如果使用上述步骤找到了该类,并且resolve标志为true,则此方法将在生成的Class上调用 resolveClass(Class)方法对象。
*/
protected  Class<?>loadClass(String name,boolean resolve) throws ClassNotFoundException //resolve为true,加载class的同时进行解析操作。
{
        synchronized(getClassLoadingLock(name)){ //同步操作,保证只能加载一次
                //First,check if the class has already been loaded
                
                //调用{@link findLoadedClass(String)},以检查是否已加载该类。
                //首先,在缓存中判断是否已经加载同名的类
                Class<?> c = findLoadedClass(name);
                if(c == null){
                        long t0 = System.nanoTime();
                        try{
                               //获取当前类的父类加载器
                                if(parent != null){
                                       //如果存在父类加载器,就调用父类的加载器进行类的加载
                                        c = parent.loadClass(name,false);
                                }else{
                                      //parent是null,说明父类加载器是引导类加载器
                                        c = findBootstrapClassOrNull(name);
                                }
                        }catch(ClassNotFoundException e){
                                //ClassNotFoundException thrown if class not found
                                //from the non-null parent classloader
                        }
        
                if(c == null){ //当前类的加载器的父类加载器未加载此类 or 当前类的加载器未加载此类
                        //If still not found,then invoke findClassinorder
                        //to find the class.
                        long t1 = System.nanoTime();
                        c = findClass(name);
                        
                        //this is the defining classloader;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)先在当前类加载器缓存中查找有无目标类,如果不为空,则调用parent.loadClass(name,false)接口进行加载。
2)判断当前加载器的父类加载器是否为空,如果不为空,则调用parent.loadClass(name,false)接口进行加载。
3)反之,如果当前加载器的父类加载器为空,则调用findBootstrapClassOrNull(name)接口,让引导类加载器进行加载。
4)如果通过以上3条路径都没有加载成功,则调用findClass(name)进行加载。该接口最终调用java.lang.ClassLoader 接口的defineClass系列的native接口加载目标java类。

举个🌰:
有一个java.lang.Object类需要加载,这个类属于java中核心的不能再核心的类了,因此一定只能由启动类加载器(Bootstrap ClassLoader)进行加载。jvm按照上面的4步进行加载,首先从应用类加载器(AppClassLoader)的缓存中查找该类,但此时并无此类,所以由应用类加载器的父类加载器即扩展类加载器进行加载,于是扩展类加载器继续从第一步开始重复。由于扩展类加载器的缓存中也一定找不到该类,于是进入第二步,扩展类的父加载器时null,因此调用findClass方法进行加载,最终通过启动类加载器进行加载。

2.3 双亲委派模型优劣势

2.3.1 优势

A、避免类的重复加载,确保一个类的全局唯一性
java类随着它的类加载器一起举杯了一种带有优先级的层次关系,通过这种层级关闭可以避免类被重复加载,当父类已经加载了该类时,就没有必要下一级加载器在加载一次了。
B、保护程序的安全,防止核心API被随意篡改。

2.3.2 劣势

检查类是否加载的委托过程是单向的。
这个加载方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类。
通常情况下,启动类加载器的类为系统核心类,包括一些重复的系统接口,而在应用类加载器中,为应用类,按照这种模式,应用类访问启动类自然是没有问题的,但是启动类访问应用类就会出现问题。比如在启动类提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定了一个工厂方法,用于创建该接口的实力,而接口和工厂方法都在启动类。

2.4 破坏双亲委派模型

双亲委派模型并不是一个具有强制性约束的模型,而是java设计者推荐给开发者们的类加载器实现方式。
在java的世界中大部分的类加载器都遵循这个模型,直到java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。

2.4.1 第一次破坏双亲委派模型

第一次被破坏是发生在双亲委派模型出现之前的JDK1.2版本以前的古老的时代。
为了兼容1.2之前已有的类加载器的概念和抽象类java.lang.ClassLoader中可能已经被用户自定义来加载器的代码,在1.2之后在此类中增加了一个protected的findClass()方法,引导用户编写的类加载逻辑时尽可能去重新这个方法,而不是在loadClass中编写代码。
上面我们分析过loadClass方法,按照此方法的逻辑,如果父类加载失败,会自动调用findClass方法来完成加载,这样不仅保证用户可以按照自己的需求去加载,还可以保证新实现的类加载器是符合双亲委派模型的。

2.4.2 第二次破坏双亲委派模型

第二次被破坏是由于这个模型自身的缺陷所导致的,在2.3.2节提到此模型的劣势是顶层类加载器无法访问底层所加载的类,换言之,顶层类加载器加载的都是比较基础的类,当基础的类想要访问应用类加载的类的方法时,这条路似乎被堵死了,实际上这种问题被一个叫做线程上下文类加载器给解决了。
线程上下文类加载器(Thread Context ClassLoader)可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局内部没有设置过的话,这个类加载器就默认使用应用类加载器进行加载。
举个🌰:JNDI(Java Name and Directory Interface)java命名和目录接口,作用是为JAVA应用程序提供命名和目录访问服务的API,现在是java标准的服务,其代码由启动类加载器进行加载,这个接口需要调用其他厂商实现并部署在classPath下的服务提供者接口(Service Provider Interface,SPI)的代码,那么就可以使用线程上下文类加载器去加载所需的SPI服务代码,这就是典型的父类加载去请求子类加载器完成类加载的行为,这种行为实际上打通了双亲委派模型的层次结构来逆向使用类加载器,违背了双亲委派模型的一般性原则,java中涉及SPI的加载基本上都采用这种方式来完成。

2.4.3 第三次破坏双亲委派模型

第三次是由于用户对程序动态性的追求而导致的,如代码热替换,模块热部署等。这里提一下IBM主导的OSGI实现模块化热部署,实现的关键是它自定义的类加载机制的实现,每一个程序模块(OSGI中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换,在OSGI环境下,类加载器不再是双亲委派模型推荐的树状结构,而是更为复杂的网状结构。
(OSGI这玩意我接触的也不多,至于类加载的实现方式我就不多说了,感兴趣的小可爱可以自行学习。)

三、自定义类加载器

在1.3节中说到有四种类加载器,最后一种是自定义类加载器,那就再来说说这玩意!

3.1 为啥要自定义类加载器?

A、 隔离加载类
在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如,阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。再比如:tomcat这类web应用服务器,内部自定义了好几种类加载器,用于隔离同一个web应用服务器上的不同应用程序。
B、 修改类加载的方式
类的加载模型并非强制,除bootstrap外,其他的加载并非不一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载。
C、扩展加载源
比如从数据库、网络、甚至是电视机机顶盒进行加载
D、 防止源码泄漏
java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。

3.2 常见的场景

1)实现类似进程内隔离,类加载器实际上用作不同的命名空间,以提供类似容器,模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器加载,就可以互不干扰。这个方面的集大成者是java EE和OSGI,JPMS等框架。
2)应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。或者是需要自己操纵字节码动态修改或者生成类型。

3.3 实现方式

用户通过定制自己的类加载器,这样可以重新定义类的加载规则,以便实现一些自定义的处理逻辑。

3.3.1 实现方式

• java提供了抽象类,所有用户自定义的类加载器都应该继承ClassLoder类。
• 在自定义ClassLoader的子类的时候,我们常见的会有2种做法。
    ○ 方式一:重写loadClass()方法
    ○ 方式二:重写findClass()方法--推荐

3.3.2 两种实现方式的对比

这两种方式本质差不多,毕竟 loadClass()也会调用findClass(),但是从逻辑上讲我们最好不要直接修改loadClass()内部逻辑。建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。
• loadClass()这个方法是实现双亲模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题,因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass()方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
• 当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作。

3.3.3 说明

• 其父类加载器是系统类加载器
• JVM中的所有类加载都会使用java.lang.ClassLoader.loadClass(String)接口(自定义类加载器并重写java.lang.ClassLoader.loadClass(String)接口的除外),连JDK的核心类库也不能例外。

下面来看一下例子:

package com.fanhf.study.classLoader;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;

public class MyClassLoader extends ClassLoader{
    private String byteCodePath;

    public MyClassLoader(String byteCodePath){
        this.byteCodePath = byteCodePath;
    }

    public MyClassLoader(ClassLoader parent, String byteCodePath) {
        super(parent);
        this.byteCodePath = byteCodePath;
    }

    @Override
    protected Class<?> findClass(String className) {
        BufferedInputStream bis = null;
        ByteArrayOutputStream baos = null;
        try {
            //获取字节码文件的完整路径
            String fileName = byteCodePath + className + ".class";
            //获取一个输入流
            bis = new BufferedInputStream(new FileInputStream(fileName));
            //获取一个输出流
            baos = new ByteArrayOutputStream();
            //具体读入数据并写出的过程
            int len;
            byte[] data = new byte[1024];
            while ((len = bis.read(data)) != -1) {
                baos.write(data, 0, len);
            }
            //获取内存中完成的字节数组的数据
            byte[] byteCodes = baos.toByteArray();
            //调用defineClass讲字节数组转换为class实例
            Class clazz = defineClass(null, byteCodes, 0, byteCodes.length);
            return clazz;
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (bis != null) {
                    bis.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                if (baos != null) {
                    baos.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }
}

测试类

package com.fanhf.study.classLoader;

public class MyClassLoaderTest{
    public static void main(String[] args) {
        MyClassLoader loader = new MyClassLoader("/Users/fanhuifang/IdeaProject/MyProjects/java-study/target/classes/com/fanhf/study/classLoader/");
        Class clazz = null;
        try {
            clazz = loader.loadClass("MyClassLoader");
            System.out.println("加载此类的类加载器为:" + clazz.getClassLoader().getClass().getName());

            System.out.println("加载当前Test类的类加载器的父类加载器为:" + clazz.getClassLoader().getParent().getClass().getName());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

到此,本文就差不多告一段落了。

特此说明,本文中的内容出处来自于《深入了解java虚拟机第二版》以及尚硅谷的康师傅的jvm课程,感兴趣的小伙伴也可以再去看看,定会受益匪浅。

-----------------你知道的越多,不知道的越多------------------

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值