继承关系的类初始化和实例化的顺序

正如曾有评论所言,我们学习的重点在于思路。很多人都知道继承关系中类的初始化和实例化顺序,但如果忘记了怎么办?如何自己找到答案?又或者遇到的问题是关于泛型擦除,又该如何分析?

思路才是重点。暂且不论泛型擦除,先看继承。首先给出一个例子,观察它的输出是什么。

示例代码

定义父类 A

public class A {
    private static String a = "NA";
    private String i = "NA";

    {
        i = "A";
        System.out.println(i);
    }

    static {
        a = "Static A";
        System.out.println(a);
    }

    public A() {
        System.out.println("Construct A");
    }
}

定义子类 B 继承自 A

public class B extends A {
    private static String b = "NB";
    private String j = "NB";

    {
        j = "B";
        System.out.println(j);
    }

    static {
        b = "Static B";
        System.out.println(b);
    }

    public B() {
        System.out.println("Construct B");
    }
}

测试类 C

public class C {
    public static void main(String[] args) {
        new B();
    }
}

执行结果

以上代码的输出如下:

Static A
Static B
A
Construct A
B
Construct B

原理分析

这一切源于 Java 编译器的处理机制。JVM 负责解析字节码,字节码虽然不是最原始的机器汇编码,但已完全可以解释 JVM 的指令执行过程。一般来说,字节码和 Java 源码相差较大,javac 会进行前期优化,修改、增加或删除源码以产生 JVM 解释器可理解的字节码。Java 语法带来的安全、易用、易读等功能,让我们往往忽略了字节码会与 Java 源码存在出入

1. 类的初始化与实例化流程

当遇到 new 操作时,例如 new B(),JVM 将尝试初始化 B 类:

  1. 如果 B 已经初始化,则开始实例化 B 类。
  2. 如果 B 类没有初始化,则先初始化 B 类。由于 B 继承自 A,所以在初始化 B 类之前需要先初始化 A 类。

因此,类的初始化过程是:A -> B。类在初始化时会执行 static 域和静态代码块。

类的实例化在类初始化之后。实例化的时候必须先实例化父类。实例化会先执行域和代码块,然后再执行构造函数。

上面的理论如果靠死记硬背,总会忘记。此外,父类的构造函数必须放在子类构造函数的第一行,这是为什么?

2. 使用 javap 分析字节码

遇到这种语法问题时,看教科书不如自己找出答案。工具就在 JDK 中,名为 javap 的命令。javap 可以输出 class 文件的字节码伪代码。我们只需要分析 B 的字节码,就可以找到答案。

joeytekiMacBook-Air:bin joey$ javap -verbose B

输出内容如下(部分精简):

Compiled from "B.java"
public class B extends A
  SourceFile: "B.java"
  minor version: 0
  major version: 50
  Constant pool:
const #1 = class    #2;    //  B
const #2 = Asciz    B;
const #3 = class    #4;    //  A
const #4 = Asciz    A;
const #5 = Asciz    b;
const #6 = Asciz    Ljava/lang/String;;
const #7 = Asciz    j;
const #8 = Asciz    <clinit>;
const #9 = Asciz    ()V;
const #10 = Asciz    Code;
const #11 = String    #12;    //  NB
const #12 = Asciz    NB;
const #13 = Field    #1.#14;    //  B.b:Ljava/lang/String;
const #14 = NameAndType    #5:#6;//  b:Ljava/lang/String;
const #15 = String    #16;    //  Static B
const #16 = Asciz    Static B;
const #17 = Field    #18.#20;    //  java/lang/System.out:Ljava/io/PrintStream;
const #18 = class    #19;    //  java/lang/System
const #19 = Asciz    java/lang/System;
const #20 = NameAndType    #21:#22;//  out:Ljava/io/PrintStream;
const #21 = Asciz    out;
const #22 = Asciz    Ljava/io/PrintStream;;
const #23 = Method    #24.#26;    //  java/io/PrintStream.println:(Ljava/lang/String;)V
const #24 = class    #25;    //  java/io/PrintStream
const #25 = Asciz    java/io/PrintStream;
const #26 = NameAndType    #27:#28;//  println:(Ljava/lang/String;)V
const #27 = Asciz    println;
const #28 = Asciz    (Ljava/lang/String;)V;
const #29 = Asciz    LineNumberTable;
const #30 = Asciz    LocalVariableTable;
const #31 = Asciz    <init>;
const #32 = Method    #3.#33;    //  A."<init>":()V
const #33 = NameAndType    #31:#9;//  "<init>":()V
const #34 = Field    #1.#35;    //  B.j:Ljava/lang/String;
const #35 = NameAndType    #7:#6;//  j:Ljava/lang/String;
const #36 = String    #2;    //  B
const #37 = String    #38;    //  Construct B
const #38 = Asciz    Construct B;
const #39 = Asciz    this;
const #40 = Asciz    LB;;
const #41 = Asciz    SourceFile;
const #42 = Asciz    B.java;

{
static {};
  Code:
   Stack=2, Locals=0, Args_size=0
   0:    ldc    #11; //String NB
   2:    putstatic    #13; //Field b:Ljava/lang/String;
   5:    ldc    #15; //String Static B
   7:    putstatic    #13; //Field b:Ljava/lang/String;
   10:    getstatic    #17; //Field java/lang/System.out:Ljava/io/PrintStream;
   13:    getstatic    #13; //Field b:Ljava/lang/String;
   16:    invokevirtual    #23; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   19:    return
  LineNumberTable: 
   line 3: 0
   line 11: 5
   line 12: 10
   line 13: 19

public B();
  Code:
   Stack=2, Locals=1, Args_size=1
   0:    aload_0
   1:    invokespecial    #32; //Method A."<init>":()V
   4:    aload_0
   5:    ldc    #11; //String NB
   7:    putfield    #34; //Field j:Ljava/lang/String;
   10:    aload_0
   11:    ldc    #36; //String B
   13:    putfield    #34; //Field j:Ljava/lang/String;
   16:    getstatic    #17; //Field java/lang/System.out:Ljava/io/PrintStream;
   19:    aload_0
   20:    getfield    #34; //Field j:Ljava/lang/String;
   23:    invokevirtual    #23; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   26:    getstatic    #17; //Field java/lang/System.out:Ljava/io/PrintStream;
   29:    ldc    #37; //String Construct B
   31:    invokevirtual    #23; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   34:    return
  LineNumberTable: 
   line 15: 0
   line 4: 4
   line 6: 10
   line 7: 16
   line 16: 26
   line 17: 34

  LocalVariableTable: 
   Start  Length  Slot  Name   Signature
   0      35      0    this       LB;
}

3. 类生命周期与初始化

类的生命周期将经历:类的装载、链接、初始化、使用、卸载。

  • 装载:将字节码读入到内存的方法区中。
  • 初始化:会在线程栈中执行 static {} 块的 code。在此之前,这个块有另一个名字 <clinit> 即类初始化方法,现在常称为 static {}。类的初始化只进行一次。

每当一个类在装载和链接完毕以后,通过字节码的分析,JVM 解析器已经知道 B 是继承 A 的,于是在初始化 B 类前,A 类会先初始化。这是一个递归过程。所以,B 类的初始化会导致 Astatic {} 执行,然后是 Bstatic {} 执行。

让我们看看 Bstatic {} 块中执行了什么:

static {};
  Code:
   Stack=2, Locals=0, Args_size=0
   // 栈深为 2,本地变量 0 个,参数传递 0 个。
   0:    ldc    #11; //String NB
   // 将常量池中#11 放到栈顶。#11="NB".
   2:    putstatic    #13; //Field b:Ljava/lang/String;
   // 将栈顶的值 "NB" 赋予常量池中的#13,也就是 static b="NB".
   5:    ldc    #15; //String Static B
   // 将#15 放入栈顶。#15="Static B".
   7:    putstatic    #13; //Field b:Ljava/lang/String;
   // 赋值 static b = "Static B".
   10:    getstatic    #17; //Field java/lang/System.out:Ljava/io/PrintStream;
   // 将 PrintStream 引用压栈。
   13:    getstatic    #13; //Field b:Ljava/lang/String;
   // 将 static b 的值压栈。
   16:    invokevirtual    #23; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   // 调用虚函数 PrintStream.println("Static B")
   19:    return
   // 退出函数,销毁函数栈帧。

通过注释可以看到,类 B 中的 static 域赋值和 static 块均被放到了类的初始化函数中。

4. 实例化与构造函数

当我们进行类的实例化的时候,会调用类的构造函数。看看类 B 的构造函数做了什么:

public B();
  Code:
   Stack=2, Locals=1, Args_size=1
   // 栈深为 2,本地变量 1 个 (其实就是 this),参数为 1 个 (就是 this)。
   0:    aload_0
   // 将第一个参数压栈,也就是 this 压栈。
   1:    invokespecial    #32; //Method A."<init>":()V
   // 在 this 上调用父类的构造函数。
   // 在 B 的构造函数中并没有声明 super(),但是 Java 编译器会自动生成此字节码来调用父类的无参构造函数。
   // 如果在 B 类中声明了 super(int),编译器会使用对应的 A 类构造函数来代替。
   // JVM 只是执行字节码而已,它并不对 super 进行约束,约束它们的是 Java 的编译器。
   // this 出栈。
   4:    aload_0
   // 将 this 压栈。
   5:    ldc    #11; //String NB
   // 将"NB"压栈。
   7:    putfield    #34; //Field j:Ljava/lang/String;
   // 给 j 赋值 this.j="NB". this 和"NB"出栈。
   10:    aload_0
   // 将 this 压栈。
   11:    ldc    #36; //String B
   // 把"B"压栈
   13:    putfield    #34; //Field j:Ljava/lang/String;
   // 给 j 赋值 this.j="B". this 和"B"出栈。栈空
   16:    getstatic    #17; //Field java/lang/System.out:Ljava/io/PrintStream;
   // 压栈 PrintStream
   19:    aload_0
   // 压栈 this
   20:    getfield    #34; //Field j:Ljava/lang/String;
   // this 出栈,调用 this.j,压栈 this.j.
   23:    invokevirtual    #23; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   // 调用 PrintStream.println(this.j). 栈空。
   26:    getstatic    #17; //Field java/lang/System.out:Ljava/io/PrintStream;
   // 压栈 PrintStream
   29:    ldc    #37; //String Construct B
   // 压栈"Construct B"
   31:    invokevirtual    #23; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   // 调用 PrintStream.println("Construct B")
   34:    return

从上面的字节码可以看出,Java 编译器在编译产生字节码的时候,将父类的构造函数、域的初始化、代码块的执行和 B 的真正的构造函数按照顺序组合在了一起,形成了新的构造函数。

结论

一个类的编译后的构造函数字节码一定会遵循这样的顺序包含以下内容:

  1. 父类的构造函数
  2. 当前类的域初始化(按照书写顺序)
  3. 代码块(按照书写顺序)
  4. 当前类的构造函数

到这里,应该彻底明白继承类的初始化和实例化顺序了。

说明:文中 javap 输出显示 major version: 50,对应 Java 6 环境。虽然具体字节码指令可能随 JDK 版本略有差异,但关于类初始化与实例化顺序的核心逻辑在现代 Java 版本中依然适用。