在类本身中创建类的实例绝对没有问题。在编译程序和运行程序时,明显的先有鸡还是先有蛋的问题以不同的方式解决。
编译时
当一个创建自身实例的类被编译时,编译器发现该类对自身有循环依赖。这种依赖性很容易解决:编译器知道该类已经在编译,因此它不会再次尝试编译它。相反,它假装该类已经存在并相应地生成代码。
运行
类创建自身对象的最大问题是当类甚至还不存在时;也就是说,当类被加载时。这个问题通过将类加载分为两个步骤来解决:首先定义类,然后对其进行初始化。
定义意味着将类注册到运行时系统(JVM 或 CLR),以便它知道类的对象具有的结构,以及在调用其构造函数和方法时应该运行哪些代码。
一旦定义了类,它就会被初始化。这是通过初始化静态成员和运行静态初始化块和其他在特定语言中定义的东西来完成的。回想一下,此时已经定义了类,因此运行时知道类的对象是什么样的以及应该运行什么代码来创建它们。这意味着在初始化时创建类的对象没有任何问题。
这是一个示例,说明类初始化和实例化如何在 Java 中交互:
class Test {
static Test instance = new Test();
static int x = 1;
public Test() {
System.out.printf("x=%d\n", x);
}
public static void main(String[] args) {
Test t = new Test();
}
}
让我们逐步了解 JVM 将如何运行该程序。首先,JVM 加载Test
类。这意味着首先定义了类,以便 JVM 知道
- 一个名为
Test
存在的类,它有一个main
方法和一个构造函数,并且
- 该类
Test
有两个静态变量,一个被调用x
,另一个被调用instance
,并且
- 什么是
Test
类的对象布局。换句话说:一个对象是什么样子的;它有什么属性。在这种情况下Test
,没有任何实例属性。
现在类已定义,它已被初始化。首先,默认值0
ornull
被分配给每个静态属性。这设置x
为0
。然后 JVM 按源代码顺序执行静态字段初始化程序。那里有两个:
- 创建
Test
该类的一个实例并将其分配给instance
. 创建实例有两个步骤:
- 为对象分配第一个内存。JVM 可以这样做,因为它从类定义阶段就已经知道对象布局。
Test()
调用构造函数来初始化对象。JVM 可以这样做,因为它已经拥有类定义阶段的构造函数代码。构造函数打印出 的当前值x
,即0
。
- 将静态变量设置
x
为1
.
只是现在类已经完成加载。请注意,JVM 创建了该类的一个实例,即使它还没有完全加载。你有这个事实的证据,因为构造函数打印0
了x
.
现在 JVM 已经加载了这个类,它调用该main
方法来运行程序。该main
方法创建了另一个类对象Test
——程序执行中的第二个对象。构造函数再次打印出 的当前值x
,即 now 1
。该程序的完整输出是:
x=0
x=1
如您所见,不存在先有鸡还是先有蛋的问题:将类加载分离到定义和初始化阶段完全避免了这个问题。
当对象的一个实例想要创建另一个实例时怎么办,就像下面的代码一样?
class Test {
Test buggy = new Test();
}
当您创建此类的对象时,再次没有固有问题。JVM 知道对象应该如何在内存中布局,以便为它分配内存。它将所有属性设置为其默认值,因此buggy
设置为null
. 然后 JVM 开始初始化对象。为了做到这一点,它必须创建另一个类对象Test
。像以前一样,JVM 已经知道如何做到这一点:它分配内存,将属性设置为null
,然后开始初始化新对象……这意味着它必须创建同一个类的第三个对象,然后是第四个,一个第五,依此类推,直到它耗尽堆栈空间或堆内存。
请注意,这里没有概念上的问题:这只是一个写得不好的程序中无限递归的常见情况。例如,可以使用计数器来控制递归;这个类的构造函数使用递归来创建一个对象链:
class Chain {
Chain link = null;
public Chain(int length) {
if (length > 1) link = new Chain(length-1);
}
}