ClassLoader详解 (JDK9以前)

1.概述

Java虚拟机把描述类的数据从Class文件加载到内存, 并对数据进行校验、转换解析和初始化, 最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
与那些在编译时需要进行连接的语言不同, 在Java语言里面, 类型的加载、 连接和初始化过程都是在程序运行期间完成的,
这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,
但是却为Java应用提供了极高的扩展性和灵活性, Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。 
例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,
让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。
这种动态组装应用的方式目前已广泛应用于Java程序之中,从最基础的Applet、JSP到相对复杂的OSGi技术,都依赖着Java语言运行期类加载才得以诞生。

简言之, 它是用来加载 Class 的。它负责将 Class 的字节码形式转换成内存形式的 Class 对象。
字节码可以来自于磁盘文件 *.class,也可以是 jar 包里的 *.class,也可以来自远程服务器提供的字节流,
字节码的本质就是一个字节数组 []byte,它有特定的复杂的内部格式。

JVM 运行实例中会存在多个 ClassLoader,不同的 ClassLoader 会从不同的地方加载字节码文件。
它可以从不同的文件目录加载,也可以从不同的 jar 文件中加载,也可以从网络上不同的静态文件服务器来下载字节码再加载。

1.1 基础知识

1.一个JVM实例(Java应用程序)里面的所有类都是通过ClassLoader加载的。
2.不同的ClassLoader在JVM中有不同的命名空间,一个类实例(Class)的唯一标识是: 【全类名 + ClassLoader】
也就是不同的ClassLoader加载同一个类文件,也会得到不相同的Class实例。
3.JVM不提供类卸载的功能,从目前参考到的资料来看,类卸载需要满足下面几点:
>> 条件一:Class的所有实例不被强引用(不可达)。
>> 条件二:Class本身不被强引用(不可达)。
>> 条件三:加载该Class的ClassLoader实例不被强引用(不可达)。

1.2 Java中的类加载器 (JDK9以前)

image

>> BootstrapClassLoader是启动类加载器,由 C 语言实现,用来加载 JVM启动时所需要的核心类,比如rt.jar、resources.jar等,加载System.getProperty("sun.boot.class.path")所指定的路径或jar。
>> ExtClassLoader是扩展类加载器,用来加载\jre\lib\ext目录下 JAR 包, 加载System.getProperty("java.ext.dirs")所指定的路径或jar。
>> AppClassLoader是系统类加载器,用来加载 classpath下的类,应用程序默认用它来加载类, 加载System.getProperty("java.class.path")所指定的路径或jar。
>> 自定义类加载器,用来加载自定义路径下的类。

这些类加载器的工作原理是一样的,区别是它们的加载路径不同,也就是说 findClass这个方法查找的路径不同。
双亲委托机制是为了保证一个 Java 类在 JVM 中是唯一的,假如你不小心写了一个与 JRE 核心类同名的类,
比如 Object类,双亲委托机制能保证加载的是 JRE里的那个 Object类,而不是你写的 Object类。
这是因为 AppClassLoader在加载你的 Object 类时,会委托给 ExtClassLoader去加载,
而 ExtClassLoader又会委托给 BootstrapClassLoader,BootstrapClassLoader发现自己已经加载过了 Object类,
会直接返回,不会去加载你写的 Object类。

1.3 URLClassLoader

那些位于网络上静态文件服务器提供的 jar 包和 class文件,jdk 内置了一个 URLClassLoader,
用户只需要传递规范的网络路径给构造器,就可以使用 URLClassLoader 来加载远程类库了。
URLClassLoader 不但可以加载远程类库,还可以加载本地路径的类库,取决于构造器中不同的地址形式。
ExtensionClassLoader 和 AppClassLoader 都是 URLClassLoader 的子类,它们都是从本地文件系统里加载类库。

1.4 context ClassLoader

#基本概念
ContextClassLoader其实指的是线程类java.lang.Thread中的contextClassLoader属性,它是ClassLoader类型,也就是类加载器实例。
有些场景下,JDK提供了一些标准接口需要第三方提供商去实现(最常见的就是SPI,Service Provider Interface,例如java.sql.Driver),这些标准接口类是由启动类加载器(Bootstrap ClassLoader)加载,但是这些接口的实现类需要从外部引入,本身不属于JDK的原生类库,无法用启动类加载器加载。
为了解决此困境,引入了线程上下文类加载器Thread Context ClassLoader。

在Thread类的【构造器】中, 初始化了当前Thread的ClassLoader, 默认与"父线程"(伪概念, 即创建此线程所在的线程)的context ClassLoader相同.
程序启动时的 main 线程的 contextClassLoader 就是 AppClassLoader。如果没有人工去设置,那么所有的线程的 contextClassLoader 都是AppClassLoader。

#功能
它可以做到跨线程共享类,只要它们共享同一个 contextClassLoader。
父子线程之间会自动传递 contextClassLoader,所以共享起来将是自动化的。

如果不同的线程使用不同的 contextClassLoader,那么不同的线程使用的类就可以隔离开来。
如果我们对业务进行划分,不同的业务使用不同的线程池,线程池内部共享同一个 contextClassLoader,
线程池之间使用不同的 contextClassLoader,就可以很好的起到隔离保护的作用,避免类版本冲突。

#应用示例
tomcat 和 spring 就根据这个原理定义了不同的类加载器.

1.4.3 线程上下文类加载器ContextClassLoader内存泄漏

1.4.3.1 JDK && Netty 曾经的ContextClassLoader内存泄漏 issue

https://github.com/netty/netty/issues/7290
https://github.com/netty/netty/pull/7493
https://bugs.openjdk.java.net/browse/JDK-7008595#

1.4.3.2 经典场景(热加载, 如 Tomcat热加载)

只要有大量热加载和卸载动态类的场景,就需要警惕后代线程ContextClassLoader设置不当导致内存泄漏。
例如定义一个接口,然后由外部动态传入代码的实现。比如: 在线编程,代码传到服务端再进行编译和运行。

由于应用启动期所有非JDK类库的类都是由AppClassLoader加载,我们没有办法通过AppClassLoader去加载非类路径下的已存在同名的类文件(对于一个ClassLoader而言,每个类文件只能加载一次,生成唯一的Class),
所以为了动态加载类,每次必须使用完全不同的自定义ClassLoader实例加载同一个类文件或者使用同一个自定义的ClassLoader实例加载不同的类文件。
如果新建过多的ClassLoader实例和Class实例,会占用大量的内存,这些ClassLoader实例和Class实例一直堆积无法卸载,那么就会导致内存泄漏
(memory leak,后果很严重,有可能耗尽服务器的物理内存,因为JDK1.8+类相关元信息存在在元空间metaspace,而元空间使用的是native memory)。

父线程中设置了一个自定义类加载器,用于加载动态类,子线程新建的时候直接使用了父线程的自定义类加载器,导致该自定义类加载器一直被子线程强引用,结合前面的类卸载条件分析,所有由该自定义类加载器加载出来的动态类都不能被卸载,导致了内存泄漏。

image

1.4.3.3 泄漏示例

1.4.3.4 解决方案

参考那两个Issue,解决方案(或者说预防手段)基本上有两个:
>> 不需要使用自定义类加载器的线程(如事件派发线程等)优先初始化,那么一般它的线程上下文类加载器是应用类加载器。
>> 新建后代线程的时候,手动覆盖它的线程上下文类加载器,参考Netty的做法,在线程初始化的时候做如下的操作:
// ThreadDeathWatcher || GlobalEventExecutor
AccessController.doPrivileged(new PrivilegedAction<Void>() {
    @Override
    public Void run() {
        watcherThread.setContextClassLoader(null);
        return null;
    }
});

1.5 loadClass, findClass, defineClass 方法

ClassLoader 里面有三个重要的方法 loadClass()、findClass() 和 defineClass(), 可通过覆盖这些方法来打破双亲委派机制。

1.loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,
2.如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。
ClassLoader 的 findClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。
3.拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。

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

99.Tomcat 类加载机制

99.1 重写loadClass方法和findClass方法, 打破双亲委派机制

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> clazz = null;

        //1. 先在本地 cache 查找该类是否已经加载过
        clazz = findLoadedClass0(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }

        //2. 从系统类加载器的 cache 中查找是否加载过
        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. 尝试在本地目录搜索 class 并加载
        try {
            clazz = findClass(name);
            if (clazz != null) {
                if (resolve)
                    resolveClass(clazz);
                return clazz;
            }
        } catch (ClassNotFoundException e) {
            // Ignore
        }

        // 5. 尝试用系统类加载器 (也就是 AppClassLoader) 来加载
            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);
}
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、如何父加载器也没找到这个类,抛出 ClassNotFound 异常。

99.2 Tomcat类加载过程

>> 如果不开启 delegate 模式
BootstrapClassLoader-->ExtensionClassLoader-->WebappClassLoader-->CommonClassLoader-->AppClassLoader(SystemClassLoader)
>> 如果开启 delegate 模式
BootstrapClassLoader-->ExtensionClassLoader-->AppClassLoader(SystemClassLoader)-->WebappClassLoader-->CommonClassLoader

1.先在本地 Cache 查找该类是否已经加载过,也就是说 Tomcat 的类加载器是否已经加载过这个类。

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

3.如果都没有,就让 ExtClassLoader 去加载,这一步比较关键,目的 防止 Web 应用自己的类覆盖 JRE 的核心类。
因为 Tomcat 需要打破双亲委托机制,假如 Web 应用里自定义了一个叫 Object 的类,
如果先加载这个 Object 类,就会覆盖 JRE 里面的那个 Object 类,这就是为什么 Tomcat 的类加载器会优先尝试用 ExtClassLoader去加载,
因为 ExtClassLoader会委托给 BootstrapClassLoader去加载,BootstrapClassLoader发现自己已经加载了 Object 类,直接返回给 Tomcat 的类加载器,
这样 Tomcat 的类加载器就不会去加载 Web 应用下的 Object 类了,也就避免了覆盖 JRE 核心类的问题。

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

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

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

从上面的过程我们可以看到,Tomcat 的类加载器打破了双亲委托机制,没有一上来就直接委托给父加载器,而是先在本地目录下加载,
为了避免本地目录下的类覆盖 JRE 的核心类,先尝试用 JVM 扩展类加载器 ExtClassLoader 去加载。
那为什么不先用系统类加载器 AppClassLoader 去加载?很显然,如果是这样的话,那就变成双亲委托机制了,这就是 Tomcat 类加载器的巧妙之处。

99.3 Tomcat为何打破双亲委派机制

1.一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,
不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
如果使用默认的类加载器就是双亲委派机制模式的,就无法加载两个相同类库的不同版本,
默认的类加器只看全限定类名。所以无法满足同一个类库的不同版本。
2.部署在同一个web容器中相同的类库相同的版本可以共享。
否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。
3.web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
4.web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,
但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启。

99.4 Tomcat中的类加载器结构

image

image

CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*、/server/*、/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。
其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

commonLoader(CATALINA_HOME/conf):
Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问, 从而实现了公有类库的共用;

catalinaLoader:
Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;

sharedLoader:
各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;

WebappClassLoader (WEB-INF/lib, WEB-INF/class)每一个Web应用程序对应一个WebApp类加载器
各个Webapp私有的类加载器,可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

JasperLoader:每一个JSP文件对应一个Jsp类加载器。
加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:
当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。
private void initClassLoaders() {
	// commonLoader的加载路径为common.loader
	commonLoader = createClassLoader("common", null);
	if( commonLoader == null ) {
		commonLoader=this.getClass().getClassLoader();
	}
	// 加载路径为server.loader,默认为空,父类加载器为commonLoader
	catalinaLoader = createClassLoader("server", commonLoader);
	// 加载路径为shared.loader,默认为空,父类加载器为commonLoader
	sharedLoader = createClassLoader("shared", commonLoader);
}

private ClassLoader createClassLoader(String name, ClassLoader parent) throws Exception {
	String value = CatalinaProperties.getProperty(name + ".loader");
	if ((value == null) || (value.equals("")))
		return parent;
		// catalinaLoader与sharedLoader的加载路径均为空,所以直接返回commonLoader对象,默认3者为同一个对象
}

100.spring中的类加载器

100.1 OverridingClassLoader

OverridingClassLoader 是 Spring 自定义的类加载器:
默认会先自己加载(excludedPackages 或 excludedClasses 例外),只有加载不到才会委托给双亲加载,这就破坏了 JDK 的双亲委派模式。
如果你不想你的类被自定义的类加载器管理,可以把它添加到这两个集合中,这样仍使用 JDK 的默认类加载机制。

参考资源
Tomcat架构解析.刘光瑞
深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明
https://www.cnblogs.com/throwable/p/12216546.html
http://ifeve.com/classloader/

posted @ 2021-06-05 20:06  psy_code  阅读(727)  评论(0编辑  收藏  举报