JVM-类加载机制

 

 

java类加载机制
虚拟机把Class文件加载到内存,并且对数据进行检验,转换解析和初始化,形成虚拟机能够直接使用的java类型。java.lang.Class

 

 

这里写图片描述

 

(1)装载:查找和导入class文件

  类加载器ClassLoader:

  
    
  a-通过一个类的全限定名获取定义此类的二进制字节流
  b-将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  c-在java堆中生成一个代表在这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

 

  双亲委派机制   https://www.cnblogs.com/lanqingzhou/p/13600792.html

      

  命名空间:https://www.cnblogs.com/lanqingzhou/p/13602419.html

 命名空间是由该类加载器以及其父类加载器所构成的,其中父类加载器加载的类对其子类可见,但是反过来子类加载的类对父类不可见,同一个命名空间中一定不会出现同一个类(全限定名一模一样的类)多个Class对象,换句话说就是在同一命名空间中只能存在一个Class对象,所以当你听别人说在内存中同一类的Class对象只有一个时其实指的是同一命名空间中,当然也不排除他压根就不知道这个概念。

 

 

 

我们需要注意一些事情:

对于方法区的认识:被加载的类的信息存储在方法区中,可以被线程所共享,也就是说,加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在了方法区之中。然后你能想起来那个二进制流中都存储了哪些信息吗?

对于Class对象认识:Class对象虽然是在内存中,但并未明确规定是在Java堆中,对于HotSpot来说,Class对象存储在方法区中。它作为程序访问方法区中二进制字节流中所存储各种数据的接口。你能大概想到反射机制中的Class对象是怎么一回事了吗?为什么可以在运行期通过反射机制得到那么多的类信息你能猜测到吗?

 

(2)链接
a-验证:保证被加载类的正确性
b-准备:为类的静态变量分配内存,并初始 化为默认值 private static int a = 3;a=0;
c-解析:把类中的符号引用转为直接引用 Object.obj

 

验证

从上面类的生命周期一图中我们可以看出,验证是连接的第一步,这一阶段的目的主要是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,从而不会危害虚拟机自身安全。也就是说,当加载阶段将字节流加载进方法区之后,JVM需要做的第一件事就是对字节流进行安全校验,以保证格式正确,使自己之后能正确的解析到数据并保证这些数据不会对自身造成危害。

验证阶段主要分成四个子阶段:

  • 文件格式验证 :保证输入的字节流能被正确的解析并存储在方法区
    • 是否以魔数0xCAFFEBABE开头
    • 主次版本号是否在虚拟机1处理范围内
    • 常量池中的常量是否有不被支持的常量类型
    • 指向常量的各种索引值是否有指向不存在的常量
  • 元数据验证:类的元数据验证,确保符合Java语言规范
    • 这个类是否有父类
    • 这个类的父类是否继承不被允许的类(final修饰的类)
    • 如果这个类不是抽象类,是否实现了接口要求实现的方法
    • 类中的字段,方法是否与父类矛盾(例如出现不符合规则的方法重载)
  • 字节码验证:通过数据流和控制流,确定程序语义是合法的,符合逻辑的
    • 保证任何时刻操作数栈的数据类型与指令代码序列能配合工作
    • 保证跳转指令不会跳转到方法体以外的字节码指令
    • 保证方法体中类型转换有效,如避免出现将父类对象赋值到子类数据类型上
  • 符号引用验证:这个验证发生在虚拟机将符号引用化为直接引用的时候,这个转化发生在解析阶段。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配校验。如:符号引用种通过字符串描述的全限定名是否能找到对应的类,符号引用中的类、字段、方法、的可访问性是否可被当前类访问。
  •           符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符号
    • 符号引用的类,字段,方法的访问性是否可以被当前类访问

挑点重点来说吧,对字节流进行校验是由一个叫做Class文件检验器的东西所完成,其实还是代码实现。

而什么叫做元数据呢?

所谓的元数据是指用来描述数据的数据,更通俗一点就是描述代码间关系,或者代码与其它资源(例如数据库表)之间内在联系的数据,你也可以更简单的认为成框架中的各种@注解,因为这些@注解很简介的描述了大量有关各个类、方法、字段额外的信息或之间的联系。

元数据验证也就是验证这些额外的信息或它们之间的联系是否正确。

我们还得注意字节码验证,在字节码验证中涉及到了一个概念:字节码流。

字节码流 = 操作码 + 操作数。

操作码就是伪指令,操作数就是普通的Java数据,如int,float等等。

所以对字节码验证的过程就是对字节码流验证的过程,也就是验证操作码是否合法,操作数是否合法。

而符号引用验证涉及到常量池解析的知识,在下文中我们顺带着将符号引用验证带过就行,现在先不说。


准备

准备阶段你只要掌握两个知识点:

1.准备阶段的目的:正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存将在方法区中分配。

注意我的重点:是类变量(static)不是实例变量,还有,我们又知道了在JVM的方法区中不仅存储着Class字节流(按照运行时方法区的数据结构进行存储,上述的二进制字节流是不严谨的说法,只是为了大家好理解),还有我们的类变量。

2.这里的类变量初始值通常是指数据类型的零值。比如int的零值为0,long为0L,boolean为false… …真正的初始化赋值是在初始化阶段进行的。

额外一点,如果你设置的类变量还具有final字段,如下:

public static final int value = 123;

那么在准备阶段变量的初始值就会被直接初始化为123,具体原因是由于拥有final字段的变量在它的字段属性表中会出现ConstantValue属性。


解析

这一阶段我个人觉得不太好理解并且非常重要,但我还是会一点点剖析难点,保证你能听懂,所以开始吧~~

先来看一下解析阶段的目的:虚拟机将常量池内的符号引用替换为直接引用。

然后说一下解析阶段最大的特点:发生时间不可预料,有可能和初始化阶段互相交换位置。至于原因,我们等下再说。

先来说看完解析阶段的目的吧,你有可能有三个疑问。哪个常量池?什么符号引用?什么直接引用?Ok,搞清这三个问题,解析这部分你也就学会了。

首先来说常量池:在Class的文件结构中我们就花了大量的篇幅去介绍了常量池,我们再来总结一下:常量池(constant pool)指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。它包括了关于类、方法、接口等中的常量,也包括字符串常量。

然后这段话中的常量池指的就是存在于.class文件中的常量池,结果在运行期被JVM装载,并且可以扩充的存在于方法区中的运行时常量池。

然后来看符号引用:在Class文件中我们也讲述了什么是符号引用。总的来说就是常量池中存储的那些描述类、方法、接口的字面量,你可以简单的理解为就是那些所需要信息的全限定名,目的就是为了虚拟机在使用的时候可以定位到所需要的目标。

最后来看直接引用:直接指向目标的指针、相对偏移量或能间接定位到目标的句柄。

现在我们对上面那句话进行重新解读:虚拟机将运行时常量池中那些仅代表其他信息的符号引用解析为直接指向所需信息所在地址的指针。

大概就是这样,我觉得你应该已经完全明白了。

解决一个遗留的问题:还记得刚才没有说到的符号引用吗?

这一阶段就是发生在JVM将符号引用转换为直接引用的时候,它的作用就是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,以确保解析动作能够正常执行!

在解析阶段主要有以下不同的动作,我只给大家罗列出来,不细讲,有兴趣的同学可以自行百度:

  • 类或接口的解析(注意数组类和非数组类):

    如果当前处于类D代码中,要把一个未解析符号引用N解析为一个类或接口的直接引用C,大致需要三步:

          1.如果C不是数组类型,那么将由D的类加载器根据N的全限定名去加载类C。在加载过程中可能触发其他相关类的加载,如父类或实现的接口,一旦失败, 解析过程就失败了

          2.如果C是一个数组类型,如Integer数组类型,则N的描述符会是“[Ljava/lang/Integer”的形式,则会加载java.lang.Interger,接着由JVM生成一个代表该数组维度和元素的数组对象。

          3.如果上两步通过,则C即成为一个实际有效的类或接口了,解析完成前,还需要验证D对C的访问权限,如果D不具备,抛出java.lang.IllegalAccessError异常。

  • 字段(简单名称+字段描述符)解析(注意递归搜索):

                         首先对字段所属的类C或接口的符号引用进行解析,一旦失败,则解析失败,如果解析成功,将会按照以下步骤解析类C的后续字段:类C自身范围查找→失败则从下往上递归在实现的接口中查找→失败则从下往上递归在继承的父类中查找,仍然失败则抛出java.lang.NoSuchFieldError异常。如果查找成功返回了引用,则进行权限验证,验证失败则抛出java.lang.IllegalAccessError异常。

          在实际情况中,虽然查找的范围是依次进行,但是如果一个字段在父类或实现的接口中多次出现,按照规则依然可以保证唯一性,但是多数编译器会拒绝编译代码。

  • 类方法解析(注意递归搜索):

                         首先解析类方法表内class_index项中索引的CONSTANT_Class_info符号引用,也就是方法所属的类或接口的符号引用,如果解析完成,将这个类方法所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续类方法的搜索。

    (1)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C 是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。

    (2)如果通过了第一步,在类C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

    (3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

    (4)否则,在类C实现的接口列表以及他们的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在相匹配的方法,说明类C是一个抽象类这时查找结束,抛出java.lang.AbstractMethodError异常。

    (5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

  • 接口方法解析(注意递归搜索):

                         接口方法解析一样需要解析接口所属接口的符号引用,如果解析成功,则按照以下步骤对后续接口方法进行搜索:接口C中查找→失败则在C的父接口中查找→失败则查找失败,抛出java.lang.AbstractMethodError异常,查找结束

          在JDK9以前,所有接口方法都是public的,也没有模块化访问约束,所以不存在访问权限问题,不可能抛出java.lang.IllegalAccessError异常。

在解析阶段还有一个很有意思的东西:动态连接!

它也是上面解析阶段发生时间不确定的直接原因:大部分JVM的实现都是延迟加载或者叫做动态连接。它的意思就是JVM装载某个类A时,如果类A中有引用其他类B,虚拟机并不会将这个类B也同时装载进JVM内存,而是等到执行的时候才去装载。

而这个被引用的B类在引用它的类A中的表现形式主要被登记在了符号表中,而解析的过程就是当需要用到被引用类B的时候,将引用类B在引用类A的符号引用名改为内存里的直接引用。这就是解析发生时间不可预料的原因,而且这个阶段是发生在方法区中的。


初始化

虚拟机规范定义了5种情况,会触发类的初始化阶段,也正是这个阶段,JVM才真正开始执行类中定义的Java程序代码:

  • new一个对象、读取一个类静态字段、调用一个类的静态方法的时候
  • 对类进行反射调用的时候
  • 初始化一个类,发现父类还没有初始化,则先初始化父类
  • main方法开始执行时所在的类
  • 最后一种情况我也不懂,就不贴了

额外补充:

有三种引用类的方式不会触发初始化(也就是类的加载),为以下三种:

  • 通过子类引用父类的静态字段,不会导致子类初始化
  • 通过数组定义来引用类,不会触发此类的初始化
  • 引用另一个类中的常量不会触发另一个类的初始化,原因在于“常量传播优化”

来说一说常量传播优化吧(先看一份代码

 

 

public class ConstClass {

 

 

  static {

    System.out.println("ConstClass init!");

  }

  public static final String HELLOWORLD = "hello world";

}

public class NotInitialization {

  public static void main(String[] args) {

    System.out.println(ConstClass.HELLOWORLD);

  }
}

 

这种调用方式不会触发ConstClass的初始化,因为常量传播优化,常量“hello world”已经被存储到了NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用实际上都被转化为NotInitialization对自身常量池的引用。

然后在初识化阶段我们重点掌握的知识就是类构造器<clinit>()了。

这个东西我也只是提几点重要的: 
1.<clinit>()是编译器自动收集类中的所有类变量的赋值动作和静态语句块合并产生的。 
2.父类中定义的静态语句块要优先于子类的变量赋值操作。 
3.虚拟机保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步。


(3)初始化
对类的静态变量,静态代码块执行初始化操作 a=3;

初始化是类加载的最后一步,在之前的几步里,只有“加载”阶段用户可以自定义类加载器参与其中,后面都是由JVM主导控制的,直到初始化开始,JVM才开始真正执行Java程序代码,将主导权移交给应用程序。

    大体来说,初始化就是执行类构造器的<clinit>()方法,此方法并不是coder直接编写的,而是由编译器自动收集类中所有类变量赋值动作和静态语句块(static块)合并产生,收集的顺序是其在源文件中出现的顺序。

    静态语句块只能访问到定义在它之前的变量,对于定义在其后的边量,只能进行赋值,但不能访问。

复制代码
public class Test {
    static {
        i = 0;    //给变量赋值可以正常编译   
        System.out.println(i);    //这句话会提示非法向前引用
    }
    static int i = 1;
}
复制代码

 

    JVM会保证在子类<clinit>()方法执行前,父类的<clinit>()方法先执行完毕,所以第一个被执行的<clinit>()方法是java.lang.Object类的<clinit>()方法,因此,父类中的静态代码块会优于子类的静态代码块执行。

    如过一个类没有静态代码块,也没有变量赋值操作,则编译器可以不生成这个类的<clinit>()方法

    接口中不能使用静态代码块,但可以由变量赋值,但执行接口的<clinit>()方法前不需要先执行父接口的<clinit>()方法,只有在父接口被使用时,父接口才会初始化。

    接口的实现类在初始化时也一样不会执行接口的<clinit>()方法

    JVM会对一个类的<clinit>()方法进行加锁同步,保证如果由多个线程初始化一个类,那么只有一个线程会执行<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。

额外补充:

解析和初始化的位置是可以互换的,如果解析一旦在初始化之后开始,这就是我们经常所说的“动态绑定”~~

除此之外,这些阶段通常都是互相交叉的混合式进行,各个阶段只保证按部就班的开始,并不保证按部就班的进行或完成。

 

  

类的卸载

在类使用完之后,满足下面的情形,会被卸载:

1. 该类在堆中的所有实例都已被回收,即在堆中不存在该类的实例对象。

2. 加载该类的classLoader已经被回收。

3. 该类对应的Class对象没有任何地方可以被引用,通过反射访问不到该Class对象。

 

如果类满足卸载条件,JVM就在GC的时候,对类进行卸载,即在方法区清除类的信息。

 

运行时数据区解释:https://www.cnblogs.com/lanqingzhou/p/12374544.html

posted on 2020-02-27 20:58  pipizhou  阅读(145)  评论(0编辑  收藏  举报