JVM虚拟机之类加载机制

概述

我们的java类都会在编译器编译之后变成一个class文件,我们需要通过虚拟机加载这个class文件才能够使用我们编写的类。本章主要讲述的内容分为以下几点:

  • 虚拟机什么时候会加载这些Class文件
  • 虚拟机如何加载这些Class文件
  • Class文件中的内容进入到虚拟机后会发生什么变化

虚拟机将描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化。最终形成可以被虚拟机直接使用的Java类型。这就是虚拟机的类加载机制。

本文参考 《深入理解Java虚拟机》 一书中关于类加载机制的内容进行说明和学习。


类加载的时机

一个类被加载到虚拟机内存到被使用完毕卸载为止,它的整个声明周期如下:
在这里插入图片描述
注意:上面图中的顺序基本上是固定的,除了解析阶段,因为解析阶段会在某些情况下在初始化阶段之后才会开始,这也是我们java程序动态绑定方法的由来。

什么时候开始类加载?

虚拟机规定了遇到以下几种情况的时候必须对类进行初始化,为什么直接说初始化,这是因为虚拟机规范中并没有对加载阶段的行为进行限制。反而对于初始化阶段有一个强制的要求。

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类没有进行初始化当先触发其初始化。这四条指令分别对应java中的实例化对象、读取或设置一个类的静态字段(不计入final,因为fianl变量会在被调用时被编译器优化,直接存入调用者的常量池中),以及调用静态方法时。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果没有初始化则需要先进行初始化
  3. 当初始化一个类时,如果其父类未初始化会先出发父类的初始化。
  4. 虚拟机启动时会初始化main方法所在的启动类
  5. 当使用JDK1.6之后出现的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getstatic,REF_putstatic或者REF_invokestatic的方法句柄,并且目标类未进行初始化,则也会进行初始化。关于这一点我们可以先不进行理解。因为这涉及到java编程中我们不太经常使用到的动态语言支持。

注意 以上五种方式在java语言规范中被规定为有且只有,所有其他方式的引用都不会触发目标类型的初始化。下面以几种我们经常混淆的被动引用的方式进行举例说明

通过子类对父类静态字段进行引用不会造成子类的初始化
public class Demo1 {
    
    
    public static void main(String[] args){
    
    
        System.out.println(SubCalss.value);

        /**
         * SuperClass init!
         * 123
         */
    }


}
class SuperCalss{
    
    
    static {
    
    
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

class SubCalss extends SuperCalss{
    
    
    static {
    
    
        System.out.println("SubClass init!");
    }
}

上面例子中我们可以看到我们虽然是通过子类对父类的静态变量进行引用,但在实际执行中,只有父类的静态代码块被执行,也就是说子类没有被初始化。但是会导致子类的加载行为。

通过数组定义来引用类不会触发此类的初始化
public class Demo2 {
    
    
    public static void main(String[] args) {
    
    
        SuperCalss[] sca = new SuperCalss[10];
    }
}

上面代码中我们复用了第一个例子中的类,但是执行完后并没有触发该类的初始化。但事实上在代码中,触发了另一个名为“[Ljvmdemos.SuperClass”的类的初始化。看过我前面一章对Class文件结构的内容的人可以知道,这表示一个SuperClass数组的意思。它是由虚拟机自动生成的,直接继承于Obejct的子类,由newarray指令触发。数组封装了一些方法来保证了该数组类的可用性以及安全性。这就是为什么java数组不会像C++一样发生数组越界的原因。

对于final常量的引用不会触发引用类的初始化
public class Demo3 {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(ConstClass.HELLO);
        /**
         * hello
         */
    }
}
class ConstClass{
    
    
    static {
    
    
        System.out.println("ConstClass init");
    }

    public static final String HELLO = "hello";
}

运行上面代码后可以发现我们并没有触发ConstClass的初始化,这是因为在编译器,常量会变被存储到调用类的常量池中,本质上并没有直接引用到定义常量的类,因此并不会触发常量所在类的初始化行为。


类加载的过程

关于在类加载过程中虚拟机到底做了什么我们将在这个部分进行说明。

加载

在加载阶段,虚拟机需要完成下面三件事情:

  1. 通过类的全限定类名来获取此类的class二进制字节流
  2. 将这个class字节流中的静态数据结构转换成方法区的运行时数据结构
  3. 在方法区中生产一个代表这个类的Class对象,作为方法区这个类的各种运行时数据的访问入口

注意:类加载阶段与后面的验证阶段是交叉进行的,也就说,我可能加载尚未完成,验证阶段就已经开始了

验证

该阶段的目的是为确保Class文件的字节流中包含的信息符合虚拟机妖气,并且不会危害虚拟机自身的安全。

从整体上看,验证阶段大致分为下面四个阶段的检验动作:

  • 文件格式验证:该阶段确保文件的格式是否正确,比如是不是class文件,该文件的编译版本是否与当前虚拟机兼容等
  • 元数据验证:该阶段是对验证文件信息是否符合java的语言规范,比如某个final类是否被继承、非抽象类是否对所有未实现的方法都进行的实现等
  • 字节码验证:该阶段主要是对字节码逻辑的验证,确保目标程序是符合逻辑的,比如类型转换的有效性,不能够把一个毫不相干的两个类进行转换,不能让代码随意跳转等
  • 符号引用验证:该阶段发生在解析阶段,确保解析的符号引用的有效性,比如通过某全限定类名可以找到对应的类以及符号引用中的对象的访问性是否可被当前类访问。

该阶段是确保虚拟机安全性的一部,但并不是必须的,可以通过-Xverify:none参数来关闭类验证,但你必须保证所允许的代码是被反复使用和验证过的,否则会造成巨大的灾难。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中进行分配。类变量只包含static修饰的变量,并不包括实例变量。

假设存在一个

public static int value  = 123

那么在准备阶段所做的事情就是为value变量在方法去分配对应内存,并对其内存清零,我们可以理解为将value设为0。


解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够毫无歧义地定位到目标即可。比如我们的类全限定名字面量就是我们类文件中对与一个类的符号引用。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或者一个能够简介定位到目标的句柄。比如我们原来的符号引用是指向一个对应的类全限定名来代表指向某个类,但直接引用需要将目标类全限定名指向的类文件加载到内存中然后直接指向其内存地址。

解析动作主要针对于类、接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符七类符号引用进行。下面将对前四种引用的解析过程进行讲解。

类或接口的解析

  1. 通过当前类的类加载器去加载目标全限定类名对应的类。在加载过程中,由于数据验证的需要,又可能触发其他类的加载动作,如果中间某个类加载异常,那么解析过程失败
  2. 如果是一个数组类,先加载元素类型,再由JVM生成代表此数组维度与元素的数组类
  3. 通过符号引用确认当前类对目标类的访问权限,不具备则异常,抛出IllealAccessError

字段解析

字段在class文件的常量池中是以以下数据结构存在的
在这里插入图片描述
其中第一个tag表示这是一个字段类型的常量,而第二个index属性指向声明字段的类或者接口描述符。因此在对字段进行解析时我们需要根据这个索引对声明这个字段的类进行解析。

  1. 如果目标类本身中存在与这个字段的简单名称和字段描述符都相匹配的字段则返回这个字段的直接引用
  2. 如果没有对其实现的接口以及父类进行递归向上检索,如果有则返回目标字段的直接引用
  3. 如果没有将抛出异常NosuchFieldError
  4. 如果成功返回引用将对这个字段进行权限验证,如果没有权限将抛出异常IllegalAccessError

但是如果目标类的父类与父接口同时都具有相同的字段将无法通过编译期

类方法解析

  1. 与字段解析相同,类方法也需要根据同样的index索引解析出方法所属的类或接口的符号引用。
  2. 在解析出的类中寻找匹配的方法。如果有则返回直接引用
  3. 如果没有将根据父类继承向上查询。如果有则返回直接引用
  4. 否则在该类所实现的接口中递归查询匹配的方法,如果存在,说明该类是一个抽象类。将抛出异常AbstractMethodError
  5. 否则,宣布查找失败,抛出NosuchMethodError
    如果查找成功还需要进行权限验证,验证失败抛出异常IllegalAccessError

接口方法解析

  1. 与字段解析相同,类方法也需要根据同样的index索引解析出方法所属的接口的符号引用。
  2. 在解析出的类中寻找匹配的方法。如果有则返回直接引用
  3. 如果没有将根据父接口继承向上查询,知道Object类。如果有则返回直接引用
  4. 否则,宣布查找失败,抛出NosuchMethodError
    由于接口中的方法都是public,所以不存在访问权限问题

初始化

类初始化是类加载过程中的最后一步,这一部分将调用编译器生成的 <clinit>() 方法对类变量进行初始化并执行静态代码块。

下面是关于初始化部分我们需要注意的知识点:

  • <clinit>() 方法由编译器产生,包含所有类变量的赋值动作以及静态代码块
  • 静态语句块只能对声明在前的静态变量进行访问,对于声明在后的静态变量只能赋值不能访问
  • <clinit>() 方法与构造函数不同,它不需要显式调用父类构造器,JVM会保证父类构造器在子类构造器执行前完成执行
  • 如果不存在类变量赋值与静态代码块,则不生成 <clinit>() 方法
  • 接口虽然没有静态代码块但是存在静态变量赋值,所以也存在 <clinit>() 方法
  • 初始化阶段是线程安全的,如果存在多个现同时去初始化一个类,那么只会有一个线程去执行 <clinit>() 方法,其他线程都将阻塞等待

类加载器与双亲委派模型

类加载器

虚拟机设计团队把类加载阶段中的“通过一个全限定类名来获取此类二进制字节流”的行为放到虚拟机的外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为类加载器
加载器虽然只用于实现类的加载动作,但它在java中起到的作用却远远不止与类加载阶段。对于任意一个类都需要由加载它的类加载器和这个类本身一同确立其在虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间。换句话来说,比较两个类是否相等,只有在这两个类是被同一个类加载器加载的前提下才有意义,否则,即使是同一个类,但被不同的类加载器加载,那么它们也必然不相等。

双亲委派模型

从虚拟机角度来看,只存在两种不同的类加载器:一种是启动类加载器,该加载器使用C++实现,属于虚拟机的一部分;另一种就是所有其他的类加载器,这些类加载器都由java语言实现,独立于虚拟机外部,并且全部继承自java.lang.ClassLoader。

从开发者角度来看,虚拟机则被分为另外两种,一种是虚拟机自带的类加载器,另一种是自定义的类加载器。我们通常不会自定义类加载器,而是使用java自带的类加载器,但是在框架、服务器中,由于一些特殊的应用方式,会在java自定义类加载器的基础上再增加一些自定义类加载器。

绝大多数Java程序都会使用到以下三种系统提供的类加载器。

  • 启动类加载器
    该类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的并且是被虚拟机识别的类库加载到虚拟机内存中(它只加载java、javax、sun开头的类,名字不符合的类库即使放在目标路径下也不会被加载)

我们都知道Obejct就是java包下的一个类,所以我们可以尝试看看Obejct的类加载器是什么

System.out.println(Object.class.getClassLoader());

//Output:
//null

执行上面的语句会发现返回结果为null,为什么是null,其实是因为我们的根类加载器的编写语言是C++,VM不能够也不允许程序员获取该类,所以返回值用null替代

  • 扩展类加载器
    该类加载器负责加载%JAVA_HOME%\jre\lib\ext目录下的类库,或者java.ext.dirs系统变量指定的目录。开发者可以直接使用该类加载器。
  • 应用程序类加载器
    该类加载器也被称为系统类加载器,可以通过ClassLoader.getSystemClassLoader()方法获取。该类加载器负责从classpath下或java.class.path指定目录下加载类,如果程序中没有自定义的类加载器,一般情况下应用程序类加载器就是指定的默认类加载器。

我们的系统程序通常都是由上面三种类加载器互相配合进行加载的,如果有必要还可以加入自己定义的类加载器。
在这里插入图片描述
上图展示的就是类加载器之间的层次关系,这种关系我们称为类加载器的双亲委派模型。该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的父子关系并不是继承关系中的父子关系,而是使用组合关系来复用父加载器代码。

双亲委派模型的工作流程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器去完成,每一个类加载器都是如此,因此所有请求最终都应该传送到顶层的启动类加载器中。只有当父类加载器反馈自己无法完成这个加载请求时(父类加载器加载范围内找不到所需的类),子类加载器才会自己去尝试加载。

双亲委派模型的作用

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它对应的类加载器一起具有了一种优先级的层次关系。比如java.lang.Obejct类,它被存放在rt.jar中,当我们需要加载这个类时,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此可以保证我们的虚拟机中永远只有一个java.lang.Obejct类。如果我们不使用双亲委派模型,而是由各个类加载器自行对类进行加载,就可能会出现我们自定义的java.lang.Obejct类覆盖虚拟机原有的java.lang.Obejct类的情况,这样我们将无法保证java语言的基本秩序,程序也将一片混乱。

双亲委派模型确保了java程序的稳定性,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass() 方法中。
该方法的内部逻辑如下:

  1. 首先检查请求的类是否已经加载过了
  2. 如果没有则调用父类加载器对这个类进行加载
  3. 如果没有父类加载器则默认使用启动类加载器去加载
  4. 如果父类加载器加载失败,就调用本身的findClass方法进行类加载

实际代码如下

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
    {
    
    
        synchronized (getClassLoadingLock(name)) {
    
    
            //1.判断当前类是否在该加载器内加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
    
    
                long t0 = System.nanoTime();
                try {
    
    
                    //2.判断是否有父加载器,有的话委托父加载器加载该类,没有的话交给根类加载器
                    if (parent != null) {
    
    
                        c = parent.loadClass(name, false);
                    } else {
    
    
                        //Ext的parent为null,因为Bootstrap是无法被程序被访问的,默认parent为null时其父加载器就是Bootstrap
                        // 此时直接用native方法调用启动类加载加载,若找不到则抛异常
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
    
    
                }

                if (c == null) {
    
    
                    // 如果父加载器无法加载那么就在本类加载器的范围内进行查找
                    // findClass找到class文件后将调用defineClass方法把字节码导入方法区,同时缓存结果
                    long t1 = System.nanoTime();
                    c = findClass(name);
                }
            }
            if (resolve) {
    
    
                resolveClass(c);
            }
            return c;
        }
    }

破坏双亲委派模型

双亲委派模型并非是一种强制约束。因此虽然我们的大多数类加载器都遵循这个模型,但是也存在例外情况。根据《深入理解java虚拟机》一书所说,双亲委派模型主要出现过三次较大规模的被破坏情况。
第一次是双亲委派模型刚出现的时候,为了向前兼容,我们的类加载器选择增加一个findClass方法来让用户提供自己的类加载方式,因为在此之前用户都是通过重写loadClass()方法来自定义类加载方式。
第二次是因为该模型自身缺陷的修补,双亲委派模型可以很好地解决各个类加载器的基础类的统一问题。但是一旦遇到基础类要调回用户代码就会出现问题。因为我们知道,当我们的类引用一个新类时,会让自身的类加载器去加载这个类,那么问题来了,我们的自定义类是无法被高层类加载器加载的,但同时父类加载器又无法反向委托子类加载器去加载目标类。因此一旦遇到我们前面提到的这个基础类要调回用户代码的情况我们就将束手无策。因此java团队提供了一种解决方案来解决这个问题,那就是引入一个新的类加载器:线程上下文类加载器。这个类加载器可以通过Thread类的setContextClassLoader来进行设置。如果没有设置那么默认从父类中继承一个。如果全部都没有设置过,那么这个类加载器默认就是我们的应用程序类加载器。这样一来,当我们的启动类加载器需要加载一些用户自定义类的时候就可以委派给线程上下文类加载器去间接委托底层类加载器去加载类。,这种方式破坏了双亲委派模式中的单向委派模式。
第三次是由于用于对程序动态性的追求导致的,我们希望我们的程序可以实现代码热替换、模块热部署,也就是说我们希望当我们需要更换一部分代码时不需要重新启动服务器。目前OSGi已经成为业界事实上的Java模块标准,而OSGi实现模块热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块都有一个自己的类加载器,当需要更换模块时就将模块连同类加载器一起换掉以实现代码的热替换。
在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是发展称为一种复杂的网状结构,当类加载器加载类时,OSGi将按照下面顺序进行类搜索:

  1. 将java.*开头的类委派给父类加载器
  2. 否则将委派列表名单中的类委派给父类加载器
  3. 否则,将Import列表中的类委派给Export这个类的模块的类加载器加载
  4. 否则,查找当前模块的的ClassPath,使用当前模块的类加载器加载
  5. 否则,查找类是否在自己的Fragment Bundle中,如果在则委派给Fragment Bundle的类加载器加载
  6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。

OSGi为java程序带来的巨大的复杂性,但是确实公认认为这种方式是值得学习的,只有弄懂OSGi才算是掌握类加载器的精髓。

猜你喜欢

转载自blog.csdn.net/qq_33905217/article/details/111610573