美文网首页
类加载机制

类加载机制

作者: Chengyu_l | 来源:发表于2017-08-09 23:54 被阅读0次

    转载自http://wiki.jikexueyuan.com/project/java-vm/class.html

    类加载过程

      类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用及卸载七个阶段。它们的顺序如下图所示:


    类生命周期

    其中类加载的过程包括加载、验证、准备、解析及初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化之后执行,这时为了支持Java语言的运行时绑定(也称动态绑定)。另外这几个阶段是按顺序开始执行,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中会调用或激活另一个阶段。

    这里简要说一下Java动态绑定:绑定指的是把一个方法的调用和方法所在类关联起来,对Java来说,绑定分为静态绑定和动态绑定:

    • 静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其他连接程序实现。针对Java,可以简单的理解为程序编译器绑定。Java当中的方法只有final、static、private和构造方法是前期绑定。
    • 动态绑定:也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在Java中,几乎所有的方法都是后期绑定。

    下面详细讲述类加载过程中每个阶段所做的工作。

    加载

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

    1. 通过一个类的全限定名称获取其二进制字节流。
    2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
      注意,这里第1条中的二进制字节流并不是单纯的从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其它文件(JSP应用)生成等。

    相对类加载的其它阶段而言,加载阶段是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
    说到加载,不得不提类加载器,下面就具体讲述一下类加载器。
    类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,也就是说,即使两个类来源同一个Class文件,只要它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代码类的Class对象的equals()、isInstance()等方法返回的结果,也包括使用了instanceOf关键字对对象所属关系的判断结果。

    类加载器可以大致划分为以下三类:

    1. 启动类加载器(Bootstrap Class Loader):它是由C++实现,是虚拟机自身的一部分。它负责加载存放在JDK/jre/lib目录下,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被启动类加载器加载)。启动类加载器无法被Java程序直接引用。
    2. 扩展类加载器(Extension Class Loader):它是由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK/jre/lib/ext目录下的,或者由java.ext.dirs系统环境变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
    3. 应用类加载器(Application Class Loader):它是由sun.misc.Launcher$AppClassLoader实现,它负责加载用户类(ClassPath)所指定的类。开发者可以直接使用应用类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

    如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的类加载器只懂得从本地文件系统加载标准的Class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

    • 在执行非可信代码之前,自动验证数字签名
    • 动态创建符合用户需求的定制化类
    • 从特定场所获取Class,例如数据库中或网络中

    这几种类加载器的层次关系如下图所示:


    类加载器的层次关系

    这种层次关系称为双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承来实现,而是使用组合关系来复用父加载器中的代码。

    双亲委派模型的工作流程:如果一个类加载器收到一个类加载请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上。因此,所有类加载器请求最终都会被传递到顶层的启动类加载器。只有当父加载器在它的搜索范围内没有找到所需要的类,子加载器才会尝试自己去加载该类。
    双亲委派模型有一个明显的好处,就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,这对于Java程序的稳定运行很重要。例如:java.lang.Object类存放在JDK/jre/lib下的rt.jar之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,保证了Object类在程序中的各种类加载器中都是同一个类。

    验证

    验证的目的是为了确保Class文件所包含的信息符合Java虚拟机规范要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有不同,但大致都会完成以下四个阶段的验证:

    1. 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前虚拟机处理。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储。后面三个验证都是基于方法区的存储结构进行的。
    2. 元数据验证:对类的元数据进行语义校验(其实就是语法校验),保证不存在不符合Java语法规范的元数据信息。
    3. 字节码验证:该阶段验证主要是进行数据流和流控制分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
    4. 符号验证:这个最后一个阶段的验证,它发生在虚拟机将符号引用转换为直接引用的时候,主要是对类自身以外的信息进行匹配性的校验。
    准备

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

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

    假设一个类变量的定义为:

    public static int value = 3;
    

    那么变量value在准备阶段过后的初始值为0,而不是3,因为这个时候尚未开始执行任何Java方法,value赋值为3的putstatic指令是在程序编译后,存放于类构造器()方法之中的,所以value赋值为3的动作将在类初始化阶段执行。

    下表列出了Java中所有基本数据类型以及reference类型的默认值


    基本数据类型以及reference类型的默认值

    这里还需要注意如下几点:

    • 对于基本数据类型来说,类变量(static)和全局变量,如果不显示地对其赋值而直接使用,则系统会为其赋予默认的零值。而对于局部变量来说,在使用之前必须显式地为其赋值,否则编译时不通过。
    • 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译不通过;而只被final修饰的常量,既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值(构造函数中赋值)。总之,系统不会为其赋予默认的零值。
    • 对于引用类型来说,如数组引用、对象引用等,如果没有进行显式赋值而直接使用,系统都会为其赋予默认的零值,即null。
    • 如果数组初始化时没有对数组中的元素赋值,那么其中的元素将根据对应的数据类型被赋予默认的零值。
    解析

    解析阶段是虚拟机将常量池中的符号引用转换为直接引用的过程。解析阶段可以开始于类初始化之前,也可能在初始化之后,虚拟机会根据需要来判断。对于一个符号引用进行多次解析是很常见的事情,虚拟机会对第一次解析结果进行缓存(在运行时常量池中记录直接引用,并把常量标记为已解析状态),从而避免解析动作重复进行。

    解析动作主要针对类或接口、字段、类方法和接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四种类型。

    1. 类或接口的解析:判断所要转换成的直接引用是普通对象类型,还是数组类型的引用,从而进行不同的解析。
    2. 字段解析:对字段解析时,会先在本类中查询是否包含有与目标字段相匹配的字段,如果有,则结束查找;如果没有,则会按照继承关系从下往上递归搜索该类的接口和父接口;如果还没有,则会继续按照继承关系从下往上递归搜索该类的父类,直到查找结束。查找流程如下图:


      字段查找流程

    从下面一段代码的执行结果中很容易看出来字段解析的搜索顺序:

    class Super{  
        public static int m = 11;  
        static{  
            System.out.println("执行了super类静态语句块");  
        }  
    }  
    
    class Father extends Super{  
        public static int m = 33;  
        static{  
            System.out.println("执行了父类静态语句块");  
        }  
    }  
    
    class Child extends Father{  
        static{  
            System.out.println("执行了子类静态语句块");  
        }  
    }  
    
    public class StaticTest{  
        public static void main(String[] args){  
            System.out.println(Child.m);  
        }  
    }  
    

    执行结果如下:

    执行了super类静态语句块
     执行了父类静态语句块
     33
    

    如果注释掉Father类中对m定义的那一行,则输出结果如下:

    执行了super类静态语句块
    11
    

    分析如下:static 变量发生在静态解析阶段,也即是在初始化之前,此时已经将字段的符号引用转换为内存引用,将它与对应的类关联在了一起,由于在子类中没有查找到与m相匹配的字段,那么m便不会与子类关联在一起,因此并不会触发子类的初始化。
    最后需要注意:理论上是按照上述顺序进行搜索解析,但实际应用中,虚拟机的编译器实现可能要比上述规范要求更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己和父类的接口中出现,编译器可能会拒绝编译。如果对上面的代码做些修改,将Super改为接口,并将Child类继承Father类且实现Super接口,那么在编译时会报如下错误:

    StaticTest.java:24: 对 m 的引用不明确,Father 中的 变量 m 和 Super 中的 变量 m
    都匹配
                    System.out.println(Child.m);
                                            ^
    1 错误
    
    1. 类方法解析:对类方法解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,在搜索接口。
    2. 接口方法解析:与类方法解析步骤类似,只是接口不会有父类,因此递归向上搜索父接口就行了。
    初始化

    初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始零值,而在初始化阶段,则是根据程序员通过程序指定的值去初始化类变量和其他资源。初始化阶段就是执行类构造器()方法的过程。

    这里简单说一下()方法执行的规则:

    1. ()方法是有编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的类变量,定义在它之后的变量,在前面的静态语句中可以赋值,但不能访问。

    下面给出两个简单的例子,以便更清楚地说明如上规则:

    class Father{  
        public static int a = 1;  
        static{  
            a = 2;  
        }  
    }  
    class Child extends Father{  
        public static int b = a;  
    }  
    public class ChildTest{  
        public static void main(String[] args){  
            System.out.println(Child.b);  
        }  
    }  
    

    执行上面的代码,会打印出2,也就是说b的值被赋为了2。
    我们来看看上面的例子得到该结果的步骤:首先在准备阶段为类变量分配内存病设置类变量初始值,这样A和B均被赋值为默认值0,而后再在调用()方法时给他们赋予程序中指定的值。当我们调用Child.b时,触发Child的()方法,根据规则2,在此之前,首先要执行完其父类Father的()方法,又根据规则1,在执行()方法时,需要按static语句或者static变量在代码中出现的顺序来执行相关的static语句,因此当触发执行Father的()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,而后在执行Child类的()方法,这样便会将b赋值为2。

    如果我们颠倒一下 Father 类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则 1,执行 Father 的()方法时,根据顺序先执行了 static 语句块中的内容,后执行了“public static int a = 1;”语句。

    class Father{  
        static{  
            a = 2;  
        }  
        public static int a = 1; 
    }  
    class Child extends Father{  
        public static int b = a;  
    }  
    public class ClinitTest{  
        public static void main(String[] args){  
            System.out.println(Child.b);  
        }  
    }  
    

    另外,在颠倒二者的顺序之后,如果在 static 语句块中对 a 进行访问(比如将 a 赋给某个变量),在编译时将会报错,因为根据规则 1,它只能对 a 进行赋值,而不能访问。

    相关文章

      网友评论

          本文标题:类加载机制

          本文链接:https://www.haomeiwen.com/subject/nkzqrxtx.html