• 有关java类、对象初始化的话题,从一道面试题切入


    转载请注明出处:https://www.cnblogs.com/christmad/p/9747187.htm

    最近在整理东西时,刚好碰到以前看的一道有关java类、对象初始化相关题目,觉得答案并不是非常好(记忆点比较差,不是很连贯)。加上刚好复习完类加载全过程的五个阶段(加载-验证-准备-解析-初始化),所以如果周志明大大诚不我欺的话,无论是类加载过程、还是实例化过程的顺序我都已经了然于心了才对。

    一道面试题

    标题:2015携程JAVA工程师笔试题(基础却又没多少人做对的面向对象面试题)

    地址:https://zhuanlan.zhihu.com/p/25746159

    该题代码如下:

    public class Base {
    
    
        private String baseName = "base";
    
        public Base() {
            callName();
        }
    
        public void callName() {
            System.out.println(baseName);
        }
    
        static class Sub extends Base {
            private String baseName = "sub";
    
            public void callName() {
                System.out.println(baseName);
            }
        }
    
        public static void main(String[] args) {
            Base b = new Sub();
        }
    
    }
    

    问题:main函数运行后输出什么?

    关于类初始化、实例化的问题该如何分析

    输出base?sub?还是null?

    这个题从第一次搜集,到复习,两遍......在不看答案的情况下愣是没想起来......好在COPY AND PASTE工程师有一样法宝就是“能run 的先 run一遍再说”。憋着不看答案,run完之后,结果是null。不是很震惊,因为如果是base或sub的话,这道题的层次就不会很高。

    嗯,那为什么是null呢?看一下原链接给的提示(文末总结)是什么:

    看完之后,一口老血吐了出来,错别字先不提,主要是记忆点比较模糊,像这种“父类静态块 ->子类静态块 ->父类初始化语句”,还有这种“父类构造函器 ->子类初始化语句 子类构造器”,怎么记?死记硬背吗?但是顺序是没有错的,每个位置的表达上还是有些问题。

    看得出来答主是按照本题的代码进行分析,最后我也会给出一个执行顺序和原文总结中顺序一致的代码出来,其实就是把静态语句块(static{}),实例初始化块({}),Sub类的无参构造器补齐之后(同时调整一下类的位置)观察它们的输出顺序的结果。

    整理概念

    工欲善其事必先利其器,这里有必要把一些概念理清楚。

    (1)类初始化阶段,和 实例初始化

    因为类加载有“初始化”阶段,而实例初始化也常被工程师们叫做“初始化”,因此,为了区分这两个“初始化”,类加载过程的初始化我叫它“类初始化阶段”,实例初始化过程我叫它“实例初始化”(不用简称,免得混乱)。

    先看下类加载的初始化阶段到底是什么:

    上图是《深入java虚拟机》周志明大大的第二版书,第7章第2节内容的第一张图。这7个阶段中和程序员比较有关系的是loading、preparation和initialization。后面会讲到。

    类的初始化阶段是其生命周期中第5个阶段。让我们看看这个阶段干了些什么:初始化阶段是执行类构造器<cinit>()方法的过程。而<cinit>()方法是由编译器自动收集的类中所有类变量的赋值动作(static变量,且必须有赋值动作的语句)和静态语句块(static{})后合并产生的。更直白的说法就是<cinit>()方法是由static赋值动作加上static{}块合并而成的,它只针对静态变量(前面标蓝字体的原因)。收集的顺序就是你的static语句和static{}块的书写顺序。

    (2)静态语句块(static{}),和 实例初始化块({})

    原题代码中没有书写static{} 也没有 {} 。后面我的补充代码会展示出来。

    (3)类加载,和 类初始化

    所谓的类加载应该指的是类生命周期中的前五个阶段,从loading到initialization。在《深入java虚拟机》书中7.2节提到的五个必须进行“初始化”的情况中就包括“先初始化父类”这一条。通常在泛指的时候,可以把“类加载”和“类初始化”对等起来,它们表示类声明周期的前五个阶段,这样在口头表述的时候比较便利。

    分析题目

    将类初始化阶段和实例初始化这两个概念区分开后,再分析这个题目。

    main函数中的代码非常简单:Base b = new Sub(); 。但是注意,这行代码是在Base.java文件的main方法中,而运行main函数,JVM首先要加载这个main函数所在的类,即运行这行代码时JVM会先加载Base到方法区中,可能会对加载顺序的判断有一丢丢影响(所以后面我重写了代码,Base和Sub放在Xase中,让Xase类运行main方法)。

    好,绕了一点弯路,重新回到题目上,前面提到最近刚好复习了类加载过程的五个阶段(生命周期中到“使用”之前的那五个阶段),不知道书上有没有提到实例初始化过程?这个阶段涉及到如本题中main函数所示的代码如何初始化(b的静态类型是Base,实际类型是Sub),不过其实也就是子类覆盖父类方法如何执行的问题,剩下的那些实例变量和实例初始化块执行顺序都是很容易理解的。

    经过自己debug(原文中也提到)也能发现Sub类覆盖Base的callName后实际运行时该方法的指针是指向Sub的callName方法的(debug时将看到执行Base构造函数中的callName时会跳转到Sub的callName方法上)。所以到这里可以“假装”我们也掌握了实例初始化相关的知识点。

    有了以上两个知识点,开始分析代码,在main函数中执行代码Base b = new Sub();  JVM会按顺序将 Base、Sub类的class文件加载到方法区(完成生命周期的前五个阶段)。由于原文中main方法在Base中,所以Base已经加载过,所以执行这段代码时,只是加载Sub的class文件(后面加载类简称加载)。JVM如果要加载Sub类,要先保证它的父类Base被加载。因此无论是代码顺序保证的,还是类加载机制保证的,Base类都会先于Sub类被加载到方法区。

    (1)加载类阶段

    好了,目前为止Base和Sub都加载完了,由类加载机制保证父类先加载,所以 父类的类初始化 先于 子类的类初始化 完成。

    即,实例初始化前,有 父类的类初始化 -> 子类的类初始化

    前面说过,<cinit>()方法的执行是按 有赋值动作static语句 和 static语句块 的书写顺序执行的,经过类的类初始化阶段后,每个类的类变量都已经初始化完成。

    本题中没有类变量,但学习时我们可以自己加上,后面我会再贴出另一道类初始化和实例化相关的题,该题中你可以认识到 static语句的赋值操作语句 和 非赋值操作语句 是如何被<cinit>()收集的(你可以在每个static语句上都打上断点,了解为什么书里介绍<cinit>()时说的是“只搜集 类变量的赋值操作”)。

    (2)实例化阶段

    接下来执行 new Sub();

    由于Sub没有定义构造函数,JVM生成一个默认构造函数(无参构造函数)。子类的任意构造函数中都有一个隐式的super()来调用父类的默认构造函数,保证继承的实例字段正确初始化。如果父类自己写了带参的构造函数,子类构造函数要明确指定调用一个父类的构造函数。

    这里,两个类都是无参构造函数,所以Sub的默认构造函数可以调用到Base的无参构造函数

    到这里可以明确的关系有:父类的类初始化 -> 子类的类初始化 ->  ...... -> 父类构造函数 -> ...... -> 子类构造函数

    你会看到每次推导出来的顺序阶段都加上蓝色字体,便于比较

    类变量的初始化过程上面介绍过,叫做 <cinit>()方法,通过 debug static语句可以看到debug栈的提示。<cinit>()方法是按static书写顺序执行的。那么实例初始化过程呢?其实实例初始化也对应一个<init>()方法,它应该(还没看到对应的资料)也是类似的执行过程,作用是帮我们初始化类的实例变量(搜集 实例变量赋值操作 和 实例初始化块)。

    因为 <cinit>()方法只是我们广义上说的 类初始化 的第5个阶段 initialization,因此在这里并不想用<cinit>()去替换"类初始化“

    那么 <init>()方法 和 构造函数 执行顺序如何?把 构造函数 放在 {}块和实例变量赋值操作 的前面,通过 debug 可以确定的关系:init()方法 -> 构造函数。

    那么 <inti>()方法中 实例初始化块{} 以及 实例变量赋值操作 的调用顺序是怎样的?还是通过 debug,可以知道顺序为: 和 <cinit>()方法类似,是按书写的顺序,并且只搜集有赋值操作的语句。如 private int a = 2; 会搜集。 private int b; 则不搜集。

    接下来请允许我用 <init>()方法 来表示 “实例初始化块{} 以及 实例变量赋值操作 ”。它看起来比 <cinit>()更能表达一个阶段。

    因此,目前可以确定的调用顺序为(加上标号):①父类的类初始化 -> 子类的类初始化 ->  父类的<init>()方法 -> ④父类构造函数 -> 子类的<init>()方法 -> 子类构造函数

    前面分析到执行Sub构造函数之前,会先调用Base的构造函数,在Base构造函数中调用前, Base的<init>()方法已经执行完,所以baseName = "base" 。

    查阅《深入java虚拟机》7.3.3节,在 准备阶段(对应上述①),生命周期的第3个阶段 会为 类变量 赋“零值”,但是 baseName的定义不是 类变量,只是普通的实例变量,可以套用这个规则吗?

    既然<cinit>()方法是修改 类变量的零值,<init>()方法应该相应的会修改 实例变量的零值。实例变量在堆上分配时应该也有零值(道理应该是这样,JVM设计不会搞这么复杂,两种变量还搞两套零值,通过debug也能看到两种变量零值是一样的)。baseName为 String类型对应 reference类型,所以它的零值为null(其他例如 int零值为 0,long零值为 0L),执行完<init>()方法后(对应上述③),baseName = "base"。

    继续分析,Base的构造函数(对应上述④)又调用了 callName方法,由于Sub类重写了Base类的 callName,所以 debug时可以看到跳转到Sub的 callName中,此时,程序只运行到上述④的阶段,Sub类的 <init>()方法 和 Sub类的构造函数(对应上述⑤ 和 ⑥)都没有运行到。根据 上一段的分析可知,此时 Sub的实例变量 baseName只有零值 null,因此 callName() 执行后将输出 null(也是本题的结果)。

    接着,Base构造函数调用结束,进入上述 ⑤ 和 ⑥ 的执行过程。执行完 ⑤ 后 Sub的 baseName = "sub"。⑥ 过程没有执行任何东西,整个过程就结束了。

    总结

    通过这道面试题的学习,你应该了解到,new ClassName(); 其实分两个大的阶段,第一个是先加载类(五个阶段)到方法区,对应上述①和②两个阶段;第二个才是使用加载好的class对象创建实例(实例初始化过程),对应上述③~⑥ 四个阶段。

    最后调整过后的代码如下:

    /**
     * https://zhuanlan.zhihu.com/p/25746159
     * 2015携程JAVA工程师笔试题(基础却又没多少人做对的面向对象面试题)
     */
    public class Xase {
    
        {
            System.out.println("I'm Xase {}");
        }
    
        static {
            System.out.println("I'm Xase static {}");
        }
    
        static class Base {
    
            public Base() {
                System.out.println("Base constructor");
                callName();
            }
    
            {
                // base的实例初始化块
                System.out.println("BASE {}");
            }
            private String baseName = "base";
    
            static {
                // base的静态语句块
                System.out.println("Base static {}");
            }
    
            public void callName() {
                System.out.println(baseName);
            }
        }
    
        static class Sub extends Base {
            private String baseName = "sub";
    
            public Sub() {
                System.out.println("Sub constructor");
            }
    
            {
                System.out.println("Sub {}");
                b = 3;
            }
            private int a = 2;
            private int b;
    
            static {
                // sub的静态语句块
                System.out.println("Sub static {}");
            }
    
            public void callName() {
                System.out.println(baseName);
            }
    
        }
    
        public static void main(String[] args) {
            Base b = new Sub();
        }
    
    }
    

    Xase包裹了Base和Sub,作为一道面试题“拥挤”一点也不是什么毛病。其中main方法在上述代码中放在Xase类中,如果你不想显示Xase的静态代码块的打印,可以把main方法挪到Sub方法里。

    debug时,在每一个 {} ,static{} ,构造函数,实例变量 和 类变量上都打上断点,就可以清楚观察到对象实例化时各个代码调用的顺序。

    附我的Xase代码输出:

    最后的最后

    说好的附加题,http://www.cnblogs.com/javaee6/p/3714716.html

    这道题中可以直接应用《深入理解java虚拟机》7.3.3节的零值规则,大不了不对还可以吐槽一下周大大。分析的过程也相对少一些(比开头那题少40%~50%分析量吧)。

    掌握了真正的分析方法后,这种类型的题已经不是什么问题。

    其实,要全面一点的话,内部类那一块的初始化也应该练一练,但是之前没有找到好的题目,如果大家有好的题目可以实战,欢迎在评论区砸我~哈哈

    转载请注明出处:https://www.cnblogs.com/christmad/p/9747187.htm

  • 相关阅读:
    5月26号
    5.17 Quartz笔记
    5.23Java各种对象(PO,BO,VO,DTO,POJO,DAO,Entity,JavaBean,JavaBeans)的区分
    5.23@Comfiguration的解释
    5月20号
    5.20
    java与C++不同之处(java不支持特性)
    递归算法之回溯
    0119
    linux打包及备份指令
  • 原文地址:https://www.cnblogs.com/christmad/p/9747187.html
Copyright © 2020-2023  润新知