能不能自己写一个类叫java.lang.System/String?网上答案都是错的--ClassLoader详解

ClassLoader 是Java中的类加载器,其作用就是将class字节码文件加载到java虚拟机内存中。本文详细介绍了ClassLoader的实现机制。介绍完了ClassLoader,题目所提到的问题也就解决了。

类启动过程

相信学习过java的都知道,我们平时写的java代码(*.java)是不能直接运行的,只有编译后生成的.class文件才能被jvm识别。那么具体过程是什么呢?
类从加载到虚拟机内存中开始到卸载出内存为止,生命周期包括: 加载验证准备解析初始化使用卸载。其中验证、准备、解析,统称为链接。

类启动过程

如上图所示,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这个顺序来按部就班地开始,而解析阶段则不一定,它在某些情况下可以在初始化阶段后再开始。因为java支持运行时绑定。

加载(Loading)

类的加载,指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

加载.class文件的方式有:

  1. 从本地系统中直接加载
  2. 通过网络下载.class文件
  3. 从zip,jar等归档文件中加载.class文件
  4. 从专有数据库中提取.class文件
  5. 将Java源文件动态编译为.class文件

在了解了什么是类的加载后,回头来再看jvm进行类加载阶段都做了什么。虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名称来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

相对于类加载过程的其他阶段,加载阶段是开发期相对来说可控性比较强,本文也是主要介绍这一阶段。

验证(Verification)

验证的目的,是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证元数据的验证字节码验证符号引用验证

准备(Preparation)

类变量(即static修饰的字段变量)分配内存,并且设置该类变量的初始值即0(如static int i=5;这里只将i初始化为0,至于5的值将在初始化时赋值),这里不包含用final修饰的static,因为final在编译的时候就会分配了,注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

两个关键点,即内存分配的对象以及初始化的类型。

  • 内存分配的对象。Java 中的变量有类变量和实例变量两种类型,类变量指的是被 static 修饰的变量,而其他所有类型的变量都属于实例变量。在准备阶段,JVM只会为类变量分配内存,而不会为实例变量分配内存。实例变量的内存分配需要等到初始化阶段才开始。
  • 初始化的类型。在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值(如0、0L、null、false等),而不是用户代码里初始化的值。

解析(Resolution)

解析阶段JVM 针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类引用进行解析。这个阶段的主要任务是将其在常量池中的符号引用替换成在内存中的直接引用

  • 符号引用(Symbolic Reference):符号引用,以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经在内存中。

  • 直接引用(Direct Reference):直接引用,可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

初始化(Initialization)

初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化。在Java中对类变量进行初始值设定有两种方式:

  • 定义静态变量时指定初始值。如 private static String x="123";
  • 在静态代码块里为静态变量赋值。如 static{ x="123"; }

JVM初始化步骤:

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句

卸载(UnLoading)

在以下情况的时候,Java虚拟机会结束生命周期:

  • 执行了System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

以上只是粗略介绍了一下类的加载过程,本文不对此做详细展开。

初步介绍类加载器

前文已介绍了类的启动过程,其中第一步就是加载,“类加载器”的任务是,根据一个类的全限定名来读取此类的二进制字节流到JVM中,然后转换为一个与目标类对应的java.lang.Class对象实例。
在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。
虚拟机提供了3种类加载器,启动(Bootstrap)类加载器扩展(Extension)类加载器应用程序(Application)类加载器(也称系统类加载器)。

启动(Bootstrap)类加载器

Bootstrap类加载器主要加载的是JVM自身需要的类,这个加载器是使用C++语言实现的,是虚拟机自身的一部分,它负责将 <JAVA_HOME>/lib 路径下的核心类库,或 -Xbootclasspath 参数指定的路径下的jar包加载到内存中,注意由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。

扩展(Extension)类加载器

扩展类加载器是指 sun.misc.Launcher$ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载 <JAVA_HOME>/lib/ext 目录下,或者系统变量 -Djava.ext.dir 指定路径中的类库,开发者可以直接使用标准扩展类加载器。

// ExtClassLoader 部分代码
static class ExtClassLoader extends URLClassLoader {
        private static volatile Launcher.ExtClassLoader instance;
        ...
//加载<JAVA_HOME>/lib/ext目录中的类库
        private static File[] getExtDirs() {
            String var0 = System.getProperty("java.ext.dirs");
            File[] var1;
            if (var0 != null) {
                StringTokenizer var2 = new StringTokenizer(var0, File.pathSeparator);
                int var3 = var2.countTokens();
                var1 = new File[var3];

                for(int var4 = 0; var4 < var3; ++var4) {
                    var1[var4] = new File(var2.nextToken());
                }
            } else {
                var1 = new File[0];
            }

            return var1;
        }
        ...
    }

应用程序(Application)类加载器

也叫系统类加载器,应用程序加载器 sun.misc.Launcher$AppClassLoader 类。它负责加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用应用程序类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。

//AppClassLoader 部分代码
    static class AppClassLoader extends URLClassLoader {
        final URLClassPath ucp = SharedSecrets.getJavaNetAccess().getURLClassPath(this);

        public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {
            final String var1 = System.getProperty("java.class.path");
            final File[] var2 = var1 == null ? new File[0] : Launcher.getClassPath(var1);
            return (ClassLoader)AccessController.doPrivileged(new PrivilegedAction<Launcher.AppClassLoader>() {
                public Launcher.AppClassLoader run() {
                    URL[] var1x = var1 == null ? new URL[0] : Launcher.pathToURLs(var2);
                    return new Launcher.AppClassLoader(var1x, var0);
                }
            });
        }

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器。
需要注意的是,Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模型(Parent Delegation Model)

双亲委派模型

该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。子类加载器和父类加载器不是以继承(Inheritance)的关系来实现,而是通过组合(Composition)关系来复用父加载器的代码。
双亲委派模型的工作过程为:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的加载器都是如此,因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法完成该加载请求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。

在这里插入图片描述

使用这种模型来组织类加载器之间的关系的好处,是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。通过这种层级关可以避免类的重复加载,当父亲加载器已经加载了该类时,就子加载器就不会再加载一次。例如java.lang.Object类,无论哪个类加载器去加载该类,最终都是由启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。否则的话,如果不使用该模型的话,如果用户自定义一个java.lang.Object类且存放在classpath中,那么系统中将会出现多个Object类,应用程序也会变得很混乱。如果我们自定义一个rt.jar中已有类的同名Java类,会发现JVM可以正常编译,但该类永远无法被加载运行。

详解类加载器

下面我们从代码层面了解几个Java中定义的类加载器及其双亲委派模式的实现,它们类图关系如下:


在这里插入图片描述

从图可以看出顶层的类加载器是ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器),这里我们主要介绍ClassLoader中几个比较重要的方法。

  • loadClass(String)
    该方法加载指定名称(包括包名)的二进制类型,该方法在JDK1.2之后不再建议用户重写,但用户可以直接调用该方法,loadClass()方法是ClassLoader类自己实现的,该方法中的逻辑就是双亲委派模式的实现,其源码如下,loadClass(String name, boolean resolve)是一个重载方法,resolve参数代表是否生成class对象的同时进行解析相关操作。
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            // 先从缓存查找该class对象,找到就不用重新加载
            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.
                    // 如果都没有找到,则通过自定义实现的findClass去查找并加载
                    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;
        }
    }

正如loadClass方法所展示的,当类加载请求到来时,先从缓存中查找该类对象,如果存在直接返回,如果不存在则交给该类加载器的父加载器去加载,倘若没有父加载则交给顶级启动类加载器去加载,最后倘若仍没有找到,则使用findClass()方法去加载。

  • findClass(String)
    在JDK1.2之前,在JDK1.2之后已不再建议用户去覆盖loadClass() 方法,而是建议把自定义的类加载逻辑写在findClass() 方法中,从前面的分析可知,findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自己的 findClass() 方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模型。ClassLoader类中findClass()方法源码如下:
protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
  • defineClass(String name, byte[] b, int off, int len)
    defineClass()方法是用来将byte字节流解析成JVM能够识别的Class对象(ClassLoader中已实现该方法逻辑),通过这个方法不仅能够通过class文件实例化class对象,也可以通过其他方式实例化class对象,如通过网络接收一个类的字节码,然后转换为byte字节流创建对应的Class对象,defineClass()方法通常与findClass()方法一起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象,URLClassLOader中findClass代码如下:
protected Class<?> findClass(final String name)
        throws ClassNotFoundException
    {
        final Class<?> result;
        try {
            result = AccessController.doPrivileged(
                new PrivilegedExceptionAction<Class<?>>() {
                    public Class<?> run() throws ClassNotFoundException {
                        String path = name.replace('.', '/').concat(".class");
                        Resource res = ucp.getResource(path, false);
                        if (res != null) {
                            try {
                                return defineClass(name, res);
                            } catch (IOException e) {
                                throw new ClassNotFoundException(name, e);
                            }
                        } else {
                            return null;
                        }
                    }
                }, acc);
        } catch (java.security.PrivilegedActionException pae) {
            throw (ClassNotFoundException) pae.getException();
        }
        if (result == null) {
            throw new ClassNotFoundException(name);
        }
        return result;
    }
  • resolveClass(Class≺?≻ c)
    该方法可以使类的Class对象创建完成同时被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用。

上述4个方法是ClassLoader类中的比较重要且常用的方法。SercureClassLoader 扩展了 ClassLoader,新增了几个与使用相关的代码源(对代码源的位置及其证书的验证)和权限定义类验证(主要指对class源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多是与它的子类 URLClassLoader 有所关联,前面说过,ClassLoader是一个抽象类,很多方法是空的没有实现,比如 findClass()、findResource()等。而URLClassLoader这个实现类为这些方法提供了具体的实现。

在这里插入图片描述

这里额外介绍一下 sun.misc.URLClassPath 类,通过这个类就可以找到要加载的字节码流,也就是说URLClassPath类负责找到要加载的字节码,再读取成字节流,如 URLClassLOader中findClass代码中有下面这句:

Resource res = ucp.getResource(path, false);

这里的 ucp 就是 URLClassPath,。如上类图所示,URLClassPath 有3个内部类,分别是FileLoader、JarLoader、Loader,加载的字节码流的具体工作就是由这些内部类完成。至于如何分配,在创建URLClassPath对象时,会根据传递过来的URL数组中的路径判断是文件还是jar包,然后根据不同的路径创建FileLoader或者JarLoader或默认Loader类。


在这里插入图片描述

了解完URLClassLoader后接着看看剩余的两个类加载器,即拓展类加载器ExtClassLoader和应用程序类加载器AppClassLoader,这两个类都继承自URLClassLoader,是sun.misc.Launcher的静态内部类。sun.misc.Launcher主要被系统用于启动主应用程序,ExtClassLoader和AppClassLoader都是由sun.misc.Launcher创建的,其类主要类结构如下:


在这里插入图片描述

它们间的关系正如前面所阐述的那样,同时我们发现ExtClassLoader并没有重写loadClass()方法,这足矣说明其遵循双亲委派模式,而AppClassLoader重载了loadCass()方法,但最终调用的还是父类loadClass()方法,因此依然遵守双亲委派模式。

类加载器间的关系

我们进一步了解类加载器间的关系(并非指继承关系),主要可以分为以下4点

  • 启动类加载器,由C++实现,没有父类。
  • 拓展类加载器(ExtClassLoader),由Java语言实现,父类加载器为null
  • 系统类加载器(AppClassLoader),由Java语言实现,父类加载器为ExtClassLoader
  • 自定义类加载器,在没有指定的情况下,父类加载器默认为当前系统加载器,即 AppClassLoader。

直接看源码,以下是 Lancher的构造器源码:

public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
            //首先创建拓展类加载器
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
            //再创建AppClassLoader并把var1作为父加载器传递给AppClassLoader
            this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }

        Thread.currentThread().setContextClassLoader(this.loader);
        ...
    }

显然Lancher初始化时首先会创建ExtClassLoader类加载器,然后再创建AppClassLoader并把ExtClassLoader传递给它作为父类加载器,这里还把AppClassLoader默认设置为线程上下文类加载器,关于线程上下文类加载器稍后会分析。那ExtClassLoader类加载器为什么是null呢?看下面的源码创建过程就明白,在创建ExtClassLoader强制设置了其父加载器为null。

public ExtClassLoader(File[] var1) throws IOException {
            super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
            SharedSecrets.getJavaNetAccess().getURLClassPath(this).initLookupCache(this);
        }

再来看下自定义类加载器,ClassLoader 有两个protected类型的构造器,如下:

protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }

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

因此我们自定义类加载器时,可以指定父类加载器,若未指定则默认为系统类加载器:

public class MyClassLoader extends ClassLoader {
    //类存放的路径
    private String rootDir;

    public MyClassLoader(String rootDir) {
        super(ClassLoader.getSystemClassLoader().getParent());
        this.rootDir = rootDir;
    }
    。。。

编写自己的类加载器

通过前面的分析可知,实现自定义类加载器需要继承ClassLoader或者URLClassLoader,继承ClassLoader则需要自己重写findClass()方法并编写加载逻辑,继承URLClassLoader则可以省去编写findClass()方法以及class文件加载转换成字节码流的代码。那么编写自定义类加载器的意义何在呢?

  • 当class文件不在ClassPath路径下,默认系统类加载器无法找到该class文件,在这种情况下我们需要实现一个自定义的ClassLoader来加载特定路径下的class文件生成class对象。

  • 当一个class文件是通过网络传输并且可能会进行相应的加密操作时,需要先对class文件进行相应的解密后再加载到JVM内存中,这种情况下也需要编写自定义的ClassLoader并实现相应的逻辑。

  • 当需要实现热部署功能时(一个class文件通过不同的类加载器产生不同class对象从而实现热部署功能),需要实现自定义ClassLoader的逻辑。

好了,下面开始编写自己的类加载器,代码结构如下:


在这里插入图片描述

MyClassLoader 代码如下:

package classloader;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;

public class MyClassLoader extends ClassLoader {
    //类存放的路径
    private String rootDir;

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

    /**
     * 重写findClass方法
     */
    @Override
    public Class<?> findClass(String name) {
        byte[] data = loadClassData(name);
        // 调用父类的 defineClass 方法
        return this.defineClass(name, data, 0, data.length);
    }

    /**
     * 编写获取class文件并转换为字节码流的逻辑
     * @param name
     * @return
     */
    public byte[] loadClassData(String name) {
        try {
            name = rootDir + name.replace(".", File.separator) + ".class";
            FileInputStream is = new FileInputStream(name);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int b;
            while ((b = is.read()) != -1) {
                baos.write(b);
            }
            return baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

MyURLClassLoader 代码如下:

package classloader;

import java.net.URL;
import java.net.URLClassLoader;

public class MyURLClassLoader extends URLClassLoader {
    public MyURLClassLoader(URL[] urls) {
        super(urls);
    }
}

可以看到的是,继承自 URLClassLoader 的类加载器代码要比继承自 ClassLoader 的类继承器代码简单很多,这也符合我们上文的介绍。
再也看下如何使用自定义的类加载器。
Animal代码如下:

package classloader;

class Animal {
    public void say() {
        System.out.println("hello world!");
    }
}

ClassLoaderTest 代码:

package classloader;

import java.io.File;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;

public class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException, MalformedURLException {
        String rootDir = "/Users/lvbing/classloader/";
        String className = "classloader.Animal";

        myClassLoaderTest(rootDir, className);
        myURLClassLoaderTest(rootDir, className);
    }

    private static void myClassLoaderTest(String rootDir, String className) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        //新建一个类加载器
        MyClassLoader cl = new MyClassLoader(rootDir);
        //加载类,得到Class对象
        Class<?> clazz = cl.loadClass(className);
        //得到类的实例
        Object obj = clazz.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println("loadClass->hashCode:" + clazz.hashCode());
    }

    private static void myURLClassLoaderTest(String rootDir, String className) throws MalformedURLException, ClassNotFoundException, IllegalAccessException, InstantiationException {
        //创建自定义文件类加载器
        File file = new File(rootDir);
        //File to URI
        URI uri = file.toURI();
        URL[] urls = {uri.toURL()};

        MyURLClassLoader loader = new MyURLClassLoader(urls);

        //加载指定的class文件
        Class<?> clazz = loader.loadClass(className);
        //得到类的实例
        Object obj = clazz.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println("loadClass->hashCode:" + clazz.hashCode());
    }

}

我们运行下 ClassLoaderTest,看看结果:

sun.misc.Launcher$AppClassLoader@18b4aac2
loadClass->hashCode:1625635731
sun.misc.Launcher$AppClassLoader@18b4aac2
loadClass->hashCode:1625635731

可以看到真正加载Animal类的加载器是AppClassLoader,这是因为Animal类已经在classPath路径下,这也验证了双亲委派模型。至于为什么hashCode 也一致,可以看下上述关于loadClass方法的介绍,这是先从缓存查找的结果。需要注意的是,这个缓存是与ClassLoader的实例绑定的,不同的ClassLoader实例缓存也不一样。
我们现在把Animal.class移到测试目录,并把Animal类注释掉,再运行一次:

classloader.MyClassLoader@60e53b93
loadClass->hashCode:491044090
classloader.MyURLClassLoader@6f94fa3e
loadClass->hashCode:1725154839

结果完全符合预期。
我们修改一下测试代码,调用两次myClassLoaderTest方法:

public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException, MalformedURLException {
        String rootDir = "/Users/lvbing/classloader/";
        String className = "classloader.Animal";

        myClassLoaderTest(rootDir, className);
        myClassLoaderTest(rootDir, className);
//        myURLClassLoaderTest(rootDir, className);
    }

结果如下:

classloader.MyClassLoader@60e53b93
loadClass->hashCode:491044090
classloader.MyClassLoader@266474c2
loadClass->hashCode:1581781576

可以看到hashCode也不一致,这就是因为每个ClassLoader实例对象都有自己的缓存。
这里可以延伸一个概念,在JVM中表示两个class对象是否为同一个类对象存在两个必要条件

  • 类的完整类名必须一致,包括包名。
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。

也就是说,在JVM中,即使这个两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。

能不能自己写一个类叫java.lang.System/String

这是网上的一道面试题,先说答案:
下面通过代码来验证。
新增Math类

package java.lang;

public class Math {
    public void say(){
        System.out.println("hello Math!");
    }
}

注意包名,编译后移走Math.class到测试目录:


image.png

在ClassLoaderTest类中新增代码:

private static void mathTest(String rootDir) throws IllegalAccessException, InstantiationException {
        String className = "java.lang.Math";
        //新建一个类加载器
        MyClassLoader cl = new MyClassLoader(rootDir);
        //注意这里用的findClass方法,为了避开缓存
        Class<?> clazz = cl.findClass(className);
        //得到类的实例
        Object obj = clazz.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println("loadClass->hashCode:" + clazz.hashCode());
    }

运行结果如下:

Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
    at java.lang.ClassLoader.preDefineClass(ClassLoader.java:655)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:754)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:635)
    at classloader.MyClassLoader.findClass(MyClassLoader.java:22)
    at classloader.ClassLoaderTest.mathTest(ClassLoaderTest.java:51)
    at classloader.ClassLoaderTest.main(ClassLoaderTest.java:15)

ClassLoader.java的655行抛出了一个SecurityException异常,看下这段代码:

private ProtectionDomain preDefineClass(String name,
                                            ProtectionDomain pd)
    {
        if (!checkName(name))
            throw new NoClassDefFoundError("IllegalName: " + name);

        // Note:  Checking logic in java.lang.invoke.MemberName.checkForTypeAlias
        // relies on the fact that spoofing is impossible if a class has a name
        // of the form "java.*"
        if ((name != null) && name.startsWith("java.")) {
            throw new SecurityException
                ("Prohibited package name: " +
                 name.substring(0, name.lastIndexOf('.')));
        }
        if (pd == null) {
            pd = defaultDomain;
        }

        if (name != null) checkCerts(name, pd.getCodeSource());

        return pd;
    }

很明显,在name不为null的情况下,会检查name是否以"java."开头,那如果name为null呢?


在这里插入图片描述

再次运行:

Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:635)
    at classloader.MyClassLoader.findClass(MyClassLoader.java:22)
    at classloader.ClassLoaderTest.mathTest(ClassLoaderTest.java:51)
    at classloader.ClassLoaderTest.main(ClassLoaderTest.java:15)

依然抛出异常,不过这次不再是preDefineClass抛出的了,而是一个native方法:

private native Class<?> defineClass1(String name, byte[] b, int off, int len,
                                         ProtectionDomain pd, String source);

defineClass底层调用的是native方法,并且defineClass是protected final的,无法重写(能重写也没用)。
等等,上面的答案不是说能吗?怎么到现在为止都不行呢?
还记得开头关于Bootstrap ClassLoader的介绍吗?我们可以通过 -Xbootclasspath 参数来指定 Bootstrap加载目录。具体介绍如下:

参数 效果
-Xbootclasspath:<path> 完全取代核心的Java class 搜索路径。不常用,否则要重新写所有Java 核心class
-Xbootclasspath/p:<path> 前缀在核心class搜索路径前面。也就是优先搜索参数指定路径。不常用,避免引起不必要的冲突.
-Xbootclasspath/a:<path> 后缀在核心class搜索路径后面。也就是其他路径都搜完了,再搜索参数指定路径。常用

这里我们使用-Xbootclasspath/p:<path>参数。先把系统的Math类代码复制到我们自己写的Math类中,再把当前项目打成jar包,最后执行如下命令:

 java -Xbootclasspath/a:/Users/lvbing/classloader/java-advanced-1.0-SNAPSHOT.jar -verbose > test.txt

打开test.txt文件,发现如下记录:


在这里插入图片描述

在这里插入图片描述

可以看到java.lang.Math加载自我们自己打包的jar中,由此可见,我们确实可以自己编写以"java."开头的代码,但必须交由Bootstrap ClassLoader加载。

双亲委派模型的破坏者-线程上下文类加载器

在Java应用中存在着很多服务提供者接口(Service Provider Interface,SPI),这些接口允许第三方为它们提供实现,如常见的 SPI 有 JDBC、JNDI等,这些 SPI 的接口属于 Java 核心库,一般存在rt.jar包中,由Bootstrap类加载器加载。而 SPI 的第三方实现代码则是作为Java应用所依赖的 jar 包被存放在classpath路径下,由于SPI接口中的代码经常需要加载具体的第三方实现类并调用其相关方法,但SPI的核心接口类是由启动类加载器来加载的,而Bootstrap类加载器无法直接加载SPI的实现类,同时由于双亲委派模式的存在,Bootstrap类加载器也无法反向委托AppClassLoader加载器SPI的实现类。在这种情况下,我们就需要一种特殊的类加载器来加载第三方的类库,而线程上下文类加载器就是很好的选择。

线程上下文类加载器(contextClassLoader)是从 JDK 1.2 开始引入的,我们可以通过java.lang.Thread类中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)方法来获取和设置线程的上下文类加载器。如果没有手动设置上下文类加载器,线程将继承其父线程的上下文类加载器,初始线程的上下文类加载器是系统类加载器(AppClassLoader),在线程中运行的代码可以通过此类加载器来加载类和资源,如下图所示,以jdbc.jar加载为例。

在这里插入图片描述

从图可知rt.jar核心包是有Bootstrap类加载器加载的,其内包含SPI核心接口类,由于SPI中的类经常需要调用外部实现类的方法,而jdbc.jar包含外部实现类(jdbc.jar存在于classpath路径)无法通过Bootstrap类加载器加载,因此只能委派线程上下文类加载器把jdbc.jar中的实现类加载到内存以便SPI相关类使用。显然这种线程上下文类加载器的加载方式破坏了“双亲委派模型”,它在执行过程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器,当然这也使得Java类加载器变得更加灵活。
为了进一步证实这种场景,不妨看看DriverManager类的源码,DriverManager是Java核心rt.jar包中的类,该类用来管理不同数据库的实现驱动即Driver,它们都实现了Java核心包中的java.sql.Driver接口,如mysql驱动包中的com.mysql.jdbc.Driver,这里主要看看如何加载外部实现类,在DriverManager初始化时会执行如下代码。

public class DriverManager {
    /**
     * Load the initial JDBC drivers by checking the System property
     * jdbc.properties and then use the {@code ServiceLoader} mechanism
     */
    static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }
    
    private static void loadInitialDrivers() {
        ...
        
        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {

                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                ...
            }
        });
        ...
    }

在DriverManager类初始化时执行了loadInitialDrivers()方法,在该方法中通过ServiceLoader.load(Driver.class);去加载外部实现的驱动类。
load() 方法实现如下:

public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }

很明显了确实通过线程上下文类加载器加载的,实际上核心包的SPI类对外部实现类的加载都是基于线程上下文类加载器执行的,通过这种方式实现了Java核心代码内部去调用外部实现类。我们知道线程上下文类加载器默认情况下就是AppClassLoader,那为什么不直接通过getSystemClassLoader()获取类加载器来加载classpath路径下的类的呢?其实是可行的,但这种直接使用getSystemClassLoader()方法获取AppClassLoader加载类有一个缺点,那就是代码部署到不同服务时会出现问题,如把代码部署到Java Web应用服务或者EJB之类的服务将会出问题,因为这些服务使用的线程上下文类加载器并非AppClassLoader,而是Java Web应用服自家的类加载器,类加载器不同。,所以我们应用该少用getSystemClassLoader()。总之不同的服务使用的可能默认ClassLoader是不同的,但使用线程上下文类加载器总能获取到与当前程序执行相同的ClassLoader,从而避免不必要的问题。

以上测试代码已上传至github:https://github.com/lvbabc/practice-demo/tree/main/java-advanced

参考资料
https://blog.csdn.net/javazejian/article/details/73413292
https://blog.csdn.net/m0_43452671/article/details/89892706
https://juejin.im/post/6844903564804882445

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容