美文网首页
类的加载机制

类的加载机制

作者: 一位不愿透露姓名的李小姐 | 来源:发表于2017-03-10 21:13 被阅读0次

    搜索的时候看了好几篇文,自己就想记录一遍,加深一下记忆,以下是原文的地址,受益匪浅。
    blog.csdn.net/ns_code/article/details/17881581(此篇推荐)
    blog.csdn.net/boyupeng/article/details/47951037
    hammer.coding.me/2016/10/26/jvm-1/
    类的加载机制,主要是为了把.class文件中的各种信息加载到内存里,并且对这些数据进行校验、转换解析和初始化,最终为虚拟机可以使用的java类型。

    类从被加载到卸载,是有一个生命周期的:

    加载(Loading)、验证(Verification) 、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。
    其中验证、准备和解析三个部分被统称为连接(Linking)。


    由于Java可以进行动态扩展,这就意味着可以进行动态加载和动态链接。这样上图中的加载、验证、准备、初始化和卸载这五个步骤的顺序是确定的。但是解析就不一定了,它可以等到初始化完了再开始。所以上述的生命周期中每一个阶段都是互相价差混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。

    这里我终于深刻理解到了‘解析等到初始化完了再开始’是什么意思了#

    结果:构造块 构造块 静态块 构造块

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

    1)静态绑定:编译器绑定。在程序执行前方法已经被绑定了,Java当中的方法只有final,static,private和构造方法是前期绑定的。
    2)动态绑定:运行时绑定。在运行时根据具体对象的类型进行绑定。

    我一直以为构造方法不是运行时才会绑定吗?后来查了一下,构造方法链和动态绑定是俩概念。

    精确使用的方法是编译器绑定,在编译阶段,最佳方法名依赖于参数的静态和控制引用的静态类型所适合的方法。在这个一点上,设置方法的名称,这一步叫静态重载。决定方法是哪一个类的版本,这通过由虚拟机推断出这个对象的运行时类型来完成,一旦知道运行时类型,虚拟机就唤起继承机制,寻找方法的最终版本,叫动态绑定。
    参考链接:http://blog.csdn.net/lingzhm/article/details/44116091
    http://blog.csdn.net/lzm1340458776/article/details/26280607

    一、加载

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

    相对于类加载过程的其他阶段,加载阶段是开发期可控性最强的阶段,因为加载阶段可以使用系统提供的类加载器(Class Loader)来完成,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

    加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自行定义,虚拟机并未规定此区域的具体数据结构。然后在Java堆中实例化一个java.lang.Class类的对象,这个对象作为程序访问方法区中的这些类型数据的外部接口。

    二、验证

    验证是链接阶段的第一步,这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。
    验证阶段主要包括:文件格式验证、元数据验证、字节码验证和符号引用验证。(具体意思就不写啦)

    三、准备

    准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的知识点,首先是这时候进行内存分配的仅包括类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配到Java堆中。其次是这里所说的初始值通常情况下是数据类型的零值,假设

    public static int value=12;

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


    有一些特殊情况,如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假如:
    public static final int value =123;
    编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设备将value设置为123。

    特别注意几点:

    1)对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。

    2)对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。

    3)对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
    如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。

    ConstantValue属性:
    .class中的属性,通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。非static类型的变量的赋值是在实例构造器方法中进行的。在实际程序中,只有同时被final和static修饰的字段才有ConstantValue属性, 且限于基本类型和String。编译时javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根基ConstantValue为常量设置相应的值,如果该变量没有被final修饰,或者并非基本类型及字符串,则选择在类构造器中进行初始化。

    问题:
    a.为什么ConstantValue的属性值只限于基本类型和String?
    因为从常量池中只能引用到基本类型和String类型的字面量。

    b.final、static、static final修饰的字段赋值的区别?

    1)static修饰的字段在加载过程中准备阶段被初始化,但是这个阶段只会赋值一个默认的值(0或者null而并非定义变量设置的值)初始化阶段在类构造器中才会赋值为变量定义的值。
    2)final修饰的字段在运行时被初始化,可以直接赋值,也可以在实例构造器中赋值,赋值后不可修改。
    3)tatic final修饰的字段在javac编译时生成comstantValue属性,在类加载的准备阶段直接把constantValue的值赋给该字段。

    四、解析

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

    符号引用:符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。

    直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

    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

    satic变量在准备的时候已经拥有默认值了,所以m在当时都是0,当Child.m这行代码被执行的时候,在Child类中没有找到m这个字段,那根据上述解析的定义,他就会去父类或者实现的接口上去找,于是他就找到了Fater类,他找到了之后,就停止了。然后执行的是father的方法。而Child类根本不会被初始化。而身为father的父类super被初始化了。

    如果注释掉Father类中对m定义的那一行,则输出结果如下:
    执行了super类静态语句块
    11

    这时候Child类中没有找到m这个字段,父类里也没有,所以child和Father都没有被初始化。只有super被初始化了,并且m=11

    最后需要注意:理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译。如果对上面的代码做些修改,将Super改为接口,并将Child类继承Father类且实现Super接口,那么在编译时会报出如下错误:

    StaticTest.java:24: 对 m 的引用不明确,Father 中的 变量 m 和 Super 中的 变量 m
    都匹配
    System.out.println(Child.m);
    ^
    1 错误

    3、类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。

    4、接口方法解析:与类方法解析步骤类似,知识接口不会有父类,因此,只递归向上搜索父接口就行了。

    五、初始化

    类的初始化阶段是类加载过程的最后一步,在准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程。在以下四种情况下初始化过程会被触发执行:

    1.遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需先触发其初始化。生成这4条指令的最常见的java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候。

    2.使用java.lang.reflect包的方法对类进行反射调用的时候

    3.当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先出发其父类的初始化

    4.jvm启动时,用户指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个类

    在上面准备阶段 public static int value = 12; 在准备阶段完成后 value的值为0,而在初始化阶调用了类构造器<clinit>()方法,这个阶段完成后value的值为12。

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

    类构造器<clinit>()方法与类的构造函数(实例构造函数()方法)不同,它不需要显式调用父类构造,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中的第一个执行的<clinit>()方法的类肯定是java.lang.Object。
    由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句快要优先于子类的变量赋值操作

    <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句,也没有变量赋值的操作,那么编译器可以不为这个类生成<clinit>()方法。

    接口中不能使用静态语句块,但接口与类不太能够的是,执行接口的<clinit>()方法不需要先执行父接口的()方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
    虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞。

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

    这个其实和上边的例子本质上是一样的。输出b为2。一下为转载文中作者的解释,挺详细的:

    执行上面的代码,会打印出2,也就是说b的值被赋为了2。

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

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

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

    类的加载器

    1、ClassLoader

    所有自定义的类加载器都要继承该类。调用ClassLoaderl类的loadClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

    2、父委托机制(Parent Delegation)

    1)类的加载过程采用父亲委托机制,各个加载器按照父子关系形成了树形结构,除了Java虚拟机自带的根类加载器以外,其余的类加载器都有切只有一个父加载器。如果当前这个加载器想要加载一个Class,它不是直接自己就加载,他先去寻找他自己上边的父加载器可不可以加载,如果不可以才进行加载

    2)当他找到某个类加载器能够加载这个类的时候,这个类加载器就被称作为定义类加载器,而在他之下的所有子类加载器包括他自己本身,都被称为初始类加载器。

    注意:加载器之间的父子关系实际上指的是加载器对象之间的包装关系,而不是类之间的继承关系。一对父子加载器可能是同一个加载器类的两个实例,也可能不是。在子加载器对象中包装了一个父加载器对象。

    就是说可能,某两个加载器loader1和loader2都是TestClassLoader类的实例,并且loader2包装了loader1,这样来说loader1就是loader2的父加载器。

    3)当生成一个自定义的类加载器实例时,如果没有指定它的父加载器,那么系统类加载器就将成为该类加载器的父加载器。

    4) 父亲委托机制的优点是能够提高软件系统的安全性。因为在这个机制下,用户自定义的类加载器就不能加载应该由父加载器加载的可靠类,这样就可以防止不可靠甚至恶意的代码代替由父加载器加载的可靠代码。(就是说,我不能自己造一个比方说含有恶意代码的Java.lang.Object类出来加载)

    5)命名空间
    每个类加载器有自己的命名空间(加载器+所有父加载器加载的类)。在同一个命名空间中,不会出现类的完整名字相同的两个类,在不同的命名空间中,有可能会出现类的完整名字相同的两个类。

    个人理解:一个类是可以加载两次的,但是前提是由两个不同的类加载器加载的,并且他俩之间没有父子关系,不然在他们共有的命名空间里就会存在这个已经加载的类。所以在每次进行类加载之前,加载器都会去命名空间里查看这个类是不是已经被加载过了。

    6)运行时包
    当同一个类加载器加载的属于相同包的类组成了运行时。决定两个类是不是属于同一个运行时包,不仅要看他们的报名是否相同,还要看定义类加载器是否相同。只有归属于同一个运行时包的类才能互相访问包可见的类和类成员。这样才能避免用户自定义的类来冒充核心类库中的类,去访问核心类库的包课件成员。

    类的卸载

    1)当一个类被加载、连接和初始化后,它的生命周期就开始了。当代表这个类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,那么这个类在方法区内的数据也会被卸载,从而结束生命周期。就是说一个类什么时候结束它的生命周期,实际上取决于代表它的Class对象什么时候结束生命周期。

    2)由java虚拟机自带的类(根加载器、扩展类加载器、系统加载器)所加载的类,在虚拟机的生命周期中,始终不会卸载。虚拟机本身会始终引用这些加载器,而这些类加载器则会始终引用他们所加载的类Class对象。但是自定义的类是可以被卸载的。

    3)在类加载器的内部实现中,用一个Java集合来存放所加载的类的引用。另外,一个class对象总是会引用它的类加载器。一个类的实例总是引用代表这个类的class对象。

    个人理解:就是其实类的加载器和类的实例之间是双向关联的关系。class对象通过getClassLoader()来得到类加载器,加载器通过getClass()方法来得到其加载的类。


    实例代码:

    这三行代码的加载图:

    此时如果我一旦把所有的引用变量都置为Null,这样Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也会结束生命周期,而该类在方法去内的二进制数据被卸载。(注意此处该对象在方法区的二进制数据被卸载)

    这样当再次加载该类的时候clazz变量引用的Class对象的哈希码将会得到不同的数值,因为在这两侧中引用了不同的Class对象,在Java虚拟机的生命周期中,对该类进行了先后两次的加载。

    相关文章

      网友评论

          本文标题:类的加载机制

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