深入理解类加载和类加载器

180 阅读16分钟

之前开发中处理了一个方法区OOM的问题,并在掘金上分享相关的排查经历和相关工具,具体可以看这篇文章: 一次由热部署导致的OOM排查经历juejin.cn/post/707976… 问题排查中,发现对类加载和类加载器的理解还不够清晰,查阅相关资料,结合具体代码,总结了一些关于类加载和类加载器的基础概念。

相关代码:github.com/pengchengSU…

类的加载

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。 在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,为Java应用提供了极高的扩展性和灵活性,例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类(例如下面会提到的SPI机制);通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分(JSP、字节码增强)。

类加载的时机

什么时候开始类加载过程的第一个阶段--“加载”,《Java虚拟机规范》没有进行强制约束,由虚拟机的具体实现来把握;《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”,同时保证在此之前完成加载、验证、准备,这六种场景称之为对类的主动使用

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段;
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化;
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类;
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化;
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载的过程

  • 加载:查找并加载类的二进制数据,将静态存储结构转化为方法区的运行时数据结构;
  • 连接:
    • 验证:确保被加载的类的正确性;
    • 准备:为类的静态变量分配内存,并将其初始化为默认值;
    • 解析:把类中的符号引用转换为直接引用;
  • 初始化:为类的静态变量赋予正确的初始;

这边只是简单总结了类加载的各个阶段的工作,更具体的内容可以参考《深入理解Java虚拟机》的相关章节,这里就不再赘述。

类加载器

Java虚拟机设计团队有意把类加载阶段中的通过一个类的全限定名来获取描述该类的二进制字节流这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。 对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。 有两种类型的类加载器:

  • Java虚拟机自带的加载器
    • 根类加载器(Bootstrap)
    • 扩展类加载器(Extension)
    • 系统(应用)类加载器(System)
  • 用户自定义的类加载器
    • java.lang.CssLoader的子类,用户可以定制类的加载方式

由 Java 虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。Java 虚拟机自带的类加载器包括根类加载器、扩展类加载器和系统类加载器,Java 虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的Class对象,因此这些Class对象始终是可触及的。 由用户自定义的类加载器所加载的类是可以被卸载的。 在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,Class对象总是会引用它的类加载器,getClassLoader()方法,就能获得它的类加载器。由此可见,Class实例与classLoader之间为双向关联关系。 file

file

看下JVM自带的3个类加载器:

public static void testClassLoader() {
    ClassLoader appClassLoader = MyClassLoader.class.getClassLoader();
    ClassLoader extClassLoader = appClassLoader.getParent();
    // 启动类加载器由C++代码实现,此处返回null
    ClassLoader bootstrapClassLoader = extClassLoader.getParent();
    
    System.out.println("应用类加载器: " + appClassLoader);
    System.out.println("扩展类加载器: " + extClassLoader);
    System.out.println("启动类加载器: " + bootstrapClassLoader);
}

file

不同的类加载器加载类文件的位置是不同,可以通过打印系统变量获取:

// 6、不同类加载器加载class文件的位置
public static void testClassPath() {
    // 启动类加载器
    System.out.println(System.getProperty("sun.boot.class.path"));
    // 扩展类加载器
    System.out.println(System.getProperty("java.ext.dirs"));
    // 系统类加载器
    System.out.println(System.getProperty("java.class.path"));
}

双亲委托模型

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。 看下ClassLoader类的loadClass()方法的伪代码,就能明白classloader是如何通过组合父加载器来实现双亲委托模型的了:

protected Class<?> loadClass(String name, boolean resolve) {
    synchronized (getClassLoadingLock(name)) {
        // 首先检查类是否已加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            // 先尝试通过该加载器的父加载器或启动类加载器加载
            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) {
                // 如果还找不到,调用findClass查找类
                c = findClass(name);
            }
        }
        // 如果是true的话,将该加载器链接到加载的类
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

自定义类加载器

这一节,我们实现一个简单的自定义类加载器 MyClassLoader,通过阅读ClassLoader抽象类的JavaDoc,注意到以下几个成员方法比较重要:

  • Class<?> findClass(String className) 根据全限定类名找到类,ClassLoader的实现类需要重写该方法,loadClass()方法在检查完父加载器后会调用该方法
  • Class<?> defineClass(String name, byte[] b, int off, int len) 将一个字节数组转换为Class类的实例
  • Class<?> loadClass(String name) 根据全限定类名找到类,推荐自定义类加载器重写findClass(),而不是当前方法

接下来,按照JavaDoc的描述,通过重写findClass方法来实现我们的自定义类加载器 MyClassLoader

public class MyClassLoader extends ClassLoader {
    private String classLoaderName;
    private String path;
    private final String extension = ".class";

    public MyClassLoader(String classLoaderName) {
        super(); //将系统类加载器作为该类加载器的父加载器
        this.classLoaderName = classLoaderName;
    }

    public MyClassLoader(String classLoaderName, ClassLoader parent) {
        super(parent); //显式指定该类加载器的父加载器
        this.classLoaderName = classLoaderName;
    }

    //@Override
    //public String toString() {
    //    return "[" + this.classLoaderName + "]";
    //}

    public void setPath(String path) {
        this.path = path;
    }

    @Override
    protected Class<?> findClass(String className) {
        //确认自定义加载器是否执行
        System.out.println("findClass() className: " + className);
        System.out.println("findClass() classLoaderName: " + toString());

        byte[] data = this.loadClassData(className);
        return this.defineClass(className, data, 0, data.length);
    }

    // 读取文件系统的上二进制字节码
    private byte[] loadClassData(String className) {
        FileInputStream fin = null;
        ByteArrayOutputStream baos = null;
        byte[] data = null;
        // 将全限定类名转换成对应文件系统的层次结构
        className = className.replace(".", "/");
        try {
            // 加上 .class 扩展名
            fin = new FileInputStream(this.path + className + this.extension);
            baos = new ByteArrayOutputStream();
            int ch;
            while (-1 != (ch = fin.read())) {
                baos.write(ch);
            }
            data = baos.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                fin.close();
                baos.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return data;
    }
}

这样,一个简单的自定义类加载器就实现了,来测试下这个类加载器的功能:

public static void testMyClassLoader() throws Exception {
    MyClassLoader myClassLoader = new MyClassLoader("myClassLoader");
    Class<?> clazz = myClassLoader.loadClass("com.example.testclassloader.MyClass");
    Object o = clazz.newInstance();
    System.out.println("classloader of instance: " + o.getClass().getClassLoader());
}

file

通过 myClassLoader加载了一个工程下的自定义类 MyClass,并通过反射实例化了对象,调用对象的类加载器,发现是系统类加载器 AppClassLoader的实例,同时我们在 MyClassLoaderfindClass方法中打印语句也没执行,说明 MyClass并不是由我们自定义的类加载器加载的。 原因就是类加载器的双亲委派模型,观察我们实例化时使用的构造函数 MyClassLoader(String classLoaderName),内部调用父类的无参构造函数 super()file

也就是说,当我们实例化一个自定义类加载器的时候没有显示指定它的父类加载器的话,默认会使用系统类加载器作为其父类加载器,又因为双亲委派模型,自定义类加载器会先使用其父类加载器(即AppClassLoader)去加载,而我们自定义类 MyClass是在 classpath下的,系统类加载器能加载到,所以,最终MyClass的类加载器就是系统类加载器。 如何让自定义加载器生效呢?这里有个 trick 的办法,既然是委托给父类加载器加载,那我让父类加载器加载不到不就可以了嘛!项目编译后,通过将要加载的Class字节码文件从 classpath下移动到别的地方,让 AppClassLoader加载不到,再由自定义加载器去对应的位置(通过设置 MyClassLoader的成员变量path)加载:

# 将 target/classes/ 下的 MyClass.class 及文件层次结构移动到桌面
mv target/classes/com/example/testclassloader/MyClass.class /Users/spc/Desktop/com/example/testclassloader/

MyClass.class文件移动到桌面,修改测试方法testMyClassLoader()如下,执行测试方法:

public static void testMyClassLoader() throws Exception {
    MyClassLoader myClassLoader = new MyClassLoader("myClassLoader");
    //myClassLoader.setPath("/Users/spc/Documents/project/my_project/test-classloader-demo/target/classes/");
    // 设置你自己的classpath,即 MyClass.class 移动的那个位置,我这边是桌面
    myClassLoader.setPath("/Users/spc/Desktop/");
    Class<?> clazz = myClassLoader.loadClass("com.example.testclassloader.MyClass");
    Object o = clazz.newInstance();
    System.out.println("classloader of instance: " + o.getClass().getClassLoader());
}

file

此时,执行结果显示是由 MyClassLoader进行的类加载,同时 findClass() 方法中打印的语句也执行了,这样就达到了我们的目的。

类的卸载

《Java虚拟机规范》中提到可以不要求虚拟机在方法区中实现垃圾收集,方法区垃圾收集的“性价比”通常也是比较低的。判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,Java堆中不存在该类及其任何派生子类的实例;
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计,否则通常是很难达成的;
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

当满足了类卸载的条件后,通过Full GC便可执行类卸载,回收方法区空间,下面的例子中,我们通过将 ClassLoaderClass的引用置为null,并调用System.gc()进行Full GC来看下类卸载,别忘了添加JVM参数-XX:+TraceClassUnloading打印类卸载日志:

public static void testClassUnloading() throws Exception {
    // 测试时确保classpath下没有 MyClass.class 文件
    // jdk1.8 加上jvm参数 -XX:+TraceClassUnloading 打印类卸载日志

    MyClassLoader myClassLoader1 = new MyClassLoader("myClassLoader1");
    myClassLoader1.setPath("/Users/spc/Desktop/");
    Class<?> clazz1 = myClassLoader1.loadClass("com.example.testclassloader.MyClass");
    System.out.println("hashcode of clazz1: " + clazz1.hashCode());

    // 从 GC Root 中移除引用
    myClassLoader1 = null;
    clazz1 = null;
    // full gc
    System.gc();

    // 也可以通过 jVisualVM 查看类卸载过程
}

file

执行结果看到,确实进行了类的卸载。

类加载器的命名空间

上文提到“每一个类加载器,都拥有一个独立的类名称空间”,同时父子类加载器命名空间之间存在一定的可见性关系。 可见性关系:

  • 同一个命名空间内的类是相互可见的;
  • 子加载器的命名空间包含所有父加载器的命名空间,因此由子加载器加载的类能看见父加载器加我的类(例如系统类加载器加载的类能看见启动类加载器加载的类);
  • 由父加载器加载的类不能看见子加载器加载的类;
  • 如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载的类相互不可见。
不同类加载器的命名空间

运行testNameSpace()测试下不同类加载器的命名空间: file

通过将MyClass.class文件移出classpath的办法,让两个自定义类加载器不委托父加载器加载,发现不同类加载器加载的Class对象的hascode是不同的,即是不同的Class对象。

父子类加载器命名空间可见性

再通过具体案例看下父子类加载器命名空间的可见性关系,上文提到“子加载器的命名空间包含所有父加载器的命名空间,由父加载器加载的类不能看见子加载器加载的类”,即子空间能看到父空间,父空间看不到子空间,接下来我们就创造这两个场景,我们需要让子加载器加载的类去引用父加载器加载的类以及父加载器加载的类去引用子加载器加载的类,在项目下新增两个类DogDogHouse

public class Dog {
    public Dog() {
        System.out.println("Dog is loaded by: " + this.getClass().getClassLoader());

        //System.out.println("from Dog: " + DogHouse.class);
    }
}
public class DogHouse {
    public DogHouse() {
        System.out.println("DogHouse is loaded by: " + this.getClass().getClassLoader());
        new Dog();
    }
}

项目编译后,将DogHouse.class移动到桌面,并执行测试方法testNameSpaceV2()

// 4、父子类加载器的命名空间的可见性
public static void testNameSpaceV2() throws Exception {
    // 子加载器加载的类能看到父加载器加载的类
    // 而父加载器加载的类看不到子加载器加载的类

    MyClassLoader myClassLoader = new MyClassLoader("myClassLoader");
    myClassLoader.setPath("/Users/spc/Desktop/");
    Class<?> clazz = myClassLoader.loadClass("com.example.testclassloader.DogHouse");
    Object o = clazz.newInstance();
}

file

可以看到,DogHouse是由MyCalssLoader(子类加载器)加载的,Dog是由APPClassLoader(父类加载器)加载的,所以在DogHouse使用new Dog()是能找到Dog类的,即子空间能看到父空间。 接下来验证另一个场景,将Dog中包含DogHouse.class的注释打开,重新编译,并将DogHouse.class移动到桌面,重新运行testNameSpaceV2()file

此时,Dog是找不到DogHouse的,即父空间看不到子空间。

不同类加载器的命名空间可见性

最后,再验证下如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载的类相互不可见,项目下添加一个新类Cat

public class Cat {
    private Cat cat;

    public void setCat(Object cat) {
        this.cat = (Cat) cat;
    }
}

编译后,将Cat.class文件移动到桌面,执行以下测试方法testNameSpaceV3()

// 5、非父子命名空间的之间的可见性
public static void testNameSpaceV3() throws Exception {
    // 未删除classpath下的Cat.class,没有问题
    // 将classpath下的Cat.class移动到desktop后,报错
    // 异常原因很有意思
    // Caused by: java.lang.ClassCastException: com.example.testclassloader.Cat cannot be cast to com.example.testclassloader.Cat

    MyClassLoader myClassLoader1 = new MyClassLoader("myClassLoader1");
    MyClassLoader myClassLoader2 = new MyClassLoader("myClassLoader2");
    myClassLoader1.setPath("/Users/spc/Desktop/");
    myClassLoader2.setPath("/Users/spc/Desktop/");
    Class<?> clazz1 = myClassLoader1.loadClass("com.example.testclassloader.Cat");
    Class<?> clazz2 = myClassLoader2.loadClass("com.example.testclassloader.Cat");

    System.out.println(clazz1 == clazz2);

    Object o1 = clazz1.newInstance();
    Object o2 = clazz2.newInstance();

    // 反射调用 setCat 方法
    Method method = clazz1.getMethod("setCat", Object.class);
    method.invoke(o1, o2);
}

file

这边的报错信息很有意思,java.lang.ClassCastException: Cat cannot be cast to Cat,虽然类的全限定类名一样,但是由于是不同的类加载器加载的,两者是互相不可见。

线程上下文类加载器

当前类加载器(current classloader)

每个类都会使用自己的类加载器(即加载自身的类加载器)来去加载其他类(指的是所依赖的类),如果classX引用了classY,那么classX的类加载器就会去加载classY(前提是classY尚余被加载)

线程上下文类加载器(context classloader)

JDK 1.2引入线程上下文类加载器,Thread提供getContextClassLoader()setContextClassLoader()方法,如果没有通过setContextClassLoader进行设置的话,线程将继承其父线程的上下文类加载器,Java应用运行时的初始线程的上下文类加载器是系统类加载器,在线程中运行的代码可以通过该类加载器来加载类与资源。使用当前线程Thread.currentThread().getContextClassLoader()所指定的classloader来加载的类。 使用线程上下文类加载器比较典型的例子就是SPI(Service Provider Interface)机制,是Java提供的可用于第三方实现和扩展的机制,JDK定义接口规范,各个厂商根据接口规范实现。Java核心库是由启动类加载器来加载的,而这些接口的实现却来自于不同的jar包,放在classpath下,对启动类加载器是不可见的,因为启动类加载器加载不了classpath下的字节码文件,这样传统的双亲委托模型就无法满足SPI工的要求。需要Java核心类通过线程上下文类加载器(通常是AppClassLoader)去加载不同的厂商的实现类。通过在jar包下的META-INF/services/xxx(你要实现的接口)文件中声明该接口的实现类,就可以通过**ServiceLoader.load(xxx.class)**扫描到然后加载该类。 SPI最典型的就是JDBC了,下面以JDBC为案例,介绍下JDBC是如何通过线程上下文类加载器实现SPI机制的。 mySQL驱动类com.mysql.jdbc.Driver是对接口java.sql.Driver的实现,使用JDBC获取mySQL连接的代码如下:

// SPI 之 JDBC
public static void testJDBCV2() throws Exception {
    // 会使 com.mysql.jdbc.Driver 初始化,执行 static 代码块
    // DriverManager 初始化,执行 static 代码块

    Class.forName("com.mysql.jdbc.Driver");
    System.out.println("classloader of com.mysql.jdbc.Driver: " + com.mysql.jdbc.Driver.class.getClassLoader());
    Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/mytestdb", "username", "password");
}

**首先,**使用Class.forName("com.mysql.jdbc.Driver")加载mysql驱动。 Class.forName()会触发类的加载和初始化,初始化会执行static代码块,查看com.mysql.jdbc.Driver的static代码块,com.mysql.jdbc.Driver在初始化的时候向DriverManger注册自己: file

查看DriverManager的static代码块: file

file

file

file

查看mySQL驱动jar包下对应目录META-INF/services/下的文件,这边的两个全限定类名就是mysql提供的对java.sql.Driver的实现: file

**第二步,**通过DriverManager.getConnection()获取链接,查看getConnection()实现: file

检查一下类加载器对类对象的可见性: file

其实从Java1.6开始自带的jdbc4.0版本已支持SPI服务加载机制,不再需要Class.forName()将自己注册到DriverManager上,而是使用ServiceLoader根据META-INF/services/java.sql.Driver文件自动加载实现类。 那默认的线程上下文类加载器是什么呢? 贴一张java程序启动的图: file

创建完java虚拟机, java虚拟机里面还有很多启动程序,其中有一个程序叫做sun.misc.Launcher,通过启动这个java类,会由这个类引导加载器加载并创建很多其他的类加载器。而这些加载器才真正启动并加载磁盘上的字节码文件。也就是说,是C++调用Launcher对象执行的Javamain方法: file

总结

文中示例代码: github.com/pengchengSU…