类加载机制

前言

    我们所编写的.java文件会经过javap指令编译成.class字节码文件,.class字节码文件中的内容描述类的相关信息。我们都知道.class文件是一种文件,但是我们的Java程序是运行在内存中的,如果我们需要使用到.class字节码中的内容描述的类信息,这时候就要讲到JVM的类加载机制了,类加载机制就是把class文件加载到内存,并对数据进行验证,解析和初始化,最终形成可以被虚拟机直接使用的java类型。这个过程有点类似IO操作读取文件流到程序中,.class文件也是一种文件,这不过它的读取并不是InputStream去读取然后放到内存中的,而是由类加载机制去读取到内存中的


1.类加载过程

    类从被加载到JVM内存中开始,到卸载出内存为止,它的整个生命周期可以分为:加载、验证、准备、解析、初始化、使用、卸载。而验证、准备、解析三个阶段又可以统称为连接,流程如图所示:

    其中加载、验证、准备、初始化这四个阶段执行的顺序是确定,唯一不确定的是解析阶段解析阶段是可能会出现在初始化阶段之后发生的,这也是为了支持Java语言的运行时绑定(也被称为动态绑定或晚期绑定)。可以网友不太理解,我们来看个例子:

        1. 假设B extend A, A a = new B();这时编译器是认可的,编译通过的,但此时编译器只知道B是A的子类,并不清楚B的具体类型

        2. 而只有在程序运行时,Java的后期绑定机制判定该对象new B()的运行时类型为B,所以方法的调用策略是从B类中调用相应的方法

        3. 动态绑定(后期绑定或运行时绑定)也常常被称为多态

    而类加载机制并不包括上面的7个过程,只包括其中的:加载 -> 验证 -> 准备 -> 解析 -> 初始化五个阶段,下面主要分析下这五个阶段虚拟机具体干了什么事,类加载的过程描述如图所示:


    1.1 加载

        加载是整个过程的第一个阶段,虚拟机主要完成一下事情:

            1. 通过一个类的全限定名来获取定义此类的二进制字节流,这里并没有必须要求只能从class文件中获取,也可以从网络,动态生成,数据库等渠道获取类的二进制字节流

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

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

        这里提一下,加载阶段连接阶段的部分内容是交叉进行的,就比如在加载class文件二进制字节流过程中,连接阶段中的验证阶段就会去效验二进制字节流是否符合文件格式要求


    1.2 验证

        验证阶段主要是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,不会危害虚拟机的安全,验证又包括:文件格式验证、元数据验证、字节码验证、符号引用验证

            1. 文件格式验证:验证字节流是否符合Class文件格式的规范,我们首先看一张Class文件的16进制图:

            我们可以看到文件是以0xCAFEBABE开头的,这个被称为魔数,也就是表示此二进制字节流是可以被加载的class文件、除了效验魔数之外,还会效验主、次版本是否在当前虚拟机处理范围之内、常量池中的常量是否有不被支持的类型

            2. 元数据验证:是对字节码描述的信息进行语义分析,描述的信息是否符合Java语言的规范,例如是否继承了final类,是否覆盖了父类中的final方法等信息

            3. 字节码验证:确定程序语义是合法的、符合逻辑的、比如会效验方法体中的类型转换是有效的,子类对象可以赋值给父类对象,但是父类对象赋值给子类对象这就是不合法的

            4. 符合引用验证:是否可以通过字符串描述的全限定名能找到对应的类,确保类中存在符合方法的字段描述符


    1.3 准备

        准备阶段是为类的静态变量分配内存空间,并对其设置默认值,这里只会对类的静态static变量设置默认值,实例变量是在对象实例化时随着对象一起分配在Java堆内存中的,并调用构造方法初始化的。这里还有一种情况就是用final修饰static变量,默认值就是刚开始我们程序中所设置的值,比如:public static int value = 123;这时在准备阶段value的值就是123了


    1.4 解析

        把类中对常量池内的符号引用转换为直接引用,比如说方法的符号引用,是有方法名和相关描述符组成,在解析阶段,虚拟机把符号引用替换成一个指针,这个指针就是直接引用,它指向该类的该方法在方法区中的内存位置


    1.5 初始化

        对类的静态变量赋于正确的初始值,在准备阶段是给类的静态变量赋于默认值,而在初始化阶段会对静态变量赋于初始值,相当于初始化阶段是执行类构造器<clinit>()方法的过程,<clinit>()方法时由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,跟我们平时所讲的构造器有点不同,我们平时所说的构造器指的是实例构造器<init>()方法不同,这里是指的类的构造器,这个类构造器只会被执行一次。

    初始化的时机

        对于初始化阶段,虚拟机规定了有且只有5种情况必须立即对类进行"初始化":

        1. 创建类的实例(碰到new关键字)

        2. 使用java.lang.reflect包的方法对类进行反射调用的时候(比如:Class.forName("xxx"))

        3. 对类的静态变量进行访问或赋值

        4. 访问调用类的静态方法

        5. 初始化一个类的子类,会优先对其父类进行初始化

        6. 作为程序的启动入口,比如执行main()方法


2.类加载器

    前面我们所讲的是类加载的几个过程,而在加载阶段是将class文件所描述的字节码信息加载到JVM内存中,形成具体的java.lang.Class对象,而这个过程正是由类加载器实现的

    从JVM角度上来说,只存在两种类型的类加载器:一种是启动类加载器(Bootstrap ClassLoader),而这个类加载器是由C++语言实现的,而另一类统称为其他类加载器,这类加载器则是由Java语言自己实现的,这些类加载器都继承自抽象类java.lang.ClassLoader。但是从我们开发人员的角度来说,又可以将其他类加载器分为:扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)、用户自定义类加载器。大致类加载器的组织结构图:

    2.1 启动类加载器(Bootstrap ClassLoader)

        是在Java虚拟机启动后进行初始化的,主要负责加载%JAVA_HOME%/jre/lib/rt.jar,也可以通过JVM参数-Xbootclasspath指定需要加载的路径以及类库

    2.2 扩展类加载(Extension ClassLoader)

        主要负责加载%JAVA_HOME%/jre/lib/ext路径下的jar包,也可以通过系统变量java.ext.dirs所指定的路径中的所有类库

    2.3 应用程序类加载器(Application ClassLoader)

        主要负责加载用户类路径(ClassPath)上所指定的类库,也是Java程序默认的类加载器

    2.4 用户自定义类加载器(User ClassLoader)

        我们可以继承自ClassLoader,然后重写它的findClass方法。从特定位置加载class文件,得到字节数组,然后利用defineClass把字节数组转化为Class对象

    类加载的隔离性问题

    每个类加载器都有自己的命名空间来保存自己已加载的类,当一个类装载器装载一个类时,它会通过保存在命名空间里的类全局限定名进行搜索来检测这个类是否已经被加载了。保存的命名就比如:ClassLoader id + PackageName + ClassName。为了类加载的隔离性问题,JVM引入了双亲委派机制


3.双亲委派机制

    思想:当类加载器收到了类加载的请求,首先自己不会去加载这个类,而是把这个请求转发给自己的父类加载器去完成,当自己的父类不能加载时,自己才会尝试着去加载这个类。总的概括来说就是:自底向上检查类是否已经被加载过,自顶向下加载类

    加载过程:

        1. 当App ClassLoader加载一个class时,判断是否已经被加载过,没有被加载过,则交给自己的parent父类加载器Ext ClassLoader去加载

        2. Ext ClassLoader收到加载请求,也会判断是否已经被加载过,没有则交给自己的父类parent加载器Bootstrap ClassLoader去加载

        3. Boostrap ClassLoader如果能加载,就加载,如果不能加载该class(该class不在lib目录下),则加载失败,这时候就会使用Ext ClassLoader去加载

        4. 如果Ext ClassLoader也不能加载,则会由App ClassLoader去加载,如果App ClassLoader也不能加载,并且我们没有重写findClass()方法,就会抛出ClassNotFoundException异常

    ClassLoader.loadClass()源码分析

    我们可以从代码中看出,在加载的过程还会给加载的class加上一把锁,防止同一个class并发的去加载,代码中也跟我们所讲的一样,首先会检查该类是否已经被加载过,如果没有被加载过,则调用自己的parent.loadClass去加载,是一个递归的调用过程,我们可以看到最后都不能加载的时候会调用findClass()方法,而findClass()方法里面是抛出ClassNotFoundException()异常的,我们自定义类加载器继承自ClassLoader类,重写它的findClass方法,这时候我们自定义我们的加载逻辑,不让它抛出ClassNotFoundException异常

    使用双亲委派机制的好处就在于:避免一个class被多个类加载器去加载,影响加载的效率,也为了避免重复加载导致JVM内存存在着相同的class对象。这里提一下像Bootstrap ClassLoader所加载的rt.jar核心包中的类,我们是不可以编写跟rt.jar里面的包名和类名都一样的类,不然会抛出这是不安全的异常


    破坏双亲委派机制

        双亲委派模型也并不是一个强制性的约束条件,这种模型也可以通过一些条件去破坏。

        1. 我们继承自ClassLoader,重写loadClass()方法,里面的逻辑可以自己定义不采用双亲委派的方法去加载。但是官方已经不提倡这么去做了,不提倡重写loadClass()方法,而比较建议的是重写findClass()方法

        2. 双亲委派机制被破坏还有一个原因就是 Java自己本身存在一个缺陷,比如在加载数据库驱动的时候,Java只提供了java.lang.Driver接口,但是他的实现类是由数据库厂商去实现的,但是Bootstrap ClassLoader又无法加载这些实现类的代码,这时候父类加载器就会通过Thread.currentThread().getClassLoader()获取子类加载器去完成这些实现类的动作


4.类的初始化顺序

    类的加载可以通过Class.forName()和ClassLoader.loadClass()方法实现动态加载,但它们两个加载的时候又有点区别。

    Class.forName(): 把类的.class文件加载JVM中,并会调用类的static静态代码快,比较常见的就是Class.forName("com.mysql.cj.Driver")加载mysql驱动时,经常会看到这个语句

    ClassLoader.loadClass(): 只是把.class文件加载到JVM中,并不会执行类中的static静态代码块

    对象的初始化顺序:静态变量/静态代码块 -> 普通代码块 -> 构造方法

        1. 父类静态变量静态代码块(先声明的先执行)

        2. 子类静态变量静态代码块(先声明的先执行)

        3. 父类普通成员变量普通代码块(先声明的先执行)

        4. 父类的构造函数

        5. 子类普通成员变量普通代码块(先声明的先执行)

        6. 子类的构造函数

  

总结

    大概讲了一下类加载的整体阶段,阶段中虚拟机做了哪些事,后面单独讲了加载阶段的类加载器,介绍了各种类加载器,然后讲了JVM所采用的加载模型,最后写的是比较基础的类的加载顺序。讲的还是比较基础的

posted @ 2020-06-14 16:38  半分、  阅读(355)  评论(0编辑  收藏  举报