JVM - 类加载子系统

本文深入探讨Java类加载器的工作原理,包括类加载过程、链接、初始化阶段,以及类加载器的分类和双亲委派机制。解析类在JVM中的加载、验证、准备、解析和初始化流程,揭示类加载器如何保证类的唯一性和安全性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

学习清单:
深入探讨 Java 类加载器
老大难的 Java ClassLoader 再不理解就老了
好怕怕的类加载器

1. 类加载子系统的作用

类加载过程即是指JVM虚拟机把.class文件中类信息加载进内存,并进行解析生成对应的class对象的过程,class对象就是一份描述Class结构的元信息对象(类模版对象),通过该元信息对象可以获知Class的结构信息:如构造函数,属性和方法等,Java允许用户借由这个Class相关的元信息对象间接调用Class对象的功能,这里就是我们经常能见到的Class

在这里插入图片描述

  • 类加载器子系统负责从文件系统或者网络中加载class文件,class文件在文件头有特定的标识(cafe baby)

  • ClassLoader只负责class文件的加载,至于是否能运行,则有Execution Engine决定(这里的是否能运行指的是会不会出错)

  • 加载的类信息存放在一块称为方法区的内存空间,除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量

2. 类的加载过程

在这里插入图片描述

2.1 加载

① 加载指的是根据一个类的全限定名将 (class文件) 定义此类的二进制字节流读入内存

虚拟机规范并没有指明二进制文件是从哪里获取,也就是说并不一定是从class文件中获取,还可以通过以下方式获取

  • 运行时计算生成
    我们经常使用的动态代理技术就是这样,在java.lang.reflect.Proxy中使用ProxyGenerator.generateProxyClass来为特定接口生成形式为*$Proxy的代理类的二进制字节流

  • 由其他文件生成
    我们用到的JSP文件也可以生成对应的Class

  • ZIP包中读取
    常见的就是JARWAR格式的包的使用

② 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(instanceKlass
字节码被加载到方法区,内部采用c++的instancKlass来描述java类,他的主要field有

- _java_mirror 即 java 的类镜像,指向Class对象,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量 
- _methods 即方法 
- _constants 即常量池 
- _class_loader 即类加载器 
- _vtable 虚方法表 
- _itable 接口方法表
  • jdk1.8是放在了元空间,构成了instanceKlass的数据结构,这个instanceKlass是用来描述类的数据结构
  • jdk1.7是放在了堆的永久代

③ 在内存中生成一个代表此类的java.lang.Class对象,作为访问方法区这些运行时数据结构的入口(Class对象)

Class对象有指向Klass的指针,java并不能直接访问instanceKlass,而需要使用该Class对象来使用(他就是上面提到的java_mirror),想要访问Klass对象要先找到Class对象,再通过Class指向Klass的指针访问instanceKlass

加载完成之后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区之中,然后在内存中实例化一个java.lang.Class类型的对象(并没有明确要在堆中)
hotspot选择将Class对象存储在方法区中(这点比较特殊,他虽然是对象,但是存储在方法区中)持有instanceKlass的内存地址(instanceKlass也持有_java_mirror对象的内存地址),Java虚拟机规范并没有明确要求一定要存储在方法区或堆区中
在这里插入图片描述
在这里插入图片描述

关于这里的instanceKlass再提一嘴【理解HotSpot虚拟机】对象在jvm中的表示:OOP-Klass模型

HotSpot是基于c++实现,而c++是一门面向对象的语言,本身具备面向对象基本特征,所以Java中的对象表示,最简单的做法是为每个Java类生成一个c++类与之对应。

HotSpot JVM并没有这么做,而是设计了一个OOP-Klass Model;这里的 OOP 指的是 Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而 Klass 则包含元数据和方法信息,用来描述Java类。

之所以采用这个模型是因为HotSopt JVM的设计者不想让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klassoop,其中oop中不含有任何虚函数,而Klass就含有虚函数表,可以进行method dispatch

Klass简单的说是Java类在HotSpot中的c++对等体,用来描述Java类,一般jvm在加载class文件时,会在方法区创建instanceKlass,表示其元数据,包括常量池、字段、方法等
OOP则是在Java程序运行过程中new对象时创建的

类的加载由类加载器完成,JVM提供的类加载器叫做系统类加载器,此外还可以通过继承ClassLoader基类来自定义类加载器

相对于类生命周期的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载

关于数组类型的加载
数组类本身并不通过类加载器创建,它是由jvm直接创建的,但是的数组的元素类型还是要由类加载器去创建

  • 如果数组的类型是引用类型,就会递归加载这个组件类型
  • 如果数组的类型不是引用类型,会把数组标记为和引用类加载器相关联
2.2 链接

连接阶段负责把类的二进制数据合并到JRE中,其又可分为如下三个阶段:

① 校验

此阶段主要确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全

为什么不在刚读取二进制字节流后就进行验证,而是在Class对象生成完成了以后再验证?

深入理解JVM虚拟机这本书说的是加载阶段和连接阶段的部分内容是交叉进行的(比如一部分字节码文件格式验证工作)

② 准备

为类变量分配内存,并将其初始化为默认值,这些内存都将在方法区中分配(此时为默认值,在初始化的时候才会给变量赋值)

public static int value = 123;

此时在准备阶段过后的初始值为0而不是123;将value赋值为123的putstatic指令是程序被编译后,存放于类构造器<client>方法之中

注意: 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象分配到堆中,这里还没对象,自然不会为实例变量分配初始化即在方法区中分配这些变量所使用的内存空间。

注意: static final的常量在编译的使时候就已经分配值了,准备阶段会显示初始化

public static final int value = 123;

此时value的值在准备阶段过后就是123

③ 解析

把常量池内的符号引用转换为直接引用

符号引用: 符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行,布局和内存无关

直接引用: 可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在

主要有以下四种:

  • 类或接口的解析
  • 字段解析
  • 类方法解析
  • 接口方法解析

假设:一个类有一个静态变量,该静态变量是一个自定义的类型,那么经过解析后,该静态变量将是一个指针,指向该类在方法区的内存地址

2.3 初始化

在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作都是由虚拟机主导和控制的,到了初始化阶段,才开始真正执行Java程序代码

初始化阶段是执行类构造器<clinit>方法的过程(注意这不是我们平时自己定义的构造器,构造器在JVM角度是<init>方法)

  • <clinit>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的,收集的顺序是由语句在源文件中出现的顺序决定的;

  • 虚拟机会保证<clinit>方法执行之前,父类的<clinit>方法已经执行完毕;和实例构造器<init>()不同,不需要去显示的调用父类的构造方法

  • JVM必须确保一个类在初始化的过程中,如果是多线程需要同时初始化它,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程。(所以可以利用静态内部类实现线程安全的单例模式)

  • 如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<clinit>()方法

  • 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的不能。前面的静态语句块可以赋值,但不能访问

public class ClassInitTest{
	private static int num = 1;
	static{
		num = 2;
		number = 20;
	}
	private static int number = 10;
	public static void main(String[] args){
		System.out.print(number);//10
	}
}

之前一直搞不懂在上面代码中number = 20;的赋值操作为什么可以放到private static int number = 10;声明语句的上面,针对上面的这些步骤再看

① 首先在链接的准备阶段就会为类变量分配内存,并将其初始化为默认值,所以这个时候内存中就已经有了number,且初值为默认值0

② 静态代码块中number = 20;的赋值操作是在初始化阶段进行的,所以合情合理

③ 最后打印结果是10,因为<clinit>方法中指令按照语句在源文件中出现的顺序执行

但是下面的代码是错的,虽然可以在它声明之前赋值,但是不能在它声明之前调用

public class ClassInitTest{
	private static int num = 1;
	static{
		num = 2;
		number = 20;
		System.out.print(number);//错误,非法的前向引用
	}
	private static int number = 10;
	public static void main(String[] args){
		System.out.print(number);//10
	}
}
  • 接口中不能使用静态语句块,但仍有变量初始化的赋值操作,但是和类不同的是,执行接口的< clinit>()方法不需要先执行父类的,只有当父类接口中定义的变量被使用的时候才会初始化
2.3.1 java中,对于初始化阶段,有且只有以下六种情况才会对要求类立刻“初始化”(加载,验证,准备,自然需要在此之前开始)
  1. 遇到newgetstaticputstaticinvokestatic指令的时候
  • 使用new关键字实例化对象(比如new、反射、序列化)
  • 调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行getstatic或者putstatic指令),(被final修饰的静态字段除外、编译器优化时已经放入常量池)
  • 调用一个类型的静态方法时(即在字节码中执行invokestatic指令)
  1. 初始化一个类的派生类时,先触发父类的初始化(Java虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作,接口例外)
    需要注意,这一点对于接口来说,初始化接口不要求其父接口都被初始化,注意在真正使用到父接口(如引用父接口的常量)才会初始化

  2. 使用java.lang.reflect包的方法进行反射调用的时候,如果类没有被初始化,则要先初始化。

  3. 虚拟机启动时,用户会先初始化要执行的主类(含有main

上面称为对一个类的主动引用,除此之外所有引用类的方法都属于被动引用,不会触发初始化

2.3.2 不会引发初始化的几个场景
  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化
public class SuperClass{
	public static int value = 123;
	static{
		System.out.printlin("Super init");
	}
}

public class SubClass extends SuperClass{
	static{
		System.out.printlin("Sub init");
	}
}

public class NotInitialization{
	public static void main(String[] args){
		System.out.printlin(SubClass.val);
		//Super init
		//123
	}
}

但是是否会触发子类的加载和验证,在虚拟机规范中并未明确规定,这取决于虚拟机的具体实现

  1. 定义对象数组和集合,不会触发该类的初始化
package org.fenixsoft.classloading;
public class NotInitialization{
	public static void main(String[] args){
		SuperClass[] sups = new SuperClass[10];
	}
}

但是会触发 [Lorg.fenixsoft.classloading.SuperClass的类的初始化,这个类代表一个元素类型为org.fenixsoft.classloading.SuperClass的一位数组,创建动作由字节码指令newArray触发,数组中的属性和方法(lengthclone())都实现在这个类里面;
Java语言对数组的访问比c/c++安全是因为这个类封装了数组元素的访问方法,而c/c++翻译为对数组指针的移动

  1. 类A引用类B的static final常量不会导致类B初始化(注意静态常量必须是字面值常量,否则还是会触发B的初始化)
public class ConstClass{
	static{
		System.out.printlin("ConstClass init");
	}
	public static final String HELLO = "hello";
}
public class NotInitialization{
	public static void main(String[] args){
		System.out.printlin("ConstClass.HELLO");
		//hello
	}
}

上面的代码并没有输出ConstClass init,因为虽然引用了ConstClass类的HELLO常量,但是在编译阶段通过常量的传播优化,以及将此常量的hello值存储到了NotInitialization类的常量池中,以后对ConstClass.Hello的引用实际都是转换为对自身常量池的引用

  1. 通过类名获取Class对象,不会触发类的初始化。如System.out.println(Person.class);

  2. 通过Class.forName加载指定类时,如果指定参数initializefalse时,也不会触发类初始化

  3. 通过ClassLoader默认的loadClass方法,也不会触发初始化动作

不会导致类初始化,不代表类不会经历加载、验证、准备阶段

3. 类加载器

类加载器是负责读取 Java 字节代码,并转换成java.lang.Class类的一个实例

把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性

3.1 类的唯一性

类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性

正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的

这里的相同包括Class对象的的equals方法,isInstance方法的返回结果,还包括使用instanceof关键字做对象所属关系判断等情况

通俗一点来讲,要判断两个类是否“相同”,前提是这两个类必须被同一个类加载器加载,否则这个两个类不“相同”

3.2 类加载器的分类

JVM规范中是这样定义类加载器类型的,JVM支持两种类型的类加载器,分别为

  • 引导类加载器(bootstrap classloader)
  • 自定义类加载器(User-Defined ClassLoader)

这样分类的原因是bootstrap classloader是由C++语言编写的,而其他都是派生于抽象类classLoader的java层面实现的类加载器

而我们也可以按照虚拟机自带的和用户自定义的为标准来分类
在这里插入图片描述
这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是以组合的方式来复用父加载器代码
都含有一个ClassLoader parent成员变量,该变量指向其父加载器,类似单向链表

① 启动类加载器 (bootstrap classloader)
  • 这个类加载器使用C/C++语言实现,嵌套在JVM内部
  • 它用来加载 Java 的核心类($JAVA_HOMEjre/lib/rt.jar里所有的class)
  • 并不继承自java.lang.ClassLoader(C++实现的当然不能继承自Java的体系结构)
  • 负责装载<Java_Home>/lib下面的核心类库或-Xbootclasspath选项指定的jar包,只加载包名为javajavaxsun开头的类
  • 由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作

下面程序可以获得根类加载器所加载的核心类库,并会看到本机安装的Java环境变量指定的jdk中提供的核心jar包路径:

public class ClassLoaderTest {
	public static void main(String[] args) {	
		URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
		for(URL url : urls){
			System.out.println(url.toExternalForm());
		}
	}
}
② 扩展类加载器 (ExtClassLoader)
  • 拓展类类加载器,它用来加载<JAVA_HOME>/jre/lib/ext路径以及java.ext.dirs系统变量指定的类路径下的类
  • 派生自ClassLoader
③ 应用程序类加载器 (AppClassLoader)
  • 应用程序类类加载器,它主要加载应用程序ClassPath下的类(包含jar包中的类)。它是java应用程序默认的类加载器,一般来说,java应用的类都是由他来加载

除了系统提供的类加载器以外,开发人员可以通过继承 java.lang.ClassLoader类的方式实现自己的类加载器,以满足一些特殊的需求

⑤ 自定义类加载器
为什么要自定义类加载器

把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性

  1. 隔离加载类
    如果引入了不同的中间件,他们定义的一些类的名字一样,路径一样,就会出现类的冲突的;这个时候让他们使用各自的类加载器加载就会实现不同的中间件的隔离

  2. 修改类加载的方式
    可以在需要的时候再动态加载

  3. 拓展加载源
    从其他地方加载二进制文件
    我们需要的类不一定存放在已经设置好的classPath下(有系统类加载器AppClassLoader加载的路径),对于自定义路径中的class类文件的加载,我们需要自己的ClassLoader

  4. 防止源码泄漏
    有时我们不一定是从类文件中读取类,可能是从网络的输入流中读取类,这就需要做一些加密和解密操作,这就需要自己实现加载类的逻辑,当然其他的特殊处理也同样适用

  5. 可以定义类的实现机制,实现类的热部署,如OSGi中的bundle模块就是通过实现自己的ClassLoader实现的

怎么自定义加载器

ClassLoader 里面有三个重要的方法 loadClass()findClass()defineClass()

loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。ClassLoaderfindClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象,关于为什么是让子类重写 findClass() 方法而不是直接重写loadClass方法,是因为在loadClass方法中有双亲委派机制的逻辑,最后如果双亲都无法加载,在去调用自身的 findClass() 方法

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) {
                // 父类加载器无法完成加载请求
            }

            if (c == null) {
                // 父类无法加载再调用自身的findClass方法加载
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

findClass方法中我们实际上需要做的就是读取以下需要加载的二进制文件,再交给defineClass方法把二进制流字节组成的文件转换为一个java.lang.Class

public class MyClassLoader extends ClassLoader{
    public MyClassLoader(){
        
    }
    
    public MyClassLoader(ClassLoader parent){
        super(parent);
    }
    
    protected Class<?> findClass(String name) throws ClassNotFoundException{
        File file = getClassFile(name);
        try{
            byte[] bytes = getClassBytes(file);
            Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
            return c;
        } 
        catch (Exception e){
            e.printStackTrace();
        }
        
        return super.findClass(name);
    }
    
    private File getClassFile(String name){
        File file = new File("D:/Person.class");
        return file;
    }
    
    private byte[] getClassBytes(File file) throws Exception{
        // 这里要读入.class的字节,因此要使用字节流
        FileInputStream fis = new FileInputStream(file);
        FileChannel fc = fis.getChannel();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        WritableByteChannel wbc = Channels.newChannel(baos);
        ByteBuffer by = ByteBuffer.allocate(1024);
        
        while (true){
            int i = fc.read(by);
            if (i == 0 || i == -1)
                break;
            by.flip();
            wbc.write(by);
            by.clear();
        }
        
        fis.close();
        
        return baos.toByteArray();
    }
}

自定义类加载器不要破坏双亲委派规则,不要轻易覆盖 loadClass 方法。否则可能会导致自定义加载器无法加载内置的核心类库。在使用自定义加载器时,要明确好它的父加载器是谁,将父加载器通过子类的构造器传入。如果父类加载器是 null,那就表示父加载器是「根加载器」

3.3 双亲委派机制

学习老大难的 Java ClassLoader 再不理解就老了

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类的时候才会将他的Class文件加载到内存中生成class对象;而在加载某个类的class文件的时候,Java虚拟机采用的是双亲委派机制,即把请求交给父类处理,他是一种任务委派模式

上面提到了双亲委派机制,其实就是,当一个类加载器去加载类时先尝试让父类加载器去加载,如果父类加载器加载不了再尝试自身加载。这也是我们在自定义ClassLoaderjava官方建议遵守的约定。
在这里插入图片描述
AppClassLoader 在加载一个未知的类名时,它并不是立即去搜寻 Classpath,它会首先将这个类名称交给 ExtensionClassLoader 来加载,如果 ExtensionClassLoader 可以加载,那么 AppClassLoader 就不用麻烦了。否则它就会搜索 Classpath

ExtensionClassLoader 在加载一个未知的类名时,它也并不是立即搜寻 ext 路径,它会首先将类名称交给 BootstrapClassLoader 来加载,如果 BootstrapClassLoader 可以加载,那么 ExtensionClassLoader 也就不用麻烦了。否则它就会搜索 ext 路径下的 jar 包。这三个 ClassLoader 之间形成了级联的父子关系,每个 ClassLoader 都很懒,尽量把工作交给父亲做,父亲干不了了自己才会干。每个 ClassLoader 对象内部都会有一个 parent 属性指向它的父加载器

class ClassLoader {
  ...
  private final ClassLoader parent;
  ...
}

值得注意的是图中的 ExtensionClassLoaderparent 指针画了虚线,这是因为它的 parent 的值是 null,当 parent 字段是 null 时就表示它的父加载器是「根加载器」。如果某个 Class 对象的 classLoader 属性值是 null,那么就表示这个类也是「根加载器」加载的

这种机制有以下好处:
① 避免类的重复加载
② 保护程序安全,防止核心API被随用篡改(沙箱安全机制

对于第一点,假设有以下的场景,两个类A和类B都要加载System类:

  • 如果不用委托而是自己加载自己的,那么类A就会加载一份System字节码,然后类B又会加载一份System字节码,这样内存中就出现了两份System字节码

  • 如果使用委托机制,会递归的向父类查找,也就是首选用Bootstrap尝试加载,如果找不到再向下。这里的System就能在Bootstrap中找到然后加载,类加载器在成功加载某个类之后,会把得到的 java.lang.Class类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用,如果此时类B也要加载System,也从Bootstrap开始,此时Bootstrap发现已经加载过了System那么直接返回内存中的System即可而不需要重新加载,这样内存中就只有一份System的字节码了

对于第二点,假设有以下的场景,我们自己写个类叫java.lang.System
类加载采用委托机制,这样可以保证爸爸们优先,爸爸们能找到的类,儿子就没有机会加载。而System类是Bootstrap加载器加载的,就算自己重写,也总是使用Java系统提供的System自己写的System类根本没有机会得到加载,保护程序安全,防止核心API被随用篡改

3.4 双亲委派机制的缺陷及打破

浅谈双亲委派机制的缺陷及打破双亲委派机制
以JDBC为例谈双亲委派模型的破坏

3.4.1 缺陷

由于BootstrapClassloader是顶级类加载器,BootstrapClassloader无法委派AppClassLoader来加载类,也就是说BootstrapClassloader中加载的类中无法使用由AppClassLoader加载的类。可能绝大部分情况这个不算是问题,因为BootstrapClassloader加载的都是基础类,供AppClassLoader加载的类调用的类。但是万事万物都不是绝对的,比如经典的JAVA SPI机制

3.4.2 双亲委派机制的打破

双亲委派模型主要出现过三次大规模被破坏的情况
这里大致说一下JAVA SPI机制为什么要打破,并且是如何使用线程上下文类加载器打破双亲委派机制的

下面这段话引自真正理解线程上下文类加载器

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。

这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由**启动类加载器(BootstrapClassloader)来加载的;SPI的实现类是由系统类加载器(SystemClassLoader)**来加载的。引导类加载器是无法找到 SPI的实现类的,因为依照双亲委派模型,BootstrapClassloader无法委派AppClassLoader来加载类。

而线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。

具体看这篇JDBC详解

3.5 分工与合作

这里我们重新理解一下 ClassLoader 的意义,它相当于类的命名空间,起到了类隔离的作用。位于同一个 ClassLoader 里面的类名是唯一的,不同的 ClassLoader 可以持有同名的类。ClassLoader 是类名称的容器,是类的沙箱

不同的 ClassLoader 之间也会有合作,它们之间的合作是通过 parent 属性和双亲委派机制来完成的。parent 具有更高的加载优先级。除此之外,parent 还表达了一种共享关系,当多个子 ClassLoader 共享同一个 parent 时,那么这个 parent 里面包含的类可以认为是所有子 ClassLoader 共享的。这也是为什么 BootstrapClassLoader 被所有的类加载器视为祖先加载器,JVM 核心类库自然应该被共享

3.6 Class.forName

这是手动加载类的常见方式

public static Class<?> forName(String className)

但是他是使用哪个类加载器来加载的呢?看一下方法的具体实现

public static Class<?> forName(String className)
            throws ClassNotFoundException {
    // 使用native方法获取调用类的Class对象
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

其中getClassLoader(caller)设置了所使用的类加载器,继续看其实现:

 static ClassLoader getClassLoader(Class<?> caller) {
     if (caller == null) {
         return null;
     }
     return caller.getClassLoader0();
 }
}

这段代码的官方注解是“返回caller的类加载器”,即native方法getClassLoader0()返回调用者的类加载器。也就是说假设在A类里执行forName(String className),那么所使用的ClassLoader就是加载A的ClassLoader

forName 还提供了多参数版本,可以指定使用哪个 ClassLoader 来加载

Class<?> forName(String name, boolean initialize, ClassLoader cl)

通过这种形式的 forName 方法可以突破内置加载器的限制,通过使用自定类加载器允许我们自由加载其它任意来源的类库。根据 ClassLoader 的传递性,目标类库传递引用到的其它类库也将会使用自定义加载器加载

3.7 钻石依赖

项目管理上有一个著名的概念叫着「钻石依赖」,是指软件依赖导致同一个软件包的两个版本需要共存而不能冲突

我们平时使用的 maven 是这样解决钻石依赖的,它会从多个冲突的版本中选择一个来使用,如果不同的版本之间兼容性很糟糕,那么程序将无法正常编译运行。Maven 这种形式叫「扁平化」依赖管理

使用 ClassLoader 可以解决钻石依赖问题。不同版本的软件包使用不同的 ClassLoader 来加载,位于不同 ClassLoader 中名称一样的类实际上是不同的类,上面提到过

ClassLoader 固然可以解决依赖冲突问题,不过它也限制了不同软件包的操作界面必须使用反射或接口的方式进行动态调用。Maven 没有这种限制,它依赖于虚拟机的默认懒惰加载策略,运行过程中如果没有显示使用定制的 ClassLoader,那么从头到尾都是在使用 AppClassLoader,而不同版本的同名类必须使用不同的 ClassLoader 加载,所以 Maven 不能完美解决钻石依赖

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值