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.aS.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 中的验证代码

验证上述代码很容易,IJ 中使用 View > Show ByteCode 或执行 javap -v XXX.class 即可查看字节码,其能够确定字段继承和掩蔽是否在字节码中在 S 类声明了变量。使用 JOL Java Object Layout 插件能够看到字段继承和掩蔽在内存中的布局,其能够确定字段继承和掩蔽是否在内存中存在两个变量。根据此,就能够推断出来最后的结果。