深入拆解类加载器,这样的姿势你还不懂吗?

本文导读:

1、前奏,举个生活中的小栗子

2、为何Java类型加载、连接在程序运行期完成?

3、一个类在什么情况下才会被加载到JVM中?

什么是主动使用、被动使用?代码示例助你透彻理解类初始化的时机。

4、类的加载(Loading)内幕透彻剖析

类加载做的那些事儿、双亲委派模型工作过程、ClassLoader源码解析

5、Tomcat如何打破双亲委派模型的

6、上下文类加载器深入浅出剖析

7、最后总结

1、前奏,举个生活中的小栗子

春节马上要到了,大家是不是都在迫不及待的等着回家团圆了呢?

大春运早已启动,回家的过程其实是个「辛苦活」,有的同学还没有买到票呢,蒙眼狂奔终于抢到了,发现竟然是个站票~,退了,连站票的机会都没了吧?

昨天还听一位同学说:『嘿嘿,去年我提前就买到票了,但是... 但是... 去错火车站了。。。尼玛,当时那是啥心情啊~ 幸运的是后来又刷到票了,不然就真回不去了!』

心塞塞

回家大部分朋友都要乘坐交通工具,不管你乘坐什么样的交通工具出行,对于「交通管理」内部来说,最最重要的任务就是保障大家得出行安全。

交通出行

那么如何保障大家的出行安全呢?

乘坐地铁、飞机等这些公共交通工具,必不可少的最重要的环节就是『安检』,不是什么东西都可以随便让你带的,都是有明文规定的,比如易燃易爆、酒类等都是有限制的。

交通出行的大体过程,有点类似类文件加载到Java虚拟机(简称 JVM)的过程,程序中运行的各种类文件(比如Java、Kotlin),也是要必须经过『安检』的,才能允许进入到JVM中的,一切都是为了安全。

当然,安检的标准是不同的。

接下来,我们进入正题,一起来看看类文件是如何被加载到JVM当中的。

类加载过程

上图的对比只是为了方便理解 ,抽象出来一层『安全检查』,其实就是『类加载』的过程。
这个过程JVM当中约束了规范和标准,都会经过加载、验证、准备、解析、初始化五个阶段。

这里一定要说一个概念,个人认为对于理解类加载过程挺重要的。

更准确的说法,应该是类型的加载过程,在Java代码中,类型的加载、连接、初始化都是在程序运行时完成的。

这里的类型,是指你在开发代码时常见的class、interface、enum这些关键字的定义,并不是指具体的class对象。

举个🌰:

Object obj = new Object();

new出来的obj是Object类型吗?当然不是,obj只是通过new创建出来的Object对象,而类型实际是Object类本身。而要想创建Object对象的前提,必须要有类型的信息,才能在Java堆中创建出来。所以,这里要明确区分开。

绝大多数情况下,类型是提前编写好的,比如Object类是由JDK已经提供的。另外一些情况是可以在运行期间动态的生成出来,比如动态代理(程序运行期完成的)。

2、为何Java类型加载、连接在程序运行期完成?

其实,运行区间能做这件事,就为一些有创意的开发人员提供了很多的可能性。一切的文件都已经存在,程序运行的过程中可以采取一些特殊的处理方式把这些之前已经存在或者运行期生成出来的这些类型有机的装配在一起。

Java本身是一门静态的语言,而他的很多特性又具有动态语言才能拥有的特质,也因此类型的加载、连接和初始化在运行期间完成起到了很大的帮助作用。

类型的加载:查找并加载类的二进制数据(字节码文件),最常见的,是将类的Class文件从磁盘加载到内存中。

类型的连接:将类与类的关系确定好,对于字节码相关的处理、验证、校验在加载连接阶段去完成的。字节码本身可以被人为操纵的,也因此可能有恶意的可能性,所以需要校验。

  • 验证:确保被加载类的正确性,就是要按照JVM规范定义的。

  • 准备:为类的静态变量分配内存,并将其初始化为默认值

class Test {
		 public static int num = 1; 
}

上述代码示例中的中间过程,在将类型加载到内存过程中,num分配内存,首先设置为0,1是在后续的初始化阶段赋值给num变量。

  • 解析:把类中的符号引用转换为直接引用

符号引用: 间接的引用方式,通过一个符号的表示一个类引用了另外的类。 直接引用:直接引用到目标对象中的内存的位置
初始化阶段:为类的静态变量赋予正确的初始值。

类型的初始化:比如一些静态的变量的赋值是在初始化阶段完成的。

3、一个类在什么情况下才会被加载到JVM中?

Java程序对类的使用方式可分为两种:

  • 主动使用

  • 被动使用

特别的重要:

所有的Java虚拟机实现必须在每个类或接口被java程序首次主动使用时才初始化他们。

主动使用八种情况):

1)创建类的实例,比如new一个对象

2)访问某一个类或接口的静态变量,或者对该静态变量赋值 (访问类的静态变量的助记符getstatic,赋值是putstatic)。

3)调用类的静态方法 (应用invokestatic助记符)。

4)使用java.lang.reflect包的方法对类型进行反射调用,比如:Class.forName(“com.test.Test") 通过反射的方式获取类的Class对象。

5)初始化一个类的子类,比如有class Parent{}、子类class Child extends Parent{},当初始化Child类时也表示对Parent类的主动使用,Parent类也要全部初始化。

6)Java虚拟机启动时被标注为启动类的类,即有main方法的类。

7)JDK1.7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic, REF_putStatic, REF_invokeStatic句柄对应的类没有初始化,则初始化。

8)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

除了上述所讲的八种情况,其他使用Java类的方式都被看作是类的被动使用都不会导致类的初始化

另外,要特别说明的一点

接口的加载过程与类加载过程会有所不同,接口不能使用 「static{}」语句块,但是编译器会为接口生成对应的 ()类构造器,用于初始化接口中所定义的成员变量。

主动使用的第5种:当子类初始化时,要求其父类也要全部初始化完成。但是,对于一个接口的初始化时,并不要求其父接口要全部初始化完成,只有在真正使用到父接口时(比如引用接口中定义的常量)时才会去初始化,有点延迟加载的意思。

被动使用示例:

1)通过子类引用父类的静态字段,不会导致子类的初始化

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

    public static int a = 123;
}
public class Child extends Parent {
    static {
        System.out.println("Child init...");
    }
}

// Test类打印,子类直接调用父类的静态字段
public static void main(String[] args) {
        System.out.println(Child.a);
}

输出结果:

Parent init....
123

根据输出结果看到,不会输出 Child init...,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化,对于静态字段,只有直接定义这个字段的类才会被初始化。

2) 创建数组类对象,并不会导致引用的类初始化

public class Child extends Parent {
    static {
        System.out.println("Child init...");
    }
}

// 使用 Child 引用创建个数组
public static void main(String[] args) { 
        Child[] child = new Child[1];
        System.out.println(child);
    }

输出结果:

[Lcom.dskj.jvm.beidong.Child;@7852e922

并没有输出Child init...证明并没有初始化com.dskj.jvm.beidong.Child类,根据输出结果看到了[Lcom.dskj.jvm.beidong.Child,带了[L说明触发了数组类的初始化阶段,它是由JVM自动生成的,继承自java.lang.Object类,由于anewarray助记符触发创建动作的。

对于数组来说,JavaDoc通常将其所构成的元素称作为Component,实际上就是将数组降低一个维度的类型。

助记符:

anewarray:表示创建一个引用类型的(如类、接口、数组)数组,并将其引用值压入栈顶。

newarray:表示创建一个指定的原始类型的(如int、float、char、short、double、boolean、byte)的数组,并将其引用值压入栈顶。

对应字节码内容:

对应字节码

3)调用ClassLoader的loadClass()方法,不会导致类的初始化。

代码如下:

public class LoadClassTest {
    public static void main(String[] args) {
        try {
            ClassLoader.getSystemClassLoader().loadClass("com.dskj.jvm.passivemode.LoadClass");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

class LoadClass {
    public static final String STR = "Hello World";

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

没有输出 LoadClass init...,证明了调用系统类加载器的loadClass()方法,并不会初始化LoadClass类,因为ClassLoader#loadClass()方法内部传入的resolve参数为false,表示Class不会进入到连接阶段,也就不会导致类的初始化。

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

protected Class<?> loadClass(String name, boolean resolve)
		throws ClassNotFoundException
{
...
  if (resolve) {
		  //** Links the specified class**
			resolveClass(c);
	}
}

4)final修饰的常量,编译时会存入调用类常量池中,本质上没有引用到定义常量的类,不会导致类的初始化动作。

看下面代码:

public class ConstClassTest {
    public static void main(String[] args) {
        System.out.println(ConstClass.STR);
    }
}

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

    public static final String STR = "Hello World";
}

输出结果:

Hello World

结果只会输出 Hello World,不会输出ConstClass init...,ConstClassTest类对常量ConstClass.STR的引用,实际被转化为ConstClassTest类对自身常量池的引用了。也就是说,实际上ConstClassTest的Class文件之中并没有ConstClass类的符号引用入口。

编译完成,两个ConstClassTest和ConstClass就没有任何关系了。这句话如何能证明一下?

你可以先运行一次,然后将编译后的ConstClass.class文件从磁盘上删除掉,再次运行跟上面输出结果是一样的。

还不信?如下图所示Idea中的运行结果:

idea运行

在IDEA下测试时,如果你使用的Gradle来构建,模拟上面的删除class文件过程,要使用 xxx/out/production/ 目录下生成编译后的class文件,当类没有发生变化时不会重新生成class文件。如果使用默认的 xxx/build/xx,每次运行都会重新生成新的class文件。

如果有问题,可以在 Project Settings -> Modules -> 项目的 Paths 中调整编译输出目录。

我们继续在这个示例基础上做修改:

public class ConstClassTest {
    public static void main(String[] args) {
        System.out.println(ConstClass.STR);
    }
}

class ConstClass {
    // STR 定义的常量通过UUID生成一个随机串
    public static final String STR = "Hello World" + UUID.randomUUID();

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

注意,这里 STR 常量通过UUID生成一个随机串,编译是通过的。

直接运行,输出结果:

ConstClass init...
Hello World:d26d7f1d-2d46-41cb-b5dc-2b7b3fe61e74

看到了ConstClass init...,说明ConstClass类被初始化了。

将ConstClass.class文件删除后,再次运行:

Exception in thread "main" java.lang.NoClassDefFoundError: com/dskj/jvm/passivemode/ConstClass
	at com.dskj.jvm.passivemode.ConstClassTest.main(ConstClassTest.java:7)
Caused by: java.lang.ClassNotFoundException: com.dskj.jvm.passivemode.ConstClass
	at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	... 1 more

大家看到了吗?ConstClass.class文件被删除后,再次运行就发生了 java.lang.NoClassDefFoundError 异常了,为什么?正是因为 ConstClass 类里定义的STR常量并不是编译器能够确定的值,那么其值就不会被放到调用类的常量池中。

这个示例可以好好理解下,同时印证了该类的初始化时机中,主动使用和被动使用的场景。

大家记住一个类的8种主动使用情况,都是在开发过程中常见的使用方式。另外,注意下被动使用的几种情况,结合上面的列举的代码示例透彻理解。

类加载全过程的每一个阶段,结合前文给出的图示,详细展开。

4、类的加载(Loading)内幕透彻剖析

前面提到的类文件,就是后缀文件为.class的二进制文件。

JVM在加载阶段主要完成如下三件事

1)通过一个类的全限定名,即包名+类名 来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)JVM内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

对于第一点来说,并没有要求这个二进制字节流,具体以什么样的方式从Class文件中读取。

通过下面一张图来汇总一下:

文件来源

解释下比较常见的Class文件读取方式:

1)从ZIP包中读取Class文件,流行的SpringBoot/SpringCoud框架基本都打成Jar包形式,内嵌了Tomcat,俗称Fat Jar,通过java -jar可以直接启动,非常方便。

另外,还有一些项目仍然是使用War包形式,并且使用单独使用Tomcat这类应用容器来部署的。

2)运行时生成的Class文件,应用最多的就是动态代理技术了,比如CGLIB、JDK动态代理。

双亲委派模型

思考个问题,这些Class文件是由谁来加载的呢?

实现这个动作的代码正是类加载器来完成的,类加载器在类层次划分、OSGi、程序热部署、代码加密等领域大放异彩,成为Java技术体系中一块重要的基石。

对于任意一个类,如何确定在JVM当中的唯一性?必须是由加载该类的类加载器和该类本身一起共同确立在JVM中的唯一性。

每一个类加载器,都拥有一个独立的类名称空间。通俗理解:比较两个类是否『相等』,这两个类只有在同一个类加载器加载的前提下才有意义。否则,即使这两个类来源于同一个Class文件,被同一个JVM加载,只要加载它们的类加载器不同,那这两个类就必定不相等

类加载器之间是什么关系?

如下图所示,三种加载器之间的层次关系被称为类加载器的 『双亲委派模型(Parents Delegation Model)』。

类加载器

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。图:

这里说个有意思的问题,不止一次在某些文章留言中看到纠结:『为什么叫做双亲?』国外文章写的 parent delegation model,这里的parent不是单亲吗??应该翻译为单亲委派模型才对,全互联网都跟着错误走。。。其实parent这个英文单词翻译过来也有双亲的意思,不需要做个『杠精』,没啥意义哈。

双亲委派模型工作过程

结合类加载器的自底向上的委托关系总结:

假设一个类处于ClassPath下,版本是JDK8,默认使用应用类加载器进行加载。

1)当应用类加载器收到了类加载的请求,会把这个请求委派给它的父类(扩展类)加载器去完成。

2)扩展类加载器收到类加载的请求,会把这个请求委派给它的父类(引导类)加载器去完成。

3)引导类加载器收到类加载的请求,查找下自己的特定库是否能加载该类,即在rt.jar、tools.jar...包中的类。发现不能呀!返回给扩展类加载器结果。

4)扩展类加载器收到返回结果,查找下自己的扩展目录下是否能加载该类,发现不能啊!返回给应用类加载器结果。

5)应用类加载器收到结果,额!都没有加载成功,那只能自己加载这个类了,发现在ClassPath中找到了,加载成功。

你对并发很感兴趣,自己创建了个跟JDK一样的全限定名类LongAdder, java.util.concurrent.atomic.LongAdder,然后程序启动交给类加载器去加载,能成功吗?

当然不能!这个LongAdder是 Doug Lea 大神写的,贡献到JDK并发包下的,并且被安排在rt.jar包中了,因此是由 Bootstrap ClassLoader 类加载器优先加载的,别人谁写同样的类,那就是故意跟JDK作对,是绝对不容许的。

即使你写了同样的类,编译可以通过,但是永远不会被加载运行,被JDK直接忽略掉。

ClassLoader源码分析

双亲委派模型在JDK中内部是如何实现的?

JDK中提供了一个抽象的类加载器 ClassLoader,其中提供了三个非常核心的方法。

public abstract class ClassLoader {

	//每个类加载器都有个父加载器
	private final ClassLoader parent;

	public Class<?> loadClass(String name) {

			//查找一下这个类是不是已经加载过了
			Class<?> c = findLoadedClass(name);

			//如果没有加载过
			if( c == null ){
				//先委托给父加载器去加载,注意这是个递归调用
				if (parent != null) {
						c = parent.loadClass(name);
				}else {
						// 如果父加载器为空,查找Bootstrap加载器是不是加载过了
						c = findBootstrapClassOrNull(name);
				}
			}
			// 如果父加载器没加载成功,调用自己的findClass去加载
			if (c == null) {
					c = findClass(name);
			}

			return c;
	}

	protected Class<?> findClass(String name){
		 //1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
				...

		 //2. 调用defineClass将字节数组转成Class对象
		 return defineClass(buf, off, len);
	}

	// 将字节码数组解析成一个Class对象,用native方法实现
	protected final Class<?> defineClass(byte[] b, int off, int len){
		 ...
	}
}

参见ClassLoader核心代码注释,提取和印证几个关键信息:

1)JVM 的类加载器是分层次的,它们有父子关系,每个类加载器都有个父加载器,是parent字段。

2)loadClass() 方法是 public 修饰的,说明它才是对外提供服务的接口。根据源码可看出这是一个递归调用,父子关系是一种组合关系,子加载器持有父加载器的引用,当一个类加载器需要加载一个 Java 类时,会先委托父加载器去加载,然后父加载器在自己的加载路径中搜索 Java 类,当父加载器在自己的加载范围内找不到时,才会交还给子加载器加载,这就是所谓的『双亲委托模型』。

3)**findClass() **方法的主要职责就是找到 .class 文件,可能来自磁盘或者网络,找到后把.class文件读到内存得到byte[]字节码数组,然后调用 defineClass() 方法得到 Class 对象。

4)defineClass() 是个工具方法,它的职责是调用 native 方法把 Java 类的字节码解析成一个 Class 对象,所谓的 native 方法就是由 C 语言实现的方法,Java 通过 JNI 机制调用。

双亲委派模型在JDK不同版本中有哪些变化?

JDK8中的三层类加载器:

JDK8以及之前的JDK版本都是如下三层类加载器实现方式。

1)启动类加载器(Bootstrap ClassLoader),这个类加载器是由C++实现的,负载加载$JAVA_HOME/jre/lib目录下的jar文件,比如 rt.jar、tools.jar,或者-Xbootclasspath系统环境变量指定目录下的路径。它是个超级公民,即使开启了Security Manager的时候,它也能拥有加载程序的所有权限,使用null作为扩展类加载器的父类。

同时,启动类加载器在JVM启动后也用于加载扩展类加载器和系统类加载器。

获取ClassLoader源码

2)扩展类加载器(Extension ClassLoader),这个类加载器由sun.misc.Launcher$ExtClassLoader来实现,负责加载$JAVA_HOME/jre/lib/ext目录中,或者java.ext.dirs系统变量指定路径中所有的类库,允许用户将具备通用性的类库可以放到ext目录下,扩展Java SE功能。在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。

3)应用类加载器(App/System ClassLoader),也称作为系统类加载器,这个类加载器由sun.misc.Launcher$AppClassLoader来实现。 它负责加载用户应用类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

JDK9中的类加载器有哪些变化?

1)扩展类加载器被重命名为平台类加载器(Platform ClassLoader),部分不需要 AllPermission 的 Java 基础模块,被降级到平台类加载器中,相应的权限也被更精细粒度地限制起来。

2) 扩展类加载器机制被移除。这会带来什么影响呢?就是说如果我们指定 java.ext.dirs 环境变量,或者 $JAVA_HOME/jre/lib/ext目录存在,JVM会返回错误。 建议解决办法就是将其放入 classpath 里。部分不需要 AllPermission 的 Java 基础模块,被降级到平台类加载器中,相应的权限也被更精细粒度地限制起来。

3)在$JAVA_HOME/jre/lib路径下的 rt.jar 和 tools.jar 同样是被移除了。JDK 的核心类库以及相关资源,被存储在 jimage 文件中,并通过新的 JRT 文件系统访问,而不是原有的 JAR 文件系统。

4)增加了 Layer 的抽象, JVM 启动默认创建 BootLayer,开发者也可以自己去定义和实例化 Layer,可以更加方便的实现类似容器一般的逻辑抽象。

新增的Layer的抽象,去内部的BootLayer作为内建类加载器,包括了 BootStrap Loader、Platform Loader、Application Loader,其他 Layer 内部有自定义的类加载器,不同版本模块可以同时工作在不同的 Layer。

结合了 Layer,目前最新的 JVM 内部结构如下图所示:

Layer JVM内存结构

5、Tomcat如何打破双亲委派模型的

因为JDK里的类加载器ClassLoader是抽象类,如果你自定义类加载器可以重写 findClass() 方法,重写 findClass() 方法还是会按照既定的双亲委派机制运作的。

而我们发现loadClass()方法也是public修饰的,说明也是允许重写的,重写loadClass()方法就可以『为所欲为』了,不按照既定套路出牌了,不遵循双亲委派模型。

典型的就是Tomcat应用容器,就是自定义WebAppClassLoader类加载器,打破了双亲委派模型。

WebAppClassLoader 类加载器具体实现是重写了 ClassLoader 的两个方法:loadClass() 和 findClass()。其大致工作过程:首先类加载器自己尝试去加载某个类,如果找不到再委托代理给父类加载器,其目的是优先加载 Web 应用自己定义的类。

这也正是一个Tomcat能够部署多个应用实例的根本原因。

接下来,我们分析下源码实现:

loadClass() 重写方法的源码实现,仅保留最核心的代码便于理解:

// 重写了 loadClass() 方法
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

  // 使用了synchronized同步锁
	synchronized (getClassLoadingLock(name)) {

			Class<?> clazz = null;

			//1)先在本地缓存中,查找该类是否已经加载过
			clazz = findLoadedClass0(name);
			if (clazz != null) {
					if (resolve)
					    // 本地缓存找到,连接该类
							resolveClass(clazz);
					return clazz;
			}

			//2) 从系统类加载器的缓存中,查找该类是否已经加载过
			clazz = findLoadedClass(name);
			if (clazz != null) {
					if (resolve)
					    // 从系统类加载器缓存找到,连接该类
							resolveClass(clazz);
					return clazz;
			}

			// 3)尝试用ExtClassLoader类加载器类加载
			ClassLoader javaseLoader = getJavaseClassLoader();
			try {
					clazz = javaseLoader.loadClass(name);
					if (clazz != null) {
							if (resolve)
							    //  从扩展类加载器中找到,连接该类
									resolveClass(clazz);
							return clazz;
					}
			} catch (ClassNotFoundException e) {
					// Ignore
			}

			// 4)尝试在本地目录查找加载该类
			try {
					clazz = findClass(name);
					if (clazz != null) {
							if (resolve)
							    // 从本地目录找到,连接该类
									resolveClass(clazz);
							return clazz;
					}
			} catch (ClassNotFoundException e) {
					// Ignore
			}

			// 5) 尝试用系统类加载器来加载
					try {
							clazz = Class.forName(name, false, parent);
							if (clazz != null) {
									if (resolve)
									    // 从系统类加载器中找到,连接该类
											resolveClass(clazz);
									return clazz;
							}
					} catch (ClassNotFoundException e) {
							// Ignore
					}
		 }

	//6. 上述过程都加载失败,抛出异常
	throw new ClassNotFoundException(name);
}

loadClass() 重写的方法实现上会复杂些,毕竟打破双亲委派机制就在这里实现的。

主要有如下几个步骤:

1)先在本地缓存 Cache 查找该类是否已经加载过,即 Tomcat 自定义类加载器 WebAppClassLoader 是否已加载过。

2)如果 Tomcat 类加载器没有加载过这个类,再看看系统类加载器是否加载过。

3)如果系统类加载器也没有加载过,此时,会让 ExtClassLoader 扩展类加载器去加载,很关键,其目的防止 Web 应用自己的类覆盖 JRE 的核心类。

因为 Tomcat 需要打破双亲委托机制,假如 Web 应用里有类似上面举的例子自定义了 Object 类,如果先加载这些JDK中已有的类,会导致覆盖掉JDK里面的那个 Object 类。

这就是为什么 Tomcat 的类加载器会优先尝试用 ExtClassLoader 去加载,因为 ExtClassLoader 会委托给 BootstrapClassLoader 去加载,JRE里的类由BootstrapClassLoader安全加载,然后返回给 Tomcat 的类加载器。

这样 Tomcat 的类加载器就不会去加载 Web 应用下的 Object 类了,也就避免了覆盖 JRE 核心类的问题。

4)如果 ExtClassLoader 加载器加载失败,也就是说 JRE 核心类中没有这类,那么就在本地 Web 应用目录下查找并加载。

5)如果本地目录下没有这个类,说明不是 Web 应用自己定义的类,那么由系统类加载器去加载。这里请你注意:Web 应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。

6)如果上述加载过程全部失败,抛出 ClassNotFoundException 异常。

findClass() 重写方法的源码实现,仅展示最核心代码便于理解:

// 重写了 findClass 方法
public Class<?> findClass(String name) throws ClassNotFoundException {
    ...
    
	Class<?> clazz = null;
	try {
					//1) 优先在自己Web应用目录下查找类 
					clazz = findClassInternal(name);
	}  catch (RuntimeException e) {
				 throw e;
		 }

	if (clazz == null) {
	try {
					//2) 如果在本地目录没有找到当前类,则委托代理给父加载器去查找
					clazz = super.findClass(name);
	}  catch (RuntimeException e) {
				 throw e;
		 }

	//3) 如果父类加载器也没找到,则抛出ClassNotFoundException
	if (clazz == null) {
			throw new ClassNotFoundException(name);
	 }

	return clazz;
}

在 findClass() 重写的方法里,主要有三个步骤:

1)先在 Web 应用本地目录下查找要加载的类。

2)如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器 AppClassLoader。

3)如何父加载器也没找到这个类,抛出 ClassNotFoundException 异常。

6、上下文类加载器深入浅出剖析

我们都知道Jdbc是一个标准,那么具体数据库厂商会根据Jdbc标准提供自己的数据库实现,既然Jdbc是一个标准,这些类原生的会存在JDK中了,比如Connection、Statement,而且是位于rt.jar包中的,他们在启动的时候是由BootstrapClassLoader加载的。

那么怎么具体加载厂商的实现呢?

肯定是通过厂商提供相应的jar包,然后放到我们应用的ClassPath下,这样的话,厂商所提供的jar中的肯定不是由启动类加载器去加载的。

所以,厂商的具体驱动的实现是由应用类加载器进行加载的 。

Connection是一个接口,它是由启动类加载器加载的,而它具体的实现启动类加载器无法加载,由系统类加载器加载的。这样会存在什么样的问题?

根据**类加载原则: **

  • 父类加载器的加载类或接口是看不到子类加载器加载的类或接口的。
  • 子加载器所加载的类或接口是能看到父加载器加载的类或接口的。

SPI(Service Provider Interface)

父ClassLoader可以使用当前线程Thread.currentThread().getContextClassLoader()所指定的classloader加载的类。

这就改变了父ClassLoader不能使用子ClassLoader或是其他没有直接父子关系的ClassLoader所加载类的情况,即改变了双亲委托模型。

线程上下文类加载器就是当前线程的Current Classloader。

在双亲委托模型下,类加载器是由下而上,即下层的类加载器会委托上层进行加载。但是对于SPI来说,有些接口是Java核心库所提供的,而Java核心库是由启动类加载器来加载的,而这些接口的实现却来自于不同jar包(厂商提供),Java的启动类加载器是不会加载其他来源的jar包,这样传统的双亲委托模型就无法满足SPI的要求。

而通过给当前线程设置上下文类加载器,就可以由设置的上下文类加载器来实现对于接口实现类的加载。

线程上下文类加载器的一般使用模式:

获取 ---> 使用 --> 还原

ClassLoader classloader = Thread.currentThread().getContextClassLoader();
try {
     // 将目标类加载器设置到上下文类加载器
     Thread.currentThread().setContextClassLoader(targetTccl); 
		 // 在该方法中使用设置的上下文类加载器加载所需的类
     doSomethingUsingContextClassLoader(); 
} finally {
    // 将原来的classloader设置到上下文类加载器
    Thread.currentThread().setContextClassLoader(classloader); 
}

doSomethingUsingContextClassLoader()方法中则调用了 Thread.currentThread().getContextClassLoader() ,获取当前线程的上下文类加载器做某些事情。
如果一个类由类加载器A加载,那么这个类的依赖类也是由相同的类加载器加载的(如果该依赖类之前没有被加载过的话)。

在SPI的接口代码当中,就可以通过上下文类加载器成功的加载到SPI的实现类。因此,上下文类加载器在很多的SPI的实现中都会得到大量的应用。

当高层提供了统一的接口让低层(比如Jdbc各个厂商提供的具体实现类)去实现,同时又要在高层加载(或实例化)低层的类时,就必须要通过线程上下文类加载器来帮助高层的类加载器并加载该类(本质上,高层的类加载器与低层的类加载器是不一样的)

一般情况下,我们没有修改过线程上下文类加载器,默认的就是系统类加载器。由于是运行期间是设置的上下文类加载器,所以,不管当前程序在什么地方,在启动类的加载器的范围内还是扩展类加载器的范围内,那么我们在任何有需要的时候都是可以通过Thread.currentThread().getContextClassLoader()获取设置的上下文类加载器来完成操作。

这个也有点像ThreadLocal的类,如果借助于ThreadLocal的话就没有必要同步,因为每一个线程都有相应的数据副本,这些数据副本之间是互不干扰的,他们只能被当前的线程所使用和访问,既然每个线程都有数据副本,每个线程当然操作的是副本,所以线程之间就不需要同步、锁就可以处理并发。ThreadLocal本质上是用空间换时间的概念,因为我们将数据拷贝多份会占用一定的内存空间,每个线程中去使用。

7、最后的总结

限于篇幅,本文主要对类的初始化时机,类的加载过程中最重要的类加载器机制进行了分析,对其中的双亲委派模型,以及Tomcat是如何打破双亲委派模型的,结合源代码进行了深入剖析,对上下文类加载器是如何改变双亲委派模型进行了分析。

总结一下:

一个类都是通过主动使用的方式加载到JVM当中的,到目前为止一共总结了八种情况,除此之外的都属于被动使用,被动使用的列举了代码示例,结合示例可以更为清晰的理解。

详细介绍了双亲委派模型的工作过程,JDK8和JDK9版本中类加载器层次关系,类加载器的结果本质上并不是一种树形结构,而是一种包含关系。

同时,也介绍了Tomcat是如何打破双亲委派机制的,通过源码透视打破规则的全过程。

最后,对上下文类加载器根据Jdbc的例子,进一步分析了使用模式,如何改变双亲委派机制做到父类加载器,可以加载和使用各个厂商提供的实现类的。

另外,回到最初的图示,一个类要想顺利进入到JVM内存结构中,除了类的加载阶段外,还有验证、准备、解析、初始化四个阶段完成后,才算真正完成类的初始化操作。
在JVM中某个类的Class对象不再被引用,即不可触及,Class对象就会结束生命周期,该类在方法区内的数据会被卸载,从而技术该类的整个生命周期。

一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。

但是,JVM自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。前面已经介绍过,JVM自带的类加载器包括引导类加载器、扩展类加载器和系统类加载器(应用类加载器)。Java虚拟机本身会始终引用这些类加载器,而这些类加载器会始终引用它们所加载的类的Class对象,因此这些Class对象是始终可触及的。

在如下情况下,JVM将结束生命周期。

  • 执行了System.exit()

  • 程序正常执行结束

  • 程序在执行过程中遇到了异常或者错误而异常终止

  • 由于操作系统出现错误而导致Java虚拟机进程终止

大家如何觉得本文有收获关个注呗,码字不易,文章不妥之处,欢迎留言斧正。本号不定期会发布精彩原创文章。

参考资料:

深入理解Java虚拟机

极客时间课程

欢迎关注我的公众号,扫二维码关注获得更多精彩文章,与你一同成长~

Java爱好者社区

posted @ 2020-01-20 11:50  Java爱好者社区  阅读(749)  评论(4编辑  收藏  举报