深入理解JVM——类的加载过程(5)

本文主要参考:《深入理解Java虚拟机》—周志明

0. 概述

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

       JVM采用的是懒加载机制,打个比方,你电脑里有10款软件,你开机以后这十款软件不会自动打开,只有你需要打开哪款件时自己去点击才会打开,这就是懒加载(相对的就是即时加载,一开机十款软件同时打开),这样可以节约系统资源,但速度会慢一点。当然。虚拟机的加载器比懒加载还要灵活的很多。我们可以自定义类加载器,所以我们不仅仅可以读取磁盘上的加载文件,我甚至可以通过网络等。

       虚拟机加载进行类加载的过程是在程序运行期间完成的,在程序运行期间加载的好处是可以动态扩展,说白了就是在编译期间虚拟机是不知道要加载哪些类或者接口的,只有在程序运行的时候才知道需要加载的类。举个例子,我们在一个包下面写了很多类,我们在在别的包中可能只是需要其中的几个类,但是出于习惯我们可能会写成import test.* 这种格式,那么在程序运行的时候,虚拟机只会加载哪些我们程序需要的类。这种的动态扩展的特性可以为程序提供高度的灵活性,但缺点是可能会增加一些性能的开销。

1.类加载的时机

       类从创建起(这里的类也可能是接口,下同),就注定了其是有生命周期的(这里的生命周期指的是类在运行期间所经历的过程,与是否存储在存储介质上无关)。类从被虚拟机加载到内存中开始,到卸载出内存为止,它的生命周期经历了加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading),一共七个阶段,其中验证、准备、解析部门统称为连接。这七个阶段可以用如下图描述:

        从上图中可以明显看出各个阶段是有顺序的,加载、验证、准备、初始化这个5个阶段的顺序是固定的,也就是说类的加载过程必须按照这种顺序按部就班开始;

       初始化则不然,就是说解析阶段的工作完全可能在初始化之后才开始,之所以这么设计,就是为了支持Java语言的动态绑定。还有一点需要注意的是,虽然上述的5个阶段可能按照顺序开始,但是并不是说一个接一个阶段完成后才开始,一个阶段的进行完全可能激活另一个阶段的进行(类加载开始以后,就可以开始连接了,并不一定要等到加载完成后才开始连接,其实是并行的关系,只是加载先开始。)。

       我们现在已经知道类加载的大致顺序,而且第一个阶段就是加载。那么类是在什么时候加载到内存中的呢?这就不得不涉及两个概念:主动引用和被动引用。根据Java虚拟机的规范,只有5种情况属于主动引用:

  1. 遇到new(使用new 关键字实例化一个对象)、getstatic(读取一个类的静态字段)、putstatic或者invokestatic(设置一个类的静态字段)这4条指令的时候,如果类没有进行过初始化。则需要先触发其初始化。
  2. 使用反射进行反射调用的时候,如果类没有初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果其父类没有初始化,则需要先触发其父类的初始化
  4. 程序启动需要触发main方法的时候,虚拟机会先触发这个类的初始化
  5. 当使用jdk1.7的动态语言支持的时候,如果一个java.lang.invoke.MethodHandler实例最后的解析结果为REF_getStatic、REF_pusStatic、REF_invokeStatic的方法句柄(句柄中包含了对象的实例数据和类型数据,句柄是访问对象的一种方法。句柄存存储在堆中),并且句柄对应的类没有被初始化,那么需要先触发这个类的初始化。

至于这5种之外的情况就是被动引用了。被动引用的经典例子有:

  1. 通过子类引用父类的静态字段,这种情况不会导致子类的初始化(因为对于静态字段,只有直接定义静态字段的类才会被触发初始化,子类不是定义这个静态字段的类,自然不能被实例化)、
  2. 常量不会触发定义常量的类的初始化(因为常量在编译阶段会存入调用常量的类的常量池中,所以使用调用这个类的常量本质上并没有引用定义这个常量的类,所以不会触发定义这个常量的类的初始化就很好理解了)
  3. 通过数组定义来引用类

2.类加载的过程

2.1 加载

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

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

这三件事在Java虚拟机中并没有说的很详细,比如类的全限定名称是如何加载进来的,以及从哪里加载进来的。通常来讲,一个类的全限定名称可以从zip、jar包中加载,也可以从网络中获取,也可以在运行的时候生成(这点最明显的技术体现就是反射机制)。

       对于类的加载,可以分为数组类型和非数组类型,对于非数组类型可以通过系统的引导类加载器进行加载,也可以通过自定义的类加载器进行加载。这点是比较灵活的。

       而对于数组类型,数组类本身不通过类加载器进行加载,而是通过Java虚拟机直接进行加载的,那么是不是数组类型的类就不需要类加载器了呢?答案是否定的。因为当数组去除所有维度之后的类型最终还是要依靠类加载器进行加载的,所以数组类型的类与类加载器的关系还是很密切的。通常一个数组类型的类进行加载需要遵循以下的原则:

  • 如果数组的组件类型(也就是数组类去除一个维度之后的类型,比如对于二维数组,去除一个维度之后是一个一维数组)是引用类型,那么递归采用上面的过程加载这个组件类型
  • 如果数组类的组件类型不是引用类型,比如是基本数据类型,Java虚拟机将把数组类标记为与引导类加载器关联
  • 数组类的可见性与组件类型的可见性是一致的。如果组件类型不是引用类型,那么数组类的可见性是public,意味着组件类型的可见性也是public。

前面已经介绍过,加载阶段与连接阶段是交叉进行的,所以可能加载阶段还没有完成,连接阶段就已经开始。但是即便如此,记载阶段与连接阶段之间的开始顺序仍然保持着固定的顺序。

       我们上面提到了类加载器,我们现在就来看看什么是类加载器。顾名思义,类加载器是用来加载类的,但是类加载器的作用绝不仅仅是用于加载类。Java中的任意一个类,在Java虚拟机中都由一个类加载器和类本身确定其在虚拟机中的唯一性。当我们比较一个类与另外一个类是否相等的时候,往往是针对同一个类加载器的,如果两个除了类加载器之外其余的信息都是相同的,那么比较这两个类的实例是否是同一个实例返回的也是false的。所以如果不是同一个类加载器,比较是没有任何意义的。

提到类加载器,就不得不提到类加载器的双亲委派模型。其简要的工作过程可以用如下的图加以表示:

      我们首先要知道在java中,类加载器也是分等级的。最高级的一种加载器是加载java中的核心包下的类。比如说java.ang.String类就是通过这种类加载器进行加载的。下一个等级的就是额外的类加载器。也是加载一些类的。再下一级的就是应用程序的类加载器。再下一级的就是自定义的类加载器了。但是这几种加载器之间不是继承关系的,而是组合关系的。这是要注意的。

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器是Java虚拟机本身的一部分,这个类将负责存放<JAVA_HOME>\lib 目录下的类。要注意的一点是,这个类加载器是无法被用户直接引用的
  • 扩展类加载器(Extention ClassLoader):这个类加载器是由sun.misc.Launcher$ExtClassLoader实现的,负责加载<JAVA_HOME>\lib\ext 目录中class。开发者可以直接使用扩展类加载器
  • 应用程序类加载器(Application ClassLoader):这个类加载器是有sun.misc.Launcher$AppClassLoader实现的。这个类加载器是系统默认的类加载器。负责加载类路径上所指定的类(在命令行中可以直接使用-cp或者-classpath命令进行指定)。

       在java中,类加载采用的是代理模式。所谓的代理模式,可以简单的理解为,看起来是这个类加载器进行加载,但其实并不是这个加载器进行加载。这就是代理模式。 在代理模式中,有一个比较重要的一种是双亲委托模式。

       所谓的双亲委托模式就是:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次追溯,直到最高的爷爷辈的。如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。双亲委托机制的最大的好处就是可以确保是安全的。这种机制保证了不会出现用户自己能定义java.lang.Object类的情况出现。类加载器除了用于加载类,也是安全的最基本屏障。举一个例子:

       我首先定义可java.lang包。然后我在这个package的下面定义了一个String类。由于String类是java中的核心包,理应是由最高等级的那个类加载器进行加载的。我们采用了双亲委托的模式: 由上往下,一层一层进行判断,是否能够加载。首先是在最高等级的那个类加载器当中,发现String类是核心包下的一个类。那么就不会对我们自定义的一个类进行加载了。所以即使我们定义了那个类,我们也不能使用这个类。这就是双亲委托模式的好处,可以确保了安全。但是也并不是所有的类加载的过程都是采用双亲委托模式的。比如Tomcat服务器采用的就不是双亲委托模式,它的加载的过程和双亲委托模式是正好相反的。但是也是代理的模式。

2.2 验证阶段

验证阶段的目的是为了确保Class字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。

我们知道Java语言具有相对的安全性(这里的安全性体现为两个方面:一是Java语言本身特性,比如Java去除指针,这点可以避免对内存的直接操作;二是Java所提供的沙箱运行机制,Java保证所运行的机制都是在沙箱之内运行的,而沙箱之外的操作都不可以运行)。但是需要注意的是Java虚拟机处理的Class文件并不一定是是从Java代码编译而来,完全可能是来自其他的语言,甚至可以直接通过十六进制编辑器书写Class文件(当然前提是编写的Class文件符合规范)。从这个角度讲,其他来源的Class文件是不可能都保证其安全性的。所以如果Java虚拟机都信任其加载进来的Class文件,那么很有可能会造成对虚拟机自身的危害。

虚拟机的验证阶段主要完后以下4项验证:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

1) 文件格式验证

       这里的文件格式是指Class的文件规范,这一步的验证主要保证加载的字节流(在计算机中不可能是整个Class文件,只有0和1,也就是字节流)符合Class文件的规范(根据前面对Class类文件的描述,Class文件的每一个字节表示的含义都是确定的。比如前四个字节是否是一个魔数等)以及保证这个字节流可以被虚拟机接受处理。

       在Hotspot的规范中,对文件格式的验证远不止这些,但是只有通过文件格式的验证才能进入方法区中进行存储。所以自然也就知道,后面阶段的验证工作都是在方法区中进行的。

2) 元数据验证

       首先需要对元数据进行一点解释:元数据可以理解为描述数据的数据,更通俗的说,元数据是描述类之间的依赖关系的数据,比如Java语言中的注解使用(使用@interface创建一个注解)。OK我们已经了解了什么是元数据,那么验证阶段中的元数据验证是干嘛的?主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息

上面的语义指的是Java语言中的语义,说白了就是Java的语法。具体的验证信息包括以下几个方面:

  • 这个类是否有父类(除了java.lang.Object外其余的类都应该有父类);
  • 这个类的父类是否继承了不允许被继承的类(比如被final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的方法
  • 类中的字段、方法是否与父类产生矛盾(比如是否覆盖了父类的final字段)

3) 字节码验证

       这个阶段主要对类的方法体进行校验分析。通过了字节码的验证并不代表就是没有问题的,但是如果没有通过验证就一定是有问题的。整个字节码的验证过程比这个复杂的多,由于字节码验证的高度复杂性,在jdk1.6版本之后的虚拟机增加了一项优化,Class类文件结构这篇文章中说到过有一个属性:StackMapTable属性。可以简单理解这个属性是用于检查类型是否匹配。

4) 符号引用验证

       这个验证是最后阶段的验证,符号引用是Class文件的逻辑符号,直接引用指向的方法区中某一个地址,这个转化阶段是在连接的第三个阶段完成的,也就是解析阶段完成的额。符号引用验证主要是对类自身以外的信息进行匹配性校验。比如符号引用是否通过字符串描述的全限定名是否能够找到对应点类。

    进行符号引用验证的目的在于确保解析动作能够正常执行,如果无法通过符号引用验证那么将会抛出java.lang.IncomingChangeError异常的子类。

最后需要注意的一点是验证这一步是可以不进行的(通过vm代码设置)

2.3 准备阶段

       完成了验证阶段之后,就进入准备阶段。准备阶段是正式为变量分配内存空间(方法区)并且设置类变量初始值。需要注意的是,这时候进行内存分配的仅仅是类变量(也就是被static修饰的变量),实例变量是不包括的,实例变量的初始化是在对象实例化的时候进行初始化,而且分配的内存区域是Java堆。这里的初始值也就是在编程中默认值,也就是零值(int:0,boolean :false , 抽象数据类型:null)

      如果一个变量被static修饰,并且已经赋初值,那么在准备阶段还不会将程序中的这个初值赋给这个变量。仍然是零值。但是有一种情况是是例外的:那就是当一个变量同时被static和final修饰,那么这个字段的属性值就是当前赋予的初值(这里其实起作用的是ConstantValue属性)。

2.4 解析阶段

       解析阶段是将常量池中的符号引用替换为直接引用的过程。前面已经提到了符号引用(简单来说,我们想引用一个类,我们就用这个类的全限定名来给它指定)与直接引用(直接使用地址指向)的区别,这里不再赘述。在进行解析之前需要对符号引用进行解析,不同虚拟机实现可以根据需要判断到底是在类被加载器加载的时候对常量池的符号引用进行解析(也就是初始化之前),还是等到一个符号引用被使用之前进行解析(也就是在初始化之后)。

     到现在我们已经明白解析阶段的时机,那么还有一个问题是:如果一个符号引用进行多次解析请求,虚拟机中除了invokedynamic指令外(用来执行动态语言的,他每一次执行和解析的地址是不同的,所以不能给它缓存),虚拟机可以对第一次解析的结果进行缓存(在运行时常量池中记录引用,并把常量标识为一解析状态),这样就避免了一个符号引用的多次解析。

解析动作主要针对的是或者接口字段类方法方法类型方法句柄调用点限定符7类符号引用。这里主要说明前四种的解析过程。

1) 类或者接口的解析

要把一个类或者接口的符号引用解析为直接引用,需要以下三个步骤:

  • 如果该符号引用不是一个数组类型,那么虚拟机将会把该符号代表的全限定名称传递给调用这个符号引用的类。这个过程由于涉及验证过程所以可能会触发其他相关类的加载
  • 如果该符号引用是一个数组类型,并且该数组的元素类型是对象。我们知道符号引用是存在方法区的常量池中的,该符号引用的描述符会类似”[java/lang/Integer”的形式,将会按照上面的规则进行加载,虚拟机将会生成一个代表此数组对象的直接引用
  • 如果上面的步骤都没有出现异常,那么该符号引用已经在虚拟机中产生了一个直接引用,但是在解析完成之前需要对符号引用进行验证,主要是确认当前调用这个符号引用的类是否具有访问权限,如果没有访问权限将抛出java.lang.IllegalAccess异常

2) 字段解析

对字段的解析需要首先对其所属的类进行解析,因为字段是属于类的,只有在正确解析得到其类的正确的直接引用才能继续对字段的解析。对字段的解析主要包括以下几个步骤:

  • 如果该字段符号引用(后面简称符号)就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,解析结束
  • 否则,如果在该符号的类实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果在接口中包含了简单名称和字段描述符都与目标相匹配的字段,那么久直接返回这个字段的直接引用,解析结束
  • 否则,如果该符号所在的类不是Object类的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都相匹配的字段,那么直接返回这个字段的直接引用,解析结束
  • 否则,解析失败,抛出java.lang.NoSuchFieldError异常
  • 如果最终返回了这个字段的直接引用,就进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常

经过这些步骤后,对一个字段的解析就到此为止

3) 类方法的解析

进行类方法的解析仍然需要先解析此类方法的类,在正确解析之后需要进行如下的步骤:

  • 类方法和接口方法的符号引用是分开的,所以如果在类方法表中发现class_index(类中方法的符号引用)的索引是一个接口,那么会抛出java.lang.IncompatibleClassChangeError的异常
  • 如果class_index的索引确实是一个类,那么在该类中查找是否有简单名称和描述符都与目标字段相匹配的方法,如果有的话就返回这个方法的直接引用,查找结束
  • 否则,在该类的父类中递归查找是否具有简单名称和描述符都与目标字段相匹配的字段,如果有,则直接返回这个字段的直接引用,查找结束
  • 否则,在这个类的接口以及它的父接口中递归查找,如果找到的话就说明这个方法是一个抽象类,查找结束,返回java.lang.AbstractMethodError异常(因为抽象类是没有实现的)
  • 否则,查找失败,抛出java.lang.NoSuchMethodError异常
  • 如果最终返回了直接引用,还需要对该符号引用进行权限验证,如果没有访问权限,就抛出java.lang.IllegalAccessError异常

4) 接口方法的解析

同类方法解析一样,也需要先解析出该方法的类或者接口的符号引用,如果解析成功,就进行下面的解析工作:

  • 如果在接口方法表中发现class_index的索引是一个类而不是一个接口,那么也会抛出java.lang.IncompatibleClassChangeError的异常
  • 否则,在该接口方法的所属的接口中查找是否具有简单名称和描述符都与目标字段相匹配的方法,如果有的话就直接返回这个方法的直接引用。查找结束
  • 否则,在该接口以及其父接口中查找,直到Object类,如果找到则直接返回这个方法的直接引用
  • 否则,查找失败

接口的所有方法都是public,所以不存在访问权限问题

2.5. 初始化

类初始化阶段是类加载过程的最后一步,前面类加载的过程中除了在加载阶段用户应用程序可以通过自定义类加载器参与以外,其余动作完全由虚拟机主导与控制。到了初始化阶段,虚拟机才开始真正执行Java程序代码,前文讲到对类变量的初始化,但那是仅仅赋初值,用户自定义的值还没有赋给该变量。只有到了初始化阶段,才开始真正执行这个自定义的过程,所以也可以说初始化阶段是执行类构造器方法<clinit>的过程。那么这个<clinit> 方法是这么生成的呢?

  • <clinit>() 是编译器自动收集类中所有类变量的赋值动作和静态语句块合并生成的。编译器收集的顺序是由语句在源文件中出现的顺序决定的,静太语句块只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的语句块中可以复制,但不能访问。这点可以通过下面的代码体现

  • <clinit> () 方法与类的构造器方法不同,因为前者不需要显式调用父类构造器,因为虚拟机会保证在子类的<clinit>() 方法执行之前,父类的<clinit> 方法已经执行完毕
  • 由于父类的<clinit> 方法会先执行,所以就表示父类的static方法会先于子类的<clinit> 方法执行。这点也可以通过下面的代码得到体现:
public class Parent {

    public static int A = 1;

    static{
        A = 2;
    }
    
    static class Sub extends  Parent {
        public static int B = A;
    }

    public static void main(String[] args){
        System.out.println(Sub.B);
    }
}

得到的结果是2而不是1,这就验证了父类的静态方法会先于子类的static方法执行。

猜你喜欢

转载自blog.csdn.net/qq_36582604/article/details/81671875