JVM 第三篇:Java 类加载机制

共 7758字,需浏览 16分钟

 ·

2020-10-09 15:10

本文内容过于硬核,建议有 Java 相关经验人士阅读。

1. 什么是类的加载?

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

类加载器并不需要等到某个类被 「首次主动使用」 时再加载它, JVM 规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了 .class 文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误( LinkageError 错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

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

2. 类的生命周期

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

2.1 加载(Loading)

加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取其定义的二进制字节流。

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

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

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

加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在 Java 堆中也创建一个 java.lang.Class 类的对象,这样便可以通过该对象访问方法区中的这些数据。

2.2 验证(Verification)

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合「Java虚拟机规范」的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

  1. 文件格式验证: 验证字节流是否符合 Class 文件格式的规范;例如:是否以 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  2. 元数据验证:对字节码描述的信息进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object 之外。
  3. 字节码验证: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证: 确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

2.3 准备(Preparation)

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  1. 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 这里所设置的初始值通常情况下是数据类型默认的零值(如 0 、 0L 、 null 、 false 等),而不是被在 Java 代码中被显式地赋予的值。

2.4 初始化(Initialization)

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段, Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。

在 Java 中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值。
  2. 使用静态代码块为类变量指定初始值。

3. 类加载器

类加载器就是负责加载所有的类,将其载入内存中,生成一个 java.lang.Class 实例。一旦一个类被加载到 JVM 中之后,就不会再次载入了。

  • 启动类加载器(Bootstrap ClassLoader):其负责加载 Java 的核心类,比如 String 、 System 这些类。
  • 拓展类加载器(Extension ClassLoader):其负责加载 JRE 的拓展类库。
  • 系统类加载器(System ClassLoader):其负责加载 CLASSPATH 环境变量所指定的 JAR 包和类路径。
  • 用户类加载器:用户自定义的加载器,以类加载器为父类。

一个简单的小栗子:

public static void main(String[] args) {
    ClassLoader loader = ClassLoader.getSystemClassLoader();
    System.out.println(loader);
    System.out.println(loader.getParent());
    System.out.println(loader.getParent().getParent());
}

输出结果:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null

「为什么根类加载器为 NULL ?」

启动类加载器(Bootstrap Loader)并不是 Java 实现的,而是使用 C 语言实现的,找不到一个确定的返回父 Loader 的方式,于是就返回 null 。

「JVM 类加载机制」

  1. 全盘负责:当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  2. 父类委托:先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。
  3. 缓存机制,缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class ,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区。这就是为什么修改了 Class 后,必须重启 JVM ,程序的修改才会生效。

4. 双亲委派模型

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派机制:

  1. 当 AppClassLoader 加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器 ExtClassLoader 去完成。
  2. 当 ExtClassLoader 加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给 BootStrapClassLoader 去完成。
  3. 如果 BootStrapClassLoader 加载失败(例如在 $JAVA_HOME/jre/lib 里未查找到该 class ),会使用 ExtClassLoader 来尝试加载。
  4. 若 ExtClassLoader 也加载失败,则会使用 AppClassLoader 来加载,如果 AppClassLoader 也加载失败,则会报出异常 ClassNotFoundException 。

以下为 ClassLoader#loadClass 的源码, JDK 版本为 1.8.0_221 。

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 {
                    // 如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法 native Class findBootstrapClass(String name)
                    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.
                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;
    }
}

双亲委派模型是为了防止内存中出现多份同样的字节码,保证程序稳定的运行。

5. 自定义类加载器

在最开始,我想先介绍下自定义类加载器的适用场景:

  1. 加密:Java 代码可以轻易的被反编译,如果需要把代码进行加密以防止反编译,可以先将编译后的代码用某种加密算法加密,这样加密后的类就不能再用 Java 的 ClassLoader 去加载类了,这时就需要自定义 ClassLoader 在加载类的时候先解密类,然后再加载。
  2. 从非标准的来源加载代码:如果我们的字节码是放在数据库、甚至是在云端,就可以自定义类加载器,从指定的来源加载类。

一个小案例,首先我们创建一个需要加载的目标类:

public class ClassLoaderTest {
    public void hello() {
        System.out.println("我是由 " + getClass().getClassLoader().getClass() + " 加载的");
    }
}

这个类先进行编译,编译后的 class 我放到了 D 盘的根目录,然后删除原本在项目中的 class 文件,如果不删除的话,通过前面的双亲委派模型,我们会知道这个 class 会被 sun.misc.Launcher$AppClassLoader 进行加载。

然后我们定义一个自己的加载类:

public class MyClassLoader extends ClassLoader {
    public MyClassLoader(){}

    public MyClassLoader(ClassLoader parent){
        super(parent);
    }

    protected Class findClass(String name) throws ClassNotFoundException {
        File file = new File("D:\\ClassLoaderTest.class");
        try{
            byte[] bytes = getClassBytes(file);
            //defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class
            Class c = this.defineClass(name, bytes, 0, bytes.length);
            return c;
        }
        catch (Exception e) {
            e.printStackTrace();
        }

        return super.findClass(name);
    }

    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();
    }

    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
        MyClassLoader classLoader = new MyClassLoader();
        Class clazz = classLoader.loadClass("com.geekdigging.lesson03.classloader.ClassLoaderTest");
        Object obj = clazz.newInstance();
        Method helloMethod = clazz.getDeclaredMethod("hello"null);
        helloMethod.invoke(obj, null);
    }
}

最后打印结果:

我是由 class com.geekdigging.lesson03.classloader.MyClassLoader 加载的

参考

https://www.cnblogs.com/ityouknow/p/5603287.html

https://www.cnblogs.com/twoheads/p/10143038.html





感谢阅读



浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报