Java 类初始化的时机
规范定义类的初始化时机为 “initialize on first active use”,即 “在首次主动使用时初始化” 。装载和链接在初始化之前就要完成
首次主动使用的情形:
- 创建类的新实例 -- new,反射,克隆或反序列化
- 调用类的静态方法
- 操作类和接口的静态字段(final 字段除外,因为存在常量池中)
- 调用 Java 的特定的反射方法
- 初始化一个类的子类
- 指定一个类作为 Java 虚拟机启动时的初始化类(含有 main 方法的启动类)
除了以上 6 种情形,Java 中类的其他使用方式都是被动使用,不会导致类的初始化
Java 对象初始化的时机
对象初始化又称为对象实例化。Java 对象在其被创建时初始化。有两种方式创建 Java 对象:
① 显示对象创建,通过 new 关键字来调用一个类的构造函数,通过构造函数创建一个对象
② 隐式对象创建:
- 加载一个包含 String 字面量的类或接口会引起一个新的 String 对象创建,除非包含相同字面量的 String 对象已经在 JVM 中存在了
String s1 = "zheng";
- 自动装箱机制可能会引起一个原子类型的包装类对象被创建
Integer iWrapper = 1;
- String 连接符也可能会引起新的 String 或者 StringBuilder 对象被创建,同时还有可能引起原子类型的包装对象被创建
System.out.println("zheng" + 1);
Java 如何初始化对象
当一个对象被创建之后,虚拟机会为其分配内存,主要用来存放对象的实例变量及其从超类继承过来的实例变量(即使这些从超类继承过来的实例变量有可能被隐藏也会被分配空间)。在为这些实例变量分配内存的同时,这些实例变量也会被赋予默认值
关于实例变量隐藏
class Foo {
int i = 0;
}
class Bar extends Foo {
int i = 1;
public static void main(String... args) {
Foo foo = new Bar();
System.out.println(foo.i);
}
}
上面的代码中,Foo 和 Bar 中都定义了变量 i,在 main 方法中,我们用 Foo 引用一个 Bar 对象,如果实例变量与方法一样,允许被覆盖,那么打印的结果应该是 1,但是实际的结果却是 0
但是如果我们在 Bar 的方法中直接使用 i,那么用的会是 Bar 对象自己定义的实例变量 i,这就是隐藏,Bar 对象中的 i 把 Foo 对象中的 i 给隐藏了,这条规则对于静态变量同样适用
在内存分配完成之后,Java 的虚拟机就会开始对新创建的对象执行初始化操作
域初始化
如果在构造器中没有显式地给域赋予初值,那么就会被自动地赋为默认值:数值为 0、布尔值为 false、对象引用为 null
这是域与局部变量的主要不同点。必须明确地初始化方法中的局部变量,否则使用时会出现编译错误。但如果没有初始化类中的域,将会被自动初始化为默认值
但是,这并不是一种良好的编程习惯,如果调用 get 方法,则会得到一个 null 引用
一般都会在执行构造器之前,显式域初始化,直接将一个值赋给任何域。初始值不一定是常量值,可以调用方法对域进行初始化:
class Person {
private static int nextId;
private int id = assignId();
private static int assignId() {
return nextId ++;
}
}
构造器
如果编写一个类时没有编写构造器,那么系统就会提供一个无参数构造器,这个构造器将所有实例域设为默认值
如果类中提供了至少一个构造器,但是没有提供无参数的构造器,则在构造对象时如果没有提供参数就会被视为不合法。例如,为 Employee 类提供了一个带参数的构造器:
Employee(String name, double salary)
如果不提供无参数构造器的话,默认构造对象就是不合法的,也就是说,调用
e = new Employee();
将会产生错误
超类构造器
Java 要求一个对象被初始化之前,其超类也必须被初始化,这一点是在构造器中保证的。Java 强制要求 Object 对象之外的所有对象构造器的第一条语句必须是超类构造器的调用语句或者是类中定义的其他的构造器,如果我们既没有调用其他的构造器,也没有显式调用超类的构造器,那么编译器会为我们自动生成一个对超类默认(没有参数)构造器的调用指令。如果超类没有不带参数的构造器,并且在子类的构造器又没有显式地调用超类的其他构造器,则编译器将报错
因此,如果我们显式调用超类的构造器,那么调用指令必须放在构造器所有代码的最前面,是构造函数的第一条指令。这么做才可以保证一个对象在初始化之前其所有的超类都被初始化完成
如果我们在一个构造器中调用另外一个构造器,如下所示:
public class ConstructorExample {
private int i;
ConstructorExample() {
this(1);
....
}
ConstructorExample(int i) {
....
this.i = i;
....
}
}
对于这种情况,Java 只允许在 ConstructorExample(int i) 内出现调用超类的构造器,也就是说,下面的代码编译是无法通过的:
public class ConstructorExample {
private int i;
ConstructorExample() {
super();
this(1);
....
}
ConstructorExample(int i) {
....
this.i = i;
....
}
}
Java 对构造器作出这种限制,目的是为了要保证一个类中的实例变量在被使用之前已经被正确地初始化,不会导致程序执行过程中的错误
初始化块
初始化块将在构造器执行之前完成。实际上,如果对实例变量直接赋值或者使用初始化块赋值,那么编译器会将其中的代码放到类的构造器中去,并且这些代码会被放在对超类构造器的调用语句之后,构造器本身的代码之前
public class InstanceInitializer {
{
j = i;
}
private int i = 1;
private int j;
}
public class InstanceInitializer {
private int j = i;
private int i = 1;
}
上面的这些代码都是无法通过编译的,编译器会抱怨说我们使用了一个未经定义的变量
对象初始化顺序
实例化一个类的对象的过程是一个典型的递归过程:
在准备实例化一个类的对象前,首先准备实例化该类的父类,如果该类的父类还有父类,那么准备实例化该类的父类的父类,依次递归直到递归到 Object 类。此时,首先实例化 Object 类,再依次对以下各类进行实例化,直到完成对目标类的实例化。具体而言,在实例化每个类时,都遵循如下顺序:先依次执行实例变量初始化和实例代码块初始化,再执行构造器初始化。也就是说,编译器会将实例变量初始化和实例代码块初始化相关代码放到类的构造器中去,并且这些代码会被放在对超类构造器的调用语句之后,构造器本身的代码之前
class Foo {
int i;
Foo() {
i = 1;
int x = getValue();
System.out.println(x);
}
protected int getValue() {
return i;
}
}
class Bar extends Foo {
int j;
Bar() {
j = 2;
}
@Override
protected int getValue() {
return j;
}
}
public class ConstructorExample {
public static void main(String... args) {
Bar bar = new Bar();
}
}
运行上面这段代码,会发现打印出来的结果既不是 1,也不是 2,而是 0。
根本原因就是 Bar 重载了 Foo 中的 getValue 方法。在执行 Bar 的构造函数时,编译器会在 Bar 构造函数开头插入调用 Foo 的构造函数的代码,而在 Foo 的构造函数中调用了 getValue 方法。由于 Java 对构造函数的执行没有做特殊处理,因此这个 getValue 方法是被 Bar 重载的那个 getValue 方法,而在调用 Bar 的 getValue 方法时,Bar 的构造函数还没有被执行,这个时候 j 的值还是默认值 0,因此我们就看到了打印出来的 0
类初始化
当类被第一次使用的时候会被初始化,而且只会被一个线程初始化一次。类的初始化顺序和对象一样:初始化一个类前,会依次递归初始化该类的父类,直到递归到 Object 类。我们可以通过静态初始化器和静态域初始化来完成对类变量的初始化工作,比如:
public class StaticInitializer {
static int i = 1;
static {
i = 2;
}
}
静态域初始化和静态初始化器基本同实例域初始化和实例初始化器相同,也有相同的限制(按照编码顺序被执行,不能引用后定义和初始化的类变量)。静态域初始化和静态初始化器中的代码会被编译器放到一个名为 static 的方法中(static 是 Java 语言的关键字,因此不能被用作方法名,但是 JVM 却没有这个限制),在类被第一次使用时,这个 static 方法就会被执行。上面的 Java 代码编译之后的 static 方法字节码如下:
static {};
Code:
Stack=1, Locals=0, Args_size=0
iconst_1
putstatic #10; //Field i:I
iconst_2
putstatic #10; //Field i:I
return
JVM 运行时数据区
JVM 内存可简单分为三个区:堆(heap)、栈(stack)和方法区(method):
堆区
存放对象本身(包括非 static 成员变量),所有线程共享栈区
存放基础数据类型、对象的引用,每个线程独立空间,不可互相访问
栈分为 3 个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)方法区(静态区,包含常量池)
存永远唯一元素(类-class、类变量、static 变量、方法),所有线程共享
实例化一个对象的执行顺序
父类静态代码 -> 子类静态代码 -> 父类非静态代码 -> 父类构造函数 -> 子类非静态代码 -> 子类构造函数
由流程图可知:
- static 代码只在类初始化时加载一次,加载后存在方法区,而每一个对象在实例化时,只是在堆中保存指向方法区的引用,所以全局唯一,一改都改,节省资源
- 因为 static 代码在对象被实例化之前和类初始化一起执行,所以除了可以通过对象应用外,也可以直接通过类名引用
- 因为先执行静态代码,再执行非静态代码,所以 static 代码仅能访问 static 数据、static 方法
- this、super 属于非静态代码,所以不能引用 this 和 super
一个实例变量在对象初始化的过程中会被赋值几次
JVM 在为一个对象分配完内存之后,会给每一个实例变量赋予默认值,这个时候实例变量被第一次赋值,这个赋值过程是没有办法避免的
如果在域初始化中对某个实例变量做了初始化操作,那么这个时候,这个实例变量就被第二次赋值了
如果在初始化器中,又对变量做了初始化操作,那么这个时候,这个实例变量就被第三次赋值了
如果在类的构造函数中,也对变量做了初始化操作,那么这个时候,变量就被第四次赋值
也就是说,一个实例变量,在 Java 的对象初始化过程中,最多可以被初始化 4 次