关于类的对象建立与初始化

今天,咱们就来解决一个问题,一个类实例究竟要通过多少个步骤才能被建立出来,也就是下面这行代码的背后,JVM 作了哪些事情?java

Object obj = new Object();git

当虚拟机接受到一条 new 指令时,首先会拿指令后的参数,也就是咱们类的符号引用,于方法区中进行检查,看是否该类已经被加载,若是没有则须要先进行该类的加载操做。程序员

一旦该类已经被加载,那么虚拟机会根据类型信息在堆中分配该类对象所须要的内存空间,而后返回该对象在堆中的引用地址。github

通常而言,虚拟机会在 new 指令执行结束后,显式调用该类的对象的 方法,这个方法须要程序员在定义类的时候给出,不然编译器将在编译期间添加一个空方法体的 方法。面试

以上步骤完成后,基本上一个类的实例对象就算是被建立完成了,才可以为咱们程序中使用,下面咱们详细的了解每一个步骤的细节之处。bash

初始化父类

知乎上看到一个问题:微信

Java中,建立子类对象时,父类对象会也被一块儿建立么?函数

有关这个问题,我还特地去搜了一下,不少人都说,一个子类对象的建立,会对应一个父类对象的建立,而且这个子类对象会保存这个父类对象的引用以便访问父类对象中各项信息布局

这个答案确定是不对的,若是每个子类对象的建立都要建立其全部直接或间接的父类对象,那么整个堆空间岂不是充斥着大量重复的对象?这种内存空间的使用效率也会很低。this

我猜这样的误解来源于 《Thinking In Java》 中的一句话,可能你们误解了这段话,原话不少很抽象,我简单总结了下:

虚拟机保证一个类实例初始化以前,其直接父类或间接父类的初始化过程执行结束

看一段代码:

public class Father {
    public Father(){
        System.out.println("father's constructor has been called....");
    }
}
复制代码
public class Son extends Father {
    public Son(){
        System.out.println("son's constructor has been called ...");
    }
}
复制代码
public static void main(String[] args){
    Son son = new Son();
}
复制代码

输出结果:

father's constructor has been called.... son's constructor has been called ...
复制代码

这里说的很明白,只是保证父类的初始化动做先执行,并无说必定会建立一个父类对象引用。

这里不少人会有疑惑,虚拟机保证子类对象的初始化操做以前,先完成父类的初始化动做,那么若是没有建立父类对象,父类的初始化动做操做的对象是谁?

这就涉及到对象的内存布局,一个对象在堆中究竟由哪些部分组成?

HotSpot 虚拟机中,一个对象在内存中的布局由三个区域组成:对象头,实例数据,对齐填充。

对象头中保存了两部份内容,其一是自身运行的相关信息,例如:对象哈希码,分代年龄,锁信息等。其二是一个指向方法区类型信息的引用。

对象实例数据中存储的才是一个对象内部数据,程序中定义的全部字段,以及从父类继承而来的字段都会被记录保存。

像这样:

image

固然,这里父类的成员方法和属性必须是能够被子类继承的,没法继承的属性和方法天然是不会出如今子类实例对象中了。

粗糙点来讲,咱们父类的初始化动做指的就是,调用父类的 方法,以及实例代码块,完成对继承而来的父类成员属性的初始化过程。

对齐填充其实也没什么实际的含义,只是起到一个占位符的做用,由于 HotSpot 虚拟机要求对象的大小是 8 的整数倍,若是对象的大小不足 8 的整数倍时,会使用对齐填充进行补全。

因此不存在说,一个子类对象中会包含其全部父类的实例引用,只不过继承了可继承的全部属性及方法,而所谓的「父类初始化」动做,其实就是对父类 方法的调用而已。

this 与 super 关键字

this 关键字表明着当前对象,它只能使用在类的内部,经过它能够显式的调用同一个类下的其余方法,例如:

public class Son {

    public void sayHello(){
        System.out.println("hello");
    }
    public void introduce(String name){
        System.out.println("my name is:" + name);

        this.sayHello();
    }
}
复制代码

由于每个方法的调用都必须有一个调用者,不管你是类方法,或是一个实例方法,因此理论上,即使在同一个类下,调用另外一个方法也是须要指定调用者的,就像这里使用 this 来调用 sayHello 方法同样。

而且编译器容许咱们在调用同类的其余实例方法时,省略 this。

其实每一个实例方法在调用的时候都默认会传入一个当前实例的引用,这个值最终被传递赋值给变量 this。例如咱们在主函数中调用一个 sayHello 方法:

public static void main(String[] args){
    Son son = new Son();
    son.sayHello();
}
复制代码

咱们反编译主函数所在的类:

image

字节码指令第七行,astore_1 将第四行返回的 Son 实例引用存入局部变量表,aload_1 加载该实例引用到操做数栈。

接着,invokevirtual #4 会调用一个虚方法(也就是一个实例方法),该方法的符号引用为常量池第四项,除此以外,编译器还会将操做数栈顶的当前实例引用做为方法的一个参数传入。

image

能够看到,sayHello 方法的局部变量表中的 this 的值 就是方法调用时隐式传入的。这样你在一个实例方法中不加 this 的调用其余任意实例方法,其实调用的都是同一个实例的其余方法。

总的来讲,对于关键字 this 的理解,只须要抓住一个关键点就好:它表明的是当前类实例,而且每一个非静态方法的调用都一定会传入当前的实例对象,而被调用的方法默认会用一个名为 this 的变量进行接收。

这样作的惟一目的是,实例方法是能够访问实例属性的,也就是说实例方法是能够修改实例属性数据值的,因此任何的实例方法调用都须要给定一个实例对象,不然这些方法将不知道读写哪一个对象的属性值。

那么 super 关键字又表明着谁,可以用来作什么呢?

咱们说了,一个实例对象的建立是不会建立其父类对象的,而是直接继承的父类可继承的字段,大体的对象内存布局以下:

image

this 关键字能够引用到当前实例对象的全部信息,而 super 则只能引用从直接父类那继承来的成员信息。

看一段代码:

public class Father {
    public String name = "father";
}
复制代码
public class Son extends Father{
    public String name = "son";
    public void showName(){
        System.out.println(super.name);
        System.out.println(this.name);
    }
}
复制代码

主函数中调用这个 showName 方法,输出结果以下:

father
son
复制代码

应该不难理解,不管是 this.name 或是 super.name 它们对应的字节码指令是同样的,只是参数不一样而已。而这个参数,编译器又是如何肯定的呢?

若是是 this,编译器优先从当前类实例中查找匹配的属性字段,没有找到的话将递归向父类中继续查询。而若是是 super 的话,将直接从父类开始查找匹配的字段属性,没有找到的话同样会递归向上继续查询。

完整的初始化过程

下面咱们以两道面试题,加深一下对于对象的建立与初始化的相关细节理解。

面试题一:

public class A {
    static {
        System.out.println("1");
    }
    public A(){
        System.out.println("2");
    }
}
复制代码
public class B extends A {
    static{
        System.out.println("a");
    }
    public B(){
        System.out.println("b");
    }
}
复制代码

Main 函数调用:

public static void main(String[] args){
    A ab = new B();
    ab = new B();
}
复制代码

你们不妨能够思考一下,最终的输出结果是什么。

输出结果以下:

1
a
2
b
2
b
复制代码

咱们来解释一下,第一条语句:

A ab = new B();

首先发现类 A 并无被加载,因而进行 A 的类加载过程,类加载的最后阶段,初始化阶段会调用编译器生成的 方法,完成类中全部静态属性的赋值操做,包括静态块的代码执行。因而打印字符「1」。

紧接着会去加载类 B,一样的过程,打印了字符「a」。

最后调用 new 指令,于堆上分配内存,并开始实例初始化操做,调用自身构造器以前会首先调用一下父类 A 的构造器保证对 A 的初始化,因而打印了字符「2」,接着调用字节的构造器,打印字符「b」。

至此,第一条语句算是执行结束了。

第二条语句:

ab = new B();

因为类型 B 已经被加载进方法区了,虚拟机不会重复加载,直接进入实例化的过程,一样的过程,分别打印字符「2」和「b」。

这一道题目应该算简单的,只要理解了类加载过程当中的初始化过程和实例对象的初始化过程,应该是手到擒来。

面试题二:

public class X {
    Y y = new Y();
    public X(){
        System.out.println("X");
    }
}
复制代码
public class Y {
    public Y(){
        System.out.println("Y");
    }
}
复制代码
public class Z extends X {
    Y y  = new Y();
    public Z(){
        System.out.println("Z");
    }
}
复制代码

Main 函数调用:

public static void main(String[] args){
    new Z();
}
复制代码

一样的,你们能够先自行分析分析运行的结果是什么。

输出结果以下:

Y
X
Y
Z
复制代码

咱们一块儿来分析一下,首先这个主函数中的代码很简单,就是实例化一个 Z 类型的对象,虚拟机同样的会先进行 Z 的类加载过程。

发现并无静态语句须要执行,因而直接进入实例化阶段。实例化阶段主要分为三个部分,实例属性字段的初始化,实例代码块的执行,构造函数的执行。 而实际上,对于实例属性字段的赋值与实例代码块中代码都会被编译器放入构造函数中一块儿运行。

因此,在执行 Z 的构造器以前会先进入 X 的构造器,而 X 中的实例属性会按序被编译器放入构造器。也就是说,X 构造器的第一步实际上是这条语句的执行:

Y y = new Y();

因此,进行类型 Y 的类加载与实例化过程,结束后会打印字符「Y」。

而后,进入 X 的构造器继续执行,打印字符「X」。

至此,父类的全部初始化动做完成。

最后,进行 Z 自己的构造器的初始化过程,同样会先初始化实例属性,再执行构造函数方法体,输出字符「Y」和「Z」。

有关类对象的建立与初始化过程,这两道题目算是很好的检验了,其实这些初始化过程并不复杂,只须要你理解清楚各个步骤的初始化顺序便可。


文章中的全部代码、图片、文件都云存储在个人 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,全部文章都将同步在公众号上。

image
相关文章
相关标签/搜索