深入Java虚拟机(1):Java类的生命周期和加载机制

       关于这块java基础知识内容在网上一大片,但是很多笔者是生搬基本的概念陈述出来,很少有自己的体会,让初学者甚至有一些工作经验的同学来说都不够深刻理解,我个人学习的方式就是:尽量用实际的例子去理解,而不是干巴巴记背基础的概念内容,这样对知识的学习永远只会停留在表面,无法深入理解更无法扩展应用到其他方面,另外我在看别人的博客的时候,也很少感觉到作者能通过文字把读者带入一起思考,一起深入体会,这是本人的第二篇博客,我会尽可能的带入你和我一起学习我分享的内容,另外有出入的地方欢迎大家评论反馈!

       注:本文相关图片资源来源均在文尾给出参考文献来由

先来一个简单的例子:

public class SupClass {
    static {
        System.out.println("SupClass init...");
    }

    static int value = 2;

    public SupClass(){
        System.out.println("SupClass contruct excute...");
    }
}


public class SubClass extends SupClass {

    static {
        System.out.println("SupClass init...");
    }

    public SubClass(){
        System.out.println("SubClass contruct excute...");
    }

    static int subValue = 1;
}

主函数:直接控制台输出

public class test {
    public static void main(String[] args) {
       System.out.println(SubClass.value);
    }
}
public class test {
    public static void main(String[] args) {
       System.out.println(SubClass.subValue);
    }
}
public class test {
    public static void main(String[] args) {
        new SubClass();
    }
}

请问三个main函数分别会输出什么?大家可能会直接根据背过的内容来套:先执行父类的静态代码块、静态变量的赋值、然后再执行子类的静态代码块、静态变量的赋值、然后执行父类的构造方法再是执行子类的构造方法,当遇到一些上面简单的例子来说是可以套对,可是很多人并没有真正深入透彻的了解里面的流程(当然没有深入到指令的执行顺序这一地步哈!),这也是我之前很长一段时间的的习方式和态度。

在这里先贴出输出的内容分别是:

第一种方式:
SupClass init...
2

第二种方式:

SupClass init...
SubClass init...
1

第三种方式:

SupClass init...
SubClass init...
SupClass contruct excute...
SubClass contruct excute...

是不是还是有些同学存在错误?第三种方式的输出类容是可以通过背上面说过的内容进行套对的,但是第一种和第二种又怎么解释呢?在这里我先不给出这个例子的流程讲解,我们先进入基础内容的陈述,在基础内容的陈述过程当中我会间接的给出一些关于这种例子所涉及的一些引导和提示,希望在这个期间读者们也能慢慢的体会到之前一直没有在意的地方。

类的生命周期


        这张图就是类的生命周期的过程,我们不单要熟悉整个生命周期的顺序,还得知道每个阶段具体做了什么事情(而很多同学最多在要准备面试了会背生命周期的顺序,全一点可能会背完每个阶段做的的事情,但是并不知道真正在干嘛),在讲述每个阶段的具体内容的时候我先说明加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定在图中的位置,某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定,另外大家要谨记的是上图顺序说明着前面的阶段完成后,后面的阶段才会出现。

加载

什么是加载,加载是在干嘛?

        加载是在编译之后,编译的时候就是将我们写的.java源码文件转为.class二进制文件(具体的编译阶段相关内容就不在这里陈述了),类加载概述就是将.class文件中二进制数据读入到内存当中,这里的内存就是指java的虚拟机内存中,jvm内存结构内容可以看我的另一篇博客JVM内存结构,详细的来说加载阶段主要做了三件事情:

  • 通过类的全限定名获取其定义的二进制字节流
  • 将二进制字节流所代表的静态存储结转化成方法区的运行时数据结构
  • 在jvm堆区生成Class对象,作为第二件事所生成的数据结构的访问入口

       该步的加载通常是使用系统提供的类加载器来完成加载的,具体的加载类和类加载机制会在下文给出,当然也可以自己写自定义的类加载器来进行这一步的加载操作,上面说了加载完之后在方法区会存储二进制字节流相关内容(就是指该类的一些属性、方法等描述内容),堆中创建了当前加载的类的一个Class对象,可以用这个对象进行对方法区的这些数据的访问,注意这也就是反射涉及相关的内容,通过获取类的类类型(也就是上面第三点做的事情在堆中生成的Class对象),来获取该类的字段、方法以及字段和方法的访问修饰符等等(也就是上面第二点做的事情存储在方法去的数据结构内容),每个类的类类型(Class)在虚拟机的堆中只会有一个,来源就是在这一步的加载的时候,也只会有存在一个,后面的内容会让大家知道为什么始终只有一个。

      什么时候会JVM会进行加载一个类呢?下面对初始化阶段的描述时会告诉大家!

验证

       主要作用是验证加载步骤中被加载的类的正确性,正确性的衡量标准就是加载进来的数据是否符合虚拟机的需求,不会对虚拟机造成安全问题,主要验证的东西有以下4种:

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

其实我是不想相详细列举每种验证的具体内容,我觉得知道了也意义不大,我们只要知道上面验证的作用简介就好了。

准备

       该阶段为类的静态变量(被static修饰的)分配内存,并且设置默认值,注意准备阶段比较重要,具体默认值就看类变量的数据类型是什么,java各种数据类型的默认值就不详细罗列,那上面例子中的SupClass中

static int value = 2;

来说,在准备阶段时value的值是为0,而在后面的初始化阶段的时候才会把value的值赋值为3,具体下文会介绍,这里有几点需要注意:

  • 在该阶段处理的是类变量,不包含实例变量,实例变量的初始化是在被构造实例的时候进行初始化的
  • 如果被static和final同时修饰的字段,那在准备阶段就会被设置成所指定的值(后面会有个例子反应出来)

解析

       该阶段时把类中的符号引用替换为直接引用,是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。

       直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化

       初始化阶段时是为类的静态变量赋指定的初始值,还有执行静态代码块里的内容,具体是如何做的呢?首先得知道有<cinit>()这么一个方法,其实初始化阶段就是在执行这个类构造器<cinit>()方法整个过程,该方法是由编译器在编译的时候收集类中的所有类变量的赋值动作和静态代码块static{}中的语句合并产生的,方法的内容顺序是.java源文件中声明的顺序所决定的,根据这个顺序就隐含细节的地方,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的静态变量是可以赋值的但是不能访问

public class SupClass {
    static {
        value = 3;  // 这里可以赋值,不会有问题
        System.out.println("SupClass init...");
        System.out.println(value); // 但是这里就会编译报错
    }

    static int value = 2;

    public SupClass(){
        System.out.println("SupClass contruct excute...");
    }
}

这里要介绍非常关键的内容:

1.初始化的时机:走完了解析阶段后,不是一定进行类的初始化,只有对类的主动使用的时候才会触发类的初始化,主动使用包括以下几种:

  • 在执行new创建类的实例时,初始化new指令的目标类
  • 如果初始化子类的时候,则会先对父类进行初始化 
  • 调用类的静态方法
  • 访问类或者接口的静态变量(开篇例子第一和第二种的输出跟这个时机有关,大家可以思考一下)
  • 利用反射机制的时候
  • Java虚拟机启动时,初始化用户指定的类
  • 如果接口定义了default方法(1.8以上高版本),直接或者间接实现该接口的类被初始化的情况下,会触发该接口初始化

2.在类被初始化阶段的时候,具体的步骤:

  • 如果还没进行过初始化之前的加载、验证准备,则会先进行前面的步骤,再进行初始化。(结合上面初始化的时机和这一点,相信大家也就明白了什么时候会进行类的加载了吧!?)
  • 如果当前要初始化的类有父类未被初始化,则会先初始化它的直接父类
  • 如果类中有初始化语句,则系统依次执行这些初始化语句

        后面的使用和卸载就不用说了,生产对象实例之后,对对象的字段和方法的访问等,上面介绍了几个阶段的内容,最起码要有初步的印象,对几个关键的阶段知道主要在做什么,对初始化的触发时机以及初始化步骤有总体的认知,看到这里,读者可以结合上面所讲的内容再去思考前面的例子,基本上可以明白整个过程了。

 

 

类的加载器和加载机制


类的加载器

      本文上半部分介绍了类的生命周期,生命周期的每个阶段主要的做的事情是什么,对于加载阶段,我们只介绍了做了什么,那么现在就来介绍类通过什么加载的---类的加载器,以及是如何加载的---加载机制。

上图为类加载器的种类以及它们的层级关系,上图层级关系不是指继承关系来实现的,而是组合关系。

       在Hotspot中,最上面一层的启动类加载器是使用C++实现的,其余的加载器均是java实现,并且全部继承抽象类java.lang.ClassLoader,并且都是有启动类加载器加载到内存当中后再去加载其他的类,在java开发角度来看,类加载器可以大致分三类:

启动类加载器:

        Bootstrap ClassLoader,负责加载存放在JDK\jre\lib下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。

扩展类加载器:

        Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.开头的类),开发者可以直接使用扩展类加载器。

应用程序加载器:

        Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

        我们的java项目都是通过这三类加载器相互配合加载的,这些是默认的加载器,在某些特殊的需求背景下可以自定义类加载器,这里不举例详细说了。

 

类的加载机制

双亲委派:

        如果某个类加载器收到类加载的请求, 该加载器不会立即进行加载操作,首先把加载请求委托给它上一层级的类加载器去加载,依次向上委托,当上层级的类加载器无法完成该次加载请求之后,下层级的加载器才会自己去尝试加载需要加载的类。(双亲委派的体现可以看源码ClassLoader的loadClass方法,下文也贴出来了,ClassNotFoundException就是从这里出来的)

全盘负责:

        当某个加载器要加载某个类的时候,该类中所依赖的和和应用的其他的还未被加载的类也会由当前类加载器进行加载,除非显示的说明由另外一个类加载器来加载。

缓存机制:

        缓存机制就是保存被类加载器加载进来的类都会被缓存,当程序需要使用到某个Class对象的时候,类加载器会先从缓存区中找看是否有,如果有的话就说明以及加载过了可以直接使用,如果没有会重新加载再存入到缓存区,这里就反映出我们刚学习项目开发的时候容易出现的问题,修改过的类没有更新,需要重新启动项目重启jvm后,修改的内容才会更新。

 

        也就是类的加载机制才保证了每个类在内存中的Class对象只有一个,这就回答了上文中说的内容,另外这里扩展说下在利用反射获取类的类类型(Class)时的其中两种方式:Class.forName("类全量名")和Class.loadClass(“类的全量名”)时的细微区别(可以通过阅读源码看出来):

        class.forName():会触发类的加载,根据方法里面的全量类名寻找,除此之后还会进行初始化阶段,因为该方法里面执行了forName0(String name, boolean initialize,ClassLoader loader,Class<?> caller),第二个initialize就是指定是否需要初始化,并且源码中是写死的true,大家可以写一个简单的例子就能直接的反映出来了。

Class.loadClass:只会出发类的加载,不会进行初始化

 

双亲委派代码:

protected Class<?> loadClass(String var1, boolean var2) throws ClassNotFoundException {
        synchronized(this.getClassLoadingLock(var1)) {
            // 首先判断该类型是否已经被加载
            Class var4 = this.findLoadedClass(var1);
            if(var4 == null) {
                long var5 = System.nanoTime();
                //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
                try {
                    if(this.parent != null) {
                        //如果存在父类加载器,就委派给父类加载器加载
                        var4 = this.parent.loadClass(var1, false);
                    } else {
                        /*如果不存在父类加载器,就检查是否是由启动类加载器加载的类*/
                        var4 = this.findBootstrapClassOrNull(var1);
                    }
                } catch (ClassNotFoundException var10) {
                    ;
                }

                if(var4 == null) {
                     // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
                    long var7 = System.nanoTime();
                    var4 = this.findClass(var1);
                    PerfCounter.getParentDelegationTime().addTime(var7 - var5);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(var7);
                    PerfCounter.getFindClasses().increment();
                }
            }

            if(var2) {
                this.resolveClass(var4);
            }

            return var4;
        }
    }

        到这里java类的生命周期和加载器以及加载机制就讲完啦,我们先解释文初给的例子,然后我们再做一个网上有关这块内容的例子来加深理解和印象:

例题解答:

第一种方式:首先明确静态代码块是在初始化阶段执行的,根据初始化阶段触发时机中的一点:访问类或者接口的静态变量,或者对类的静态变量进行赋值时会进行类的初始化,那么在SubClass.value的时候,其实是访问父类的静态变量,在此之前SubClass和父类SupClass只是进行了初始化之前的阶段,此时触发的是父类的初始化而SubClass并没有被触发初始化阶段,也就是对应的输出原因。

第二种方式:同样根据初始化阶段触发时机中的一点:访问类或者接口的静态变量,或者对类的静态变量进行赋值时会进行类的初始化,那么在SubClass.subValue的时候,是访问本类的静态变量,在此之前SubClass和父类SupClass只是进行了初始化之前的阶段,但是上文着重提到初始化阶段时,如果父类未被初始化的话,会先初始化父类,也就是对应的输出原因。

第三种方式:new SubClass实例化,在执行new的创建类的实例时这个时候会触发初始化,并且是先初始化父类的,所以先是父类进行初始化,再是本类进行初始化,再是对应的父类和子类的构造。

 

扩展例题:

public class StaticTest {
    public static void main(String[] args)
    {
        staticFunction();
    }

    static StaticTest st = new StaticTest();

    static
    {
        System.out.println("1");
    }

    {// 非静态初始化块
        System.out.println("2");
    }

    StaticTest()
    {
        System.out.println("3");
        System.out.println("a="+a+",b="+b + ",c=" + c);
    }

    public static void staticFunction(){
        System.out.println("b=" + b);
    }

    int a=110;
    static int b =112;
    static final int c = 113;
}

        大家可以先仔细结合本文前后讲述的类的生命周期的过程以及每个阶段做的事情,再捋一捋整个过程,应该可以正确给出输出结果,如果还是有问题,可以通过解答再回顾和理一遍整个过程。

输出结果:

2
3
a=110,b=0,c=113
1
4

解答:

首先项目启动之后,调用了类的静态main方法,JVM会尝试去初始化该类,但是初始化之前的步骤还没进行,这个该类就正常进行加载、验证、准备、解析以及到了初始化阶段,在初始化内容那一块我有提到初始化要做的就是给静态变量赋值指定的初始值,以及执行静态代码块,并且他们的执行顺序是按照.java源文件中声明的顺序进行的初始化,那么此时初始化阶段先是给st这个静态变量进行赋值,这里是new StaticTest实例化,构造实例时会先执行非静态代码块,给非静态变量初始化,那么此时就是先输出非静态代码块中的2,然后初始化非静态变量a的值为110,接着就是执行构造方法也就是输出3,然后输出a=110,b=0,c=113,a的值没问题,前面说过了,b为静态变量,而此时还在初始化阶段并且还没执行到给b进行赋值指定值,那依然还是准备阶段的时候给b设置的默认值0,所以在这里输出b的值为0,而c的值为final修饰的静态变量,那么在准备阶段的时候就已经设置指定的值了,这些都是前文介绍的内容,所以c的值在初始化阶段的时候就已经是指定的值了,st静态变量赋值完成后,继续初始化到了执行静态代码块输1,以及给b赋值指定值112,初始化结束后然后到进入main方法调用静态方法最后输出b=112,这也就是这例子的所有的加载和执行过程。

       Java类的生命周期和加载机制相关内容到这里就结束啦,希望本篇文章对大家有所帮助,也相信大家对这部分类容有了比较全面深刻的了解啦,博主还有其他的文章供大家阅读,并且我也会持续更新与IT相关的知识,一起加油!

 

参考文献:

1.http://www.ityouknow.com/jvm/2017/08/19/class-loading-principle.html

2.https://blog.csdn.net/harvic880925/article/details/50072739

上面两个地址都是两位大牛博主,有很多好文章,大家可以去学习学习

 

 

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值