zoukankan      html  css  js  c++  java
  • JVM--详解类加载机制

    Java虚拟机的体系结构
    前面我们探讨了Class文件的结构,如果你还没有学习,将不利于这部分知识的吸收与掌握,所以请移步:JVM–Class类文件结构(一)

    学习一个东西之前,我们务必要知道,这东西大概是干什么的,有什么作用。

    为了更清楚的阐释类加载机制到底是干什么的,我先将JVM的结构图贴给大家:

    如上图,我们要学的类加载机制就是要搞清楚类加载器是如何找到指定的Class文件以及怎样将Class文件装载进内存,以便执行引擎执行Class文件中存在的数据和指令,从而使你的Java程序跑起来。

    上面的黑体字就是这玩意大概是干啥的,至于学习它有什么作用,有助于你了解Java源代码是怎么从一个普通的文件变成一个可以正在运行的程序这其中的过程。而且,学习了这部分知识,你再回过头看反射机制,会有一种醍醐灌顶的感觉。


     类的生命周期

    先来看一下类的生命周期吧:

    结合上图,类加载机制主要学习加载、验证、准备、解析、初识化这些过程,然后就是需要了解真正可以将类加载进内存的一个玩意(还是代码实现)—类加载器!

    其实,有了前面Class文件结构的基础,这些东西都很简单,不要怕~

    额外补充:

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

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


    类加载的过程

    我们根据上面所说的类的生命周期来一点点剖析类的加载过程。

    加载

    我们首先要明白一件事情:什么开始进行类加载过程的第一阶段:加载?Java虚拟机没有进行强制约束,交由虚拟机的具体实现自由把握。

    看完上面的话,我们来看在加载阶段,虚拟机需要完成哪些事情:

    我们需要注意一些事情:

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

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


     验证

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

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

    我不在这里详细的说明每一阶段的校验主要干了什么事情,有兴趣的同学可以自行百度。

    挑点重点来说吧,对字节流进行校验是由一个叫做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属性。

    public static int value = 123;

    此时在准备阶段过后的初始值为0而不是123;将value赋值为123的putstatic指令是程序被编译后,存放于类构造器<client>方法之中


     解析

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


     初始化

    初始化阶段是执行类构造器<client>方法的过程。<client>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证<client>方法执行之前,父类的<client>方法已经执行完毕。如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法。

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

    • 使用new关键字实例化对象、访问或者设置一个类的静态字段(被final修饰、编译器优化时已经放入常量池的例外)、调用类方法,都会初始化该静态字段或者静态方法所在的类。
    • 初始化类的时候,如果其父类没有被初始化过,则要先触发其父类初始化。
    • 使用java.lang.reflect包的方法进行反射调用的时候,如果类没有被初始化,则要先初始化。
    • 虚拟机启动时,用户会先初始化要执行的主类(含有main)
    • jdk 1.7后,如果java.lang.invoke.MethodHandle的实例最后对应的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法所在类没有初始化,则先初始化。

    额外补充:

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

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

    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>()方法在多线程环境中被正确的加锁、同步。


    方法区使用实例

    上面讲了那么多,不知道大家吸收了没有,学习的过程中一定要学会总结和抓中重点哦~

    咱们来看一个例子将上面类加载的过程来串一下吧,加深一下自己的印象:(其中还涉及了一点执行引擎的知识,没关系,很容易理解)

    class Lava {
    
        private int speed = 5;
        void flow() {
    
        }
    }
    
    public class Volcano {
    
        public static void main(String[] args) {
            Lava lava = new Lava();
            lava.flow();
        }
    }

    不同的虚拟机实现可能会用完全不同的方法来操作,下面描述的只是其中一种可能——但并不是仅有的一种。

    加载:读取一个类的class文件并将其中的二进制字节流组织成正确的数据结构放进运行时方法区中:

    main()的第一条指令告知虚拟机为列在常量池第一项的类分配足够的内存。所以虚拟机使用指向Volcano常量池的指针找到第一项,发现它是一个对Lava类的符号引用,然后它就检查方法区,看Lava类是否已经被加载了。

    这个符号引用仅仅是一个给出了类Lava的全限定名“Lava”的字符串。为了能让虚拟机尽可能快地从一个名称找到类,虚拟机的设计者应当选择最佳的数据结构和算法。

    当虚拟机发现还没有装载过名为“Lava”的类时,它就开始查找并装载文件“Lava.class”,并把从读入的二进制数据中提取的类型信息放在方法区中。
    解析:

    终于,虚拟机准备为一个新的Lava对象分配内存。此时它又需要方法区中的信息。还记得刚刚放到Volcano类常量池第一项的指针吗?现在虚拟机用它来访问Lava类型信息,找出其中记录的这样一条信息:一个Lava对象需要分配多少堆空间。

    JAVA虚拟机总能够通过存储在方法区的类型信息来确定一个对象需要多少内存,当JAVA虚拟机确定了一个Lava对象的大小后,它就在堆上分配这么大的空间,并把这个对象实例的变量speed初始化为默认初始值0。

    当把新生成的Lava对象的引用压到栈中,main()方法的第一条指令也完成了。接下来的指令通过这个引用调用Java代码(该代码把speed变量初始化为正确初始值5)。另一条指令将用这个引用调用Lava对象引用的flow()方法。


     类加载器

     其实这一部分的知识并不多,你需要了解、掌握的知识只有两点:

     说一点啊,看到这些高大上的名词你们不要怕,又不让你拿代码去实现,其实其中的原理都是很简单的。

    上面说了那么多,类加载器就是用于实现类加载动作的一段代码实现。好了,明白了它的作用,我们来看看什么是它命名空间。

    类加载器的命名空间:对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。也就是说,你现在要比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才有意义。

    这就是类加载器的命名空间,不难吧~但是读完上面这段话我们可以提取出另一个重要的信息:你上面所说的话中好像包含这样一种意思:类加载器在JVM中不止一个?你很聪明!好了,接下来说明何谓双亲委派模型。

    双亲委派模型:首先你得知道在JVM中有三种系统提供的类加载器:启动类加载器,扩展类加载器、应用程序类加载器。关于这三种加载器的描述大家自行百度,这也不是重点。

    好了,为了让大家印象深刻,我在给大家描述一下双亲委派模型的工作过程吧:

    那么使用这种模型有什么好处?

    Java类随着类加载器一起具备了带有优先级的层级关系。例如java.lang.Object,在程序的各种类加载器环境中都是同一个类。

    关于双亲委派模型的实现代码非常简单,我就不再贴出,有兴趣的同学可以研读《深入理解Java虚拟机》第二版中232页的内容。

    原文连接:https://blog.csdn.net/championhengyi/article/details/78680700

  • 相关阅读:
    (转)轻松应对IDC机房带宽突然暴涨问题
    (转)老男孩:Linux企业运维人员最常用150个命令汇总
    (转)cut命令详解
    (转)Awk使用案例总结(运维必会)
    (转)Nmap命令的29个实用范例
    BigPipe学习研究
    js正则大扫除
    ffff表单提交的那点事
    高并发订单操作处理方法
    订单号的处理
  • 原文地址:https://www.cnblogs.com/blwy-zmh/p/11852916.html
Copyright © 2011-2022 走看看