【Java】继承和多态在 Java 中是怎样实现的?
extends 关键字
class 子类 extends 父类 {...
} // 类继承是单继承
父类的哪些成员被继承 ?
访问修饰符 public 和 protected 修饰的父类成员字段和成员方法可以被继承 , 父类的默认方法只能在同包下继承 , 父类的 private 成员和构造方法不可继承 .
super 关键字
表示父类引用 , 用于在子类中访问父类的构造方法 , 成员字段 和 成员方法 .
super 调用父类构造方法
super()
调用父类的构造方法必须在子类构造方法的第一行 , 有参构造 super(E e)
同理 , 如果没有显式调用则在子类所有构造方法的第一行隐式插入 super()
父类的无参构造 .
**为什么 ? **
类加载器是沿继承链自上而下初始化类的 , 先初始化父类后初始化子类 , 实例化创建子类时同理 , 先在内存中创建一个对象 , 先调用父类构造再调用子类构造 , 如果子类执行到一半才调用父类构造 , 那么在执行期间依赖未定义的父类字段容易造成未定义行为 .
super 调用父类成员字段
变量隐藏机制
子类如果有成员字段与父类的成员字段同名 , 那么父类的字段会被隐藏 , 区别于方法重写 , 因为 Java 字段访问是编译时的静态绑定 , 所以可以通过向上转型访问父类变量 .
在类中可以通过 super + .
调用父类成员字段 ( 不论是否被隐藏 ) , 如果子类没有隐藏父类的成员字段 a , 那么 super.a
和 this.a
完全等价 .
super 调用父类成员方法
不论是否被重写 , super 总是调用父类的成员方法 .
转型机制
向上转型
将父类赋给子类变量 , 改变子类实例在栈中的对象类型 , 此时调用的是父类的成员字段和子类的重写方法 .
向下转型 ( 强转 )
将父类强制转换成子类 , 改变父类实例在栈中的对象类型 , 此时调用的是子类字段和子类的重写方法 , 我们通常使用默认转型创建父类实例 , 这样堆中会有子类成员字段的内存空间 , 此时再强转成子类是相对安全的 , 因为内存中有子类字段的空间 , 可以正常访问 .
要注意的是 , 将父类实例向下转型成子类实例不会调用子类的构造方法对子类成员字段进行初始化 , 如果直接创建父类实例再进行强转 , 此时调用子类的成员字段是极其危险的 , 会报错 . 只有有继承关系的类之间才能进行强转 , 否则也会报错 . 总结就是 , 语法上允许强转 , 但访问未初始化的子类成员字段会报错 .
静态绑定和动态绑定
Class Metadata 类元数据
类的字节码文件在类加载器加载后会有对应的类元数据 , 其中有很多类信息 , 包含 父类引用 , 字段表 , 虚函数表 vtable 等 .
父类引用
类元数据只记录父类信息 , 包括父类的常量池索引 , 其指向父类的全限定名 .
JVM 通过递归加载类字节码文件 , 形成完整的继承链 .
字段表
类元数据只会记录子类的成员字段信息 , 在创建子类实例调用完所有构造方法后 , JVM 会查表将父类字段和子类字段都放入堆中 , 所以在堆中父类字段和子类字段是连续的 , 父类字段在前 .
偏移地址
偏移地址就是堆中字段相对于对象头的偏移量 .
class A {int x = 10;
}
class B extends A {int x = 20;
}
public static void main(String[] args) {A a = new B;
}
new 语句调用后 , 堆中内存是这样的 :
[对象头][10][20]
如果对象头占 8 字节 , 那么 A 类的 x 字段的偏移地址是 +8 , B 类的 x 字段的偏移地址是 + 12 .
虚函数表
虚方法 : 非静态 , 非 private 修饰 , 非构造方法的所有方法 .
虚函数表本质是方法指针数组 , 存储类中所有虚方法的地址 .
子类继承父类时会复制父类的虚函数表 , 并用重写方法替换同名父类方法 , 向下转型和向下转型时 , 虚函数表不变 .
非虚方法在编译时就确定了 , 字节码文件中存在定位指令 , JVM 可以直接调用 .
A a = new B;
语句中 A 是 a 的引用数据类型 , 存储在栈中 ; B 是 a 的实际数据类型 , 存储在堆中 (实际是不一定在这里 , 只是区别一下 ) .
JVM 按照堆中实例 a 的实际数据类型查虚函数表 ; 按照创建实例时的偏移地址查字段值 .
重载方法
重载方法是静态绑定的 , 虚函数表在生成时是按照 方法名 + 参数信息 ( 方法签名 ) 的信息得到的各方法地址的指针 , 重载方法是不同的方法 , 所以如果子类重写父类重载方法的其中之一 , 在子类生成虚函数表时替换的是该重载方法的指针 , 子类再调用父类其他的重载方法时仍然调用的是父类方法 .
总结
针对 new 语句的左右两侧 , 左侧是该实例的引用数据类型 , 右侧是该实例的实际数据类型 , 按照引用数据类型调用类的成员是静态绑定 , 因为引用数据类型在编译期确定 ; 按照实际数据类型调用类的成员是动态绑定 , 因为实际数据类型在运行时确定 .
接口 interface 和实现 implements
接口是一种抽象类型 , 用于定义类应该实现的方法 , 而不提供具体实现 .
- 接口只提供方法签名 .
- 接口不能实例化 , 自然没有构造方法 .
- 实现类与接口之间是多继承 , 实现类可以实现多个接口 .
- 接口同样支持向上转型和动态绑定 .
- 接口的成员字段只能是静态常量 , 字段是被 public static final 隐式修饰的 ( 显式写出等价于没写 ) , 必须有初始值 .
关于接口的默认方法和抽象方法 ( Java 8 )
如果继承的多个接口存在同名抽象方法或同名默认方法 , 则其在实现类中重写时只需要一次 .
如果继承的多个接口的抽象方法和默认方法同名 , 则在实现类中必须重写 , 不能使用默认方法接口提供的关于该方法的默认实现 .
如果希望在实现类中调用接口的默认方法 , 则必须显式通过 接口名 + super + 方法名
调用 .
super 是用来调用父类方法和字段的 , 而不是接口 .
接口的成员字段
接口的字段只能是常量字段 , 静态字段属于接口 , 但实现类和实现类实例可以调用这个常量字段 , 实现类中可以用 接口名 + 字段名 调用或 直接使用字段名调用 , 同时在 main 方法中可以用实现类的实例名调用 , 也可以用实现类名调用 , 更可以用接口名调用 , 不过不推荐用实现类及其实例调用 , 这样代码不够清晰 , 也会有冲突问题 :
如果实现类实现的多个接口有同名字段的话 , 就不能直接使用字段名调用或者在 main 方法中使用实例名或实现类名调用了 , 否则会报错 .
实现类能够调用接口字段并非继承 , 字段仍然属于接口 , 在字节码文件中会统一优化成 接口名 + 字段名 的形式 , 前提是让编译器知道调用的是哪个接口的字段 .
抽象类
抽象类的继承是单继承 , 可以包含普通方法和任意访问修饰符修饰的成员字段 .
抽象类同样无法实例化 , 但有构造方法 , 在子类构造时被调用 .
默认方法冲突
如果子类继承父类的同时作为实现类实现接口 , 父类中有具体实例方法 , 接口存在同名的默认方法 , 子类在调用该方法时会优先调用父类方法 , 即使父类是抽象类 .
如果实现类实现多个接口 , 接口存在同名默认方法 , 则实现类必须重写该方法 , 这样是为了实现类实例可以直接调用接口默认方法 .
接口继承接口 多继承
子接口继承父接口的所有抽象方法和默认方法 , 子接口可以声明新的抽象方法 .
如果子接口重写了父接口的默认方法 , 则使用子接口版本 .
子接口仍然是接口 , 不能重写父接口的抽象方法 .
子接口的同名默认方法冲突仍然是强制重写 .