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

最近在整理东西时,刚好碰到以前看的一道有关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%分析量吧)。

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

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

猜你喜欢

转载自www.cnblogs.com/christmad/p/9747187.html