Java 字段掩蔽和继承一例
如下代码是我在开发过程中遇到的一个真实案例的简化抽象,猜猜看最后打印的结果是什么?
Note. 截至 2024/3,ChatGPT, Gemini Ultra 等一众大模型,没有能正确回答这个问题的
class F {
int a = 3;
void print() {
System.out.println("in father, a is " + a);
fire(); //INVOKEVIRTUAL F.fire ()V
}
void fire() {
System.out.println("fire in father, a is " + a);
}
}
class S extends F {
//int a; //I a
S() {
//INVOKESPECIAL F.<init> ()V
System.out.println("a in father is " + a);
this.a = 4; //PUTFIELD S.a : I
}
@Override
void print() {
super.print(); //INVOKESPECIAL F.print ()V
System.out.println("in son, a is " + a);
}
@Override
void fire() {
System.out.println("fire in son, a is " + a);
super.fire(); //INVOKESPECIAL F.fire ()V
}
public static void main(String[] args) {
F s = new S();
s.print();
}
}
在 OpenJDK 21 Windows x64 Port 平台执行,打印结果如下:
a in father is 3
in father, a is 4
fire in son, a is 4
fire in father, a is 4
in son, a is 4
这个例子中,最容易混淆的地方在于 S 的 a 字段,如果注释行取消注释,即在 S 中显式定义 int a, 那么打印结果如下:
a in father is 0
in father, a is 3
fire in son, a is 4
fire in father, a is 3
in son, a is 4
为简化描述,下文将依据 “是否在 S 显式定义 int a” 将其区分为字段掩蔽和字段继承。
简单来说,当通过 F s = new S();
创建 s 实例时,封装、继承和多态的机制便依次生效。首先是 S 的构造器,查看字节码可知,这里首先 INVOKESPECIAL F.<init> ()V
调用父类构造器,然后执行 S 构造器,包括打印 a 的值,为 this.a = 4
赋值。根据 Java 规范,F 对象中所有和 S 对象签名相同的字段都会被掩蔽,因此如果 S 对象包含 int a;
或 int a = 4;
这样的字段显式声明,那么其就会包含两个 a 字段,分别是 F.a
和 S.a
,其占据两倍的内存,这两个字段独立读写;而相反,在隐式继承时只包含一个 a 字段,F.a
就是 S.a
,因此字段继承本质就是为唯一的变量 a 赋值。
当调用 s.print()
时动态分派机制会找到 s 的实际类型 S 并调用其 print 方法,其中调用的 super.print()
执行指令 INVOKESPECIAL F.print ()V
,会调用父类 F 的 print 方法,而 F 的 print 方法中使用了字段 a,此时 a 的值通过 GETFIELD F.a: I
调用,对于字段继承而非掩蔽,其调用的就是 S 构造器中赋值的那个变量,即 4,因此打印结果为 in father, a is 4
,反之,如果是字段掩蔽,那么就有两个变量,其调用的就是 F.a
而非 S.a
,因此会打印父类构造器初始化的 3:in father, a is 3
。注意父类又调用了 fire()
方法,这是一个普通的 INVOKEVIRTUAL F.fire ()V
调用,因为实际调用者是 s,因此动态分派到了 S 重载的 fire 方法,这里打印的 a 执行的是 GETFIELD S.a: I
,因此字段继承和掩蔽,都是 S.a
,打印 fire in son, a is 4
。注意这里的子类实现又调用了 super.fire()
,其执行的是 INVOKESPECIAL F.fire ()V
,因此继续回到父类,调用了父类的 fire 方法,在其中 a 调用的是 GETFIELD F.a: I
,因此字段继承打印的是那个唯一变量 fire in father, a is 4
,而掩蔽时打印被掩蔽的父类变量 fire in father, a is 3
。最后,S print 方法回到自己的调用栈,GETFIELD S.a: I
调用了 S.a
,因此均打印 in son, a is 4
。
验证上述代码很容易,IJ 中使用
View > Show ByteCode
或执行javap -v XXX.class
即可查看字节码,其能够确定字段继承和掩蔽是否在字节码中在 S 类声明了变量。使用JOL Java Object Layout
插件能够看到字段继承和掩蔽在内存中的布局,其能够确定字段继承和掩蔽是否在内存中存在两个变量。根据此,就能够推断出来最后的结果。