您的当前位置:首页正文

Java中类的初始化顺序,为什么父类比子类先加载和初始化?

2024-11-24 来源:个人技术集锦

前言

不知道各位在Java在面试中有没有遇到过类的初始化加载顺序的面试题,举个?

这些面试题如果没有很好理解类的初始化顺序,一般都会在面试过程中栽跟头。

  • 你是否清楚类的变量初始值默认值? 这将很大程度决定你能否计算出变量的最终输出值
  • 你是否清楚类的成员组成以及他们的执行顺序?这将很大程度决定你能否正确判断代码执行顺序

比如说:

  1. 类的基本类型成员变量和引用类型的成员变量默认初始值是多少?
  2. 类的静态成员变量和类的成员变量有什么区别?
  3. 类的代码块、构造器、成员方法谁先初始化执行?
  4. 类的静态代码块、代码块、构造器、成员方法谁先初始化执行?
  5. 在有继承关系的两个类中,是父类的代码初始化执行先前于子类的代码初始化执行,还是反过来?

面试题

这里给出一道面试题,可以稍微来验证一下对这块知识是否掌握,请根据代码写出控制台输出的内容

class Mug {
    Mug(int marker) {
        System.out.println("Mug(" + marker + ")");
    }
    void f(int marker) {
        System.out.println("f(" + marker + ")");
    } }
public class Mugs {
    Mug c1;
    static int x;
    Integer y;
    {
        c1 = new Mug(1);
        System.out.println("x="+x);
        System.out.println(y);
    }
    static{
        x++;
        System.out.println("x="+x);
    }
    Mugs() {
        System.out.println("Mugs()");

    }
    static Mug c2= new Mug(2);
    public static void main(String[] args) {
    	// 请写出控制台输出的内容
        System.out.println("Inside main()");
        Mugs x = new Mugs();
    }
}

面试题答案

x=1
Mug(2)
Inside main()
Mug(1)
x=1
null
Mugs()

解析:

  1. 程序从main方法执行,然后加载main所在Mugs类,并执行初始化,并初始化类的静态变量x=0
  2. 然后执行初始化静态代码x++,输出自增后结果1
  3. 然后初始化执行静态变量c2=null,并加载Mug类调用构造方法输出Mug(2),赋值对象引用给c2
  4. 开始执行main方法内部内容,先输出Inside main()
  5. 接着调用构造方法创建实例对象,初始化c1=null,初始化y=null
  6. 初始化代码块输出x=1,输出y的值null
  7. 最后调用Mugs构造方法创建对象,将对象引用赋值给Mugs x

如果没有做出来,就可以参考下面的图来尝试再解答一次,本文也会按照这个图的内容,由浅入深告诉你为什么是这样的顺序加载执行

类的成员组成

首先我们来回顾一下Java中类的成员组成内容,一般是由下面四块内容组成

  1. 类的成员变量
  2. 类的代码块
  3. 类的构造器
  4. 类的成员方法

类成员初始化赋值

1.从方法局部变量来看类的成员变量

回顾完这些基础内容后,我们再来看看看变量初始值有关的问题

Java 尽力保证:所有变量在使用前都能得到恰当的初始化,这是Java的一大准则

对于定义于方法内部的局部变量, Java 以编译时刻错误的形式来贯彻这种保证。所以如果你这么写:

  void f() {
    int i;
    i++; // Error -- i not initialized
  }

就会得到一条出错消息,告诉你 i 可能尚未初始化。

2.为什么局部变量不赋值会报错,成员变量不赋值不报错?

既然知道未初始化的变量在没有赋值时不能使用,那为什么不直接给默认值防止这个错误
比方说让编译器自动加上默认值,比如编译器也可以为 i 赋予一个缺省值,这是因为在Java设计者看来未初始化这种情况看起来更象是程序员的疏忽,所以采用缺省值反而会掩盖这种失误

但是另外一边,Java的成员变量又是可以在声明时不赋值,这不是跟??上面的准则相反了吗?
比如,文中的面试题,这三个都是没有在声明时赋值,为什么编译器不报错?

   	Mug c1;// 引用类型成员变量
    static int x;// 基本类型静态变量
    Integer y;// 引用类型成员变量

这是因为以下原因:

  1. 类的数据成员是基本类型,情况就会变得有些不同。因为类中的任何方法都可以初始化或用到这个数据,所以强制用户一定得在使用数据前将其初始化成一个适当的值并不现实
  2. 其次是一个类的所有基本类型成员变量,编译器在初始化时都会保证它有一个初始值
  3. 对于引用类型成员变量,初始值是null
public class InitialValues {
	// 基本类型初始值(默认值)
     boolean t;  // false
     char c; // 0 你可以在main方法调用println方法验证是否是无输出值,实际它的默认值是0
     byte b;// 0
     short s;// 0
     int i;// 0
     long l;// 0
     float f;// 0.0
     double d; // 0.0
     // 引用类型初始值
     Integer x;// null
     void print(String s) { System.out.println(s); }
}

尽管数据成员的初值没有给出,但它们确实有初值(char 值为 0,所以显示为空白)。
因为成员变量有初始值,所以你至少不会冒“未初始化变量”的风险了,这也能保证上面Java设计准则。

看到这里,你应该就会明白文初的面试题,为什么Integer y这个变量在代码块中输出的是null值了
因为引用类型成员变量默认就是为null

通过构造器初始化成员变量值

回顾一下类的成员变量赋值方式:

  1. 无赋值时,编译器在初始化时赋值默认值
  2. 在声明时,就显示赋值,如int x = 0
  3. 先声明,之后可以用构造器和方法来进行初始化赋值。

在运行时刻,你可以调用方法或执行某些动作来确定初值,这 为你在编程时带来了更大的灵活性。但要牢记:你无法屏蔽自动初始化的进行,它将在构造 器被调用之前发生。因此,假如使用下述代码:

   class Counter {
      int i;
      Counter() { i = 7; }

成员变量int i首先会被置 0,然后变成 7,对于基本类型和对象引用,包括在定义时已经指定初值的 变量,这种情况都是成立的。因此,编译器不会强制你一定要在构造器的某个地方或在使用 它们之前对元素进行初始化——因为初始化早已得到了保证

成员方法与非静态方法初始化加载

静态方法(Static Method)与静态成员变量一样,属于类本身,在类装载的时候被装载到内存(Memory),不自动进行销毁,会一直存在于内存中,直到JVM关闭。

非静态方法(Non-Static Method)又叫实例化方法,属于实例对象,实例化后才会分配内存,必须通过类的实例来引用。不会常驻内存,当实例对象被JVM 回收之后,也跟着消失。

由于方法是在被调用时,才会对执行顺序有影响,这个只要看清方法前被调用的对象,就能比较清晰知晓执行顺序。

类的初始化顺序

初始化顺序准则之一:在类的内部,变量定义的先后顺序决定了它被初始化的顺序,成员变量赋值会先于构造器和其他方法执行

// 当构造器被用来创建一个Tag对象时,你看到下面按照下面注释的数字顺序执行
class Tag {
      Tag(int marker) {
        System.out.println("Tag(" + marker + ")");
		} 
}
class Card {
  Tag t1 = new Tag(1); // 先于构造器执行 ①
  Card() {
    // 在构造器执行时执行
    System.out.println("Card()");
    t3 = new Tag(33); // 重新初始化t3 ④
  }
  Tag t2 = new Tag(2); // 定义在构造器执行之后 ②
  void f() {
    System.out.println("f()"); 
  }
  Tag t3 = new Tag(3); // 定义在最后 ③
}
public class OrderOfInitialization {
    public static void main(String[] args) {
        Card t = new Card();
        t.f(); // 表明构造器已经被执行完毕
    }
}

这里控制台输出的内容为:

Tag(1)
Tag(2)
Tag(3)
Card()
Tag(33)
f()

举这个例子是为了说明:程序在main方法启动后

  1. 成员变量赋值总是先于构造器方法和成员方法执行之前执行,这跟成员变量定义在方法之间顺序,以及类中的顺序是无关的
  2. 成员变量之间,按照定义的先后顺序依次赋值执行的,如t1先于t2执行,t2先于t3执行
  3. t3成员变量在赋值完初始值后,在构造器中再次被初始化赋值,原先new Tag(3)的对象引用不再被用,之后垃圾回收器会回收new Tag(3)引用

初始化顺序准则之二:静态代码优先于非静态代码执行,静态变量优先于非静态变量执行

对于静态成员变量初始化赋值,它跟成员变量赋值一样:

  1. 静态基本类型成员变量默认初始值跟成员变量一致
  2. 静态引用类型成员变量默认值也是为null

唯一不同就是:静态成员变量在内存中只有一份,无论创建多少对象,它的变量值用于都只有一份

// 下面代码按照数字顺序执行
class Bowl {
     Bowl(int marker) {
       System.out.println("Bowl(" + marker + ")");
     }
     void f(int marker) {
       System.out.println("f(" + marker + ")");
} }
class Table {
     static Bowl b1 = new Bowl(1);
     Table() {
       System.out.println("Table()");
   	   b2.f(1); 
   	}
     void f2(int marker) {
       System.out.println("f2(" + marker + ")");
      }
 	static Bowl b2 = new Bowl(2);
}
class Cupboard {
     Bowl b3 = new Bowl(3);
     static Bowl b4 = new Bowl(4);
     Cupboard() {
       System.out.println("Cupboard()");
   	b4.f(2); 
   	}
     void f3(int marker) {
       System.out.println("f3(" + marker + ")");
     }
     static Bowl b5 = new Bowl(5);
}
public class StaticInitialization {
   public static void main(String[] args) {
       System.out.println("Creating new Cupboard() in main"); // ③
       new Cupboard();  // ④
       System.out.println("Creating new Cupboard() in main");// ⑤
       new Cupboard();// ⑥
       t2.f2(1); // ⑦
       t3.f3(1);// ⑧
   }
   static Table t2 = new Table(); // 注意此处会先执行 ①
   static Cupboard t3 = new Cupboard(); // 注意此处会先执行 ②
}

上面的代码的执行顺序:

Bowl(1)
Bowl(2)
Table()
f(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
f2(1)
f3(1)

我一开始对这个结果是不理解,后面发现捋一捋还是有道理可遵循的
注意事项:

  1. Table 类和 Cupboard 类在它们的类定义中加入了 Bowl 类型的静态成员。并且在Bowl 类型的静态成员定义之前,Cupboard 类先定义了一个 Bowl 类型的 非静态成员 b3。
  2. main方法中所在的类StaticInitialization初始化时,由于t2和t3变量是静态修饰的,会在JVM执行类加载时初始化再执行main方法里面的代码

举这个例子是为了说明:

  1. 静态初始化只有在必要时刻才会进行。如果不创建 Table 对象,也不引用 Table.b1 或 Table.b2,那么静态的 Bowl b1 和 b2 永远都不会被创建。
  2. 只有在第一个 Table 对象被创建 (或者第一次访问静态数据)的时候,它们才会被初始化。此后,静态对象不会再次被初始化【即只初始化一次】。
  3. 静态代码优先于非静态代码执行,如Cupboard的b5变量先于b3变量输出

初始化顺序准则之三:代码块先于构造器和成员方法之前执行

Java 除了在构造器和方法外进行成员变量赋值,还允许将初始化动作组织成一个特殊的“子句”中(有时也叫作“代码块”)。 就象下面这样:

    class Spoon {
      static int i;
      int j;
      static {
      // 静态修饰叫做静态代码块
		i = 47; 
	  }
	  {
	  	// 代码块
	  	j = 48;
	  }

尽管上面的代码看起来象个方法,但静态代码块实际只是一段跟在 static 关键字后面的代码。与其他 静态初始化动作一样,这段代码仅执行一次:当你首次生成这个类的一个对象时,或者首次 访问属于那个类的一个静态成员时(即便从未生成过那个类的对象)。

这里为了方便静态代码块和非静态代码块,可以从以下去它们的区别去记忆:
静态代码块:

  • 执行时机:随着类的加载而执行,而且只执行一次
  • 作用: 初始化类的信息
  • 执行顺序:如果一个类中,定义了多个静态代码块,则按照声明的先后顺序执行

非静态代码块【即代码块】:

  • 执行时机:随着对象的创建而执行
  • 作用:可以在创建对象时,对对象的属性等进行初始化。
  • 执行顺序:如果一个类中,定义了多个非静态代码块,则按照声明的先后顺序执行
class Cup {
      Cup(int marker) {
        System.out.println("Cup(" + marker + ")");
      }
      void f(int marker) {
        System.out.println("f(" + marker + ")");
		} 
	}
    class Cups {
      static Cup c1;
      static Cup c2;
      static {
        c1 = new Cup(1);
        c2 = new Cup(2);
      }
      Cups() {
        System.out.println("Cups()");
		} 
	}
    public class ExplicitStatic {
      static Test monitor = new Test();
      public static void main(String[] args) {
        System.out.println("Inside main()");
        Cups.c1.f(99);  // (1)
        monitor.expect(new String[] {
          "Inside main()",
          "Cup(1)",
          "Cup(2)",
          "f(99)"
		}); 
		}
      // static Cups x = new Cups();  // (2)
      // static Cups y = new Cups();  // (2)
    } 

无论是通过标为(1)的那行程序访问静态的 c1 对象,还是把(1)注释掉,让它去运行标为(2) 的那行,Cups 的静态初始化动作都会得到执行。如果把(1)和(2)同时注释掉,Cups 的静态初 始化动作就不会进行。此外,激活一行还是两行(2)代码都无关紧要,静态初始化动作只进 行一次。

初始化顺序准则之四:父类初始化先于子类初始化执行

在涉及到子类继承父类并创建子类对象的时候,类的初始化顺序会一丢丢不同,一般来说顺序是这样的:

父类静态相关内容初始化->子类静态相关内容初始化->父类对象内容初始化->子类对象内容初始化

具体的内容可以看下面的图

这里我们也可以举个例子来验证上面的内容

class ParentClass {
    // 父类私有普通成员变量
    private int i = 9;
    // 子类可以访问的父类的成员变量
    protected int j;
    // 父类构造器
    ParentClass() {
        // super.Object()
        System.out.println("i = " + i + ", j = " + j);
        j = 39;
    }
    // 父类私有静态静态变量
    private static int x1 = print("static ParentClass.x1 initialized");
    // 父类静态方法
    static int print(String s) {
        System.out.println(s);
        return 47;
    }
}

public class ChildrenClass extends ParentClass {
    // 子类私有普通成员变量
    private int k = print("ChildrenClass.k initialized");
    // 子类构造器
    public ChildrenClass() {
        // 隐式调用 super.ParentClass()
        System.out.println("k = " + k);
        System.out.println("j = " + j);
    }
    // 子类私有静态变量
    private static int x2 = print("static ChildrenClass.x2 initialized");

    public static void main(String[] args) {
        //System.out.println(ParentClass.print("ParentClass print method"));
        System.out.println("ChildrenClass constructor");
        ChildrenClass child = new ChildrenClass();
    }
}  

控制台输出的内容

static ParentClass.x1 initialized
static ChildrenClass.x2 initialized
ChildrenClass constructor
i = 9, j = 0
ChildrenClass.k initialized
k = 47
j = 39

执行过程分析:

  1. 在ChildrenClass.java运行程序时,首先就是访问ChildrenClass.main( )方法,它是一个 static 方法,于是加载器开始启动并找出 ChildrenClass 类被编译的程序代码(它被编译到了一个名为 ChildrenClass .class 的文件之中)。
  2. 在对它进行加载的过程中,编译器注意到它有一个父类或者说是基类(这是由 关键字 extends 告知的),于是它继续进行加载。不管你是否打算产生一个该基类的对象, 这都要发生。(你可以请尝试将对象创建代码new ChildrenClass()注释掉,来证明这一点。)
  3. 如果该父类还有其自身的父类,那么第二个父类就会被加载,如此类推。接下来,根基类中 的静态初始化(在此例中为 ParentClass)即会被执行,然后是下一个子类,以此类推。
  4. 这种方式很重要,因为子类的静态初始化可能会依赖于基类(即父类)成员能否被正确初始化的。
  5. 至此为止,必要的类都已加载完毕,对象就可以被创建了。
  6. 首先,对象中所有的原始类型都 会被设为缺省值,对象引用被设为零——这是通过将对象内存设为二进制零值而一举生成的。
  7. 然后,基类的构造器会被调用。在本例中,它是被自动调用的。但你也可以用 super 来 指定对基类构造器的调用(正如在ChildrenClass( )构造器中的第一步操作。)基类构造器和子类的构造器一样,以相同的顺序来经历相同的过程。
  8. 在基类构造器完成之后,实例变量按其次序被初始化。最后,构造器的其余部分被执行。

这里有几个关键点必须要理解:

  1. 为什么main方法一开始不是先执行首条语句输出ChildrenClass constructor 再加载子类和父类?
  2. 为什么要先加载父类静态内容再加载子类静态内容,即为什么先初始化父类先?
  3. 为什么不是创建子类对象调用子类构造器时要先执行父类的构造器?

为什么main方法一开始不是先执行首条语句输出ChildrenClass constructor 再加载子类和父类?

因为main方法的首条语句有可能依赖父类的数据成员,比如注释中依赖父类的静态方法,假设去除main方法第一行代码注释,然后执行 main依赖父类方法print(),父类方法print等待main方法执行完首行后,再加载才能初始化静态方法,形成死循环。
同理也有可能依赖子类或者其他类的数据成员,这样自然就好理解为什么是要等到类加载后再执行main方法方法体内容。

为什么要先加载父类静态内容再加载子类静态内容,即为什么先初始化父类先?

因为子类可以能引用到父类的数据,比如静态数据、静态方法(静态初始化可能会依赖于基类成员能否被正确初始化的)比如子类private int k = print("ChildrenClass.k initialized");虽然是实例变量依赖父类的print()方法,可能会有偏差,如果我加上个static修饰,变成子类静态变量依赖于父类的静态方法,这样就自然能想明白了。

为什么不是创建子类对象调用子类构造器时要先执行父类的构造器?

同样,这个方法也可以理解为,实例化子类对象要依赖父类的数据成员,比如子类的构造器调用System.out.println("j = " + j);就是实例的时候需要用到父类的数据成员,那么就自自然很容易理解。

而且为了方便理解,你可以理解为子类如果存在继承关系时,构造器首行编译器会隐式调用super(),即隐式调用父类无参数构造器,这样更加方便理解为什么父类构造器总是比子类构造器先执行。

而且Java也确实是这样的做,如果我们在子类构造器手动调用父类的构造器super()并且不是放在子类构造器首行时,编译器会报错,目的就是要保证父类先实例化,保证子类如果有依赖父类数据成员时,能够准确无误调用。

文章总结

  1. 类的成员变量有默认初始值,基本类型有具体类型的对应的默认值,引用类型的默认值为null
  2. 类的成员变量赋值会优先于构造器和其他方法执行
  3. 类的成员变量定义的先后顺序决定了它被初始化的顺序,不会因为定义在某个方法之后,就后执行,总是先于构造器和其他方法前执行
  4. 类的静态变量先于类的非静态变量之前执行
  5. 类的代码块先于构造器和其他方法之前执行

为了方便理解:你可以将静态代码块类比成静态变量,非静态代码块【代码块】类比非静态变量【普通成员变量】

最后本文可以理解为简单几句话:

  1. 类的初始化就是初始化静态成员内容(静态变量,静态代码块),类的实例化即创建对象就是初始化普通成员内容(普通成员变量,普通代码块)和构造器内容,因为普通成员(构造器也是普通成员,个人理解)属于对象范畴
  2. 静态成员内容先于普通成员内容初始化,因为类要加载之后,在JVM生成一个个.class文件和静态区域数据,才能依赖.class文件和静态区域数据创建实例对象(如果实例对象依赖静态数据的前提)
  3. 继承关系中,父类优先子类加载,父类优先于子类实例化

最后类的初始化顺序可以用一张图来简化理解

涉及到子类和父类的初始化顺序时可以看下面的图来简化理解

显示全文