【Java】继承:从入门到JVM底层,一篇搞定
【Java】继承——语言根基(三)
继承:从入门到JVM底层,一篇搞定
继承是OOP三大特征中最具争议的一个。
爱它的人说:继承让代码复用变得极其简单,子类可以"免费"获得父类的所有功能。
恨它的人说:继承破坏了封装,让代码变得脆弱,一改父类、子类全崩。
真相是:继承是一把锋利的刀,用好了切菜如泥,用不好砍到自己脚。
一、继承到底在表达什么?
1.1 is-a关系
继承就干一件事:表达"是一种"。
- 狗 是一种 动物
- 卡车 是一种 汽车
判断标准:你能对着代码说出口"子类对象是父类类型"?业务上说得通,就用继承。
class Animal { }
class Dog extends Animal { } // Dog is an Animal ✓
1.2 什么时候别用继承?
汽车有引擎——这不是"是一种",是"有一个"(has-a)。
// ✗ 错误:汽车不是引擎
class Engine { }
class Car extends Engine { }
// ✓ 正确:汽车有一个引擎
class Car {
private Engine engine;
}
一句话原则:is-a用继承,has-a用组合。
二、语法速览
// 父类
public class Animal {
protected String name;
public Animal(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + "正在吃东西");
}
}
// 子类
public class Dog extends Animal {
private String breed;
public Dog(String name, String breed) {
super(name); // 不写这行编译报错
this.breed = breed;
}
@Override // 推荐加上,让编译器帮你检查
public void eat() {
System.out.println(name + "正在啃骨头");
}
}
@Override不是必须的,但强烈推荐。如果你写成了eatt(),不加注解编译器认为你写了新方法,加上注解会直接报错。
三、底层原理:new一个子类对象,内存里发生了什么?
3.1 对象的真实内存布局
Dog dog = new Dog("旺财", "金毛");
JVM在堆中分配的内存结构:
┌─────────────────────────────────────────────────┐
│ Dog对象(堆内存) │
├─────────────────────────────────────────────────┤
│ 对象头(12/16字节) │
│ - Mark Word(哈希码、GC分代年龄、锁状态) │
│ - Klass Pointer → 指向Dog的类元数据 │
├─────────────────────────────────────────────────┤
│ 实例数据(对齐填充) │
│ ┌─────────────────────────────────────────────┐ │
│ │ 父类部分(Animal的字段) │ │
│ │ name = "旺财"(引用,4/8字节) │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 子类部分(Dog的字段) │ │
│ │ breed = "金毛"(引用,4/8字节) │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
三个关键点:
- 子类对象里确实有父类的所有字段(包括private的,只是你访问不到)
- 初始化顺序固定:父类构造器先执行 → 子类构造器后执行
- Klass Pointer是多态实现的底层基础
3.2 类加载阶段:继承链的解析
当JVM加载Dog类时,会递归加载其父类:
1. 加载Animal类 → 创建Animal的类元数据
2. 加载Dog类 → 创建Dog的类元数据,其中包含指向Animal类元数据的super指针
3. 构建虚方法表(vtable)
3.3 方法调用底层:虚方法表(vtable)
Animal animal = new Dog("旺财", "金毛");
animal.eat(); // 输出:旺财正在啃骨头
字节码层面:
invokevirtual #4 // Method Animal.eat:()V
invokevirtual指令的执行步骤:
- 通过对象头的Klass Pointer找到
Dog的类元数据 - 从类元数据中找到虚方法表
- 在方法表中查找
eat()的实际地址(偏移量固定,查找是O(1)) - 执行
Dog.eat()
虚方法表示例:
| 偏移量 | 方法签名 | Animal的方法表 | Dog的方法表 |
|---|---|---|---|
| 0 | eat() | Animal.eat() | Dog.eat() ← 覆盖 |
| 1 | sleep() | Animal.sleep() | Animal.sleep() ← 继承 |
| 2 | toString() | Object.toString() | Object.toString() |
性能说明:虚方法调用比静态方法多一次内存间接寻址,但JIT会做内联优化(如去虚化),实际开销极小。
3.4 super是什么?
很多人以为super是父类对象的引用。不是。
super是编译器指令:
class Dog extends Animal {
void test() {
super.eat();
}
}
反编译后:
invokespecial #2 // Method Animal.eat:()V
invokespecial直接绑定父类方法,不经过动态分派。这就是为什么super.eat()不会产生多态效果。
3.5 完整初始化时序图
new Son() 执行流程:
时间轴 →
┌─────────────────────────────────────────────────────────────────────┐
│ 类加载阶段(仅一次) │
│ ├── 加载Object类 → 加载Father类 → 加载Son类 │
│ └── 构建虚方法表 │
├─────────────────────────────────────────────────────────────────────┤
│ 实例化阶段 │
│ ├── 1. 为Father+Son所有字段分配内存(包括默认值0/false/null) │
│ ├── 2. 执行Father实例变量初始化:a = 1 │
│ ├── 3. 执行Father构造器 → 调用print()(此时子类c还是0!) │
│ ├── 4. 执行Son实例变量初始化:c = 3 │
│ └── 5. 执行Son构造器 → 调用print()(此时c=3) │
└─────────────────────────────────────────────────────────────────────┘
四、三个必踩的坑(附解决方案)
坑一:父类一改,子类就崩(脆弱的父类问题)
// 父类
public class Counter {
private int count = 0;
public void increment() { count++; }
public void incrementTwice() {
increment();
increment();
}
}
// 子类
public class LoggingCounter extends Counter {
@Override
public void increment() {
System.out.println("increment called");
super.increment();
}
}
某天父类优化为:
public void incrementTwice() {
count += 2; // 不再调用increment()
}
子类的日志逻辑静默失效——没有任何编译错误,运行时也不会抛异常,只是日志不打印了。
解决方案:
- 父类中可能被子类重写的方法,用文档明确契约(
@implSpec) - 用
final禁止重写关键方法 - 或使用组合替代继承
坑二:正方形继承矩形(违反里氏替换)
class Rectangle {
protected int width, height;
public void setWidth(int w) { width = w; }
public void setHeight(int h) { height = h; }
public int getArea() { return width * height; }
}
class Square extends Rectangle {
@Override
public void setWidth(int w) {
width = w;
height = w;
}
@Override
public void setHeight(int h) {
height = h;
width = h;
}
}
void test(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
System.out.println(r.getArea()); // 期望20
}
test(new Square()); // 输出16 ✗
解决方案: 让正方形和矩形都继承更抽象的Shape类,各自实现。
坑三:继承层次过深
Animal → Mammal → Canine → Dog → Retriever → GoldenRetriever
问题:
- 理解一个类的行为需要翻6个类
- 顶层修改影响所有下层
- 测试成本指数增长
经验法则:继承层次不超过3层。
五、JDK源码中的继承设计
5.1 好的设计:AbstractList → ArrayList
// 模板方法模式
public abstract class AbstractList {
public boolean add(E e) {
add(size(), e); // 调用抽象方法
return true;
}
public abstract void add(int index, E element);
}
public class ArrayList extends AbstractList {
@Override
public void add(int index, E element) {
// 具体实现
}
}
父类定义了算法骨架(模板方法),子类只实现变化的部分。
5.2 差的设计:Stack extends Vector
// Java早期设计失误
class Stack<E> extends Vector<E> { // 不该继承Vector
public void push(E item) {
addElement(item);
}
}
问题:Stack本应是LIFO,但继承了Vector的所有方法,可以绕开规则直接在中间插入元素。
正确做法(组合):
class Stack<E> {
private List<E> elements = new ArrayList<>();
public void push(E item) {
elements.add(item);
}
public E pop() {
if (isEmpty()) throw new EmptyStackException();
return elements.remove(elements.size() - 1);
}
}
六、继承 vs 组合:决策框架
6.1 组合长什么样?
class Engine {
void start() { }
}
class Car {
private Engine engine; // 组合
Car() {
engine = new Engine();
}
void start() {
engine.start(); // 委托
}
}
6.2 决策三问
| 问题 | 是 | 否 |
|---|---|---|
| 能说通"子类是父类的一种"? | → 考虑继承 | → 用组合 |
| 子类能完全替代父类(里氏替换)? | → 考虑继承 | → 用组合 |
| 父类是否会频繁变化? | → 用组合 | → 考虑继承 |
三个都是"是",且需要多态,才用继承。
6.3 组合+接口的进阶模式(推荐)
// 接口定义能力
interface Flyable {
void fly();
}
// 组合 + 委托
class Bird implements Flyable {
private FlyBehavior flyBehavior; // 组合
Bird(FlyBehavior flyBehavior) {
this.flyBehavior = flyBehavior;
}
@Override
public void fly() {
flyBehavior.fly(); // 委托
}
}
这比继承更灵活:飞行行为可以运行时切换。
七、完整实战:员工工资系统
public abstract class Employee {
private String id;
private String name;
private double baseSalary;
public Employee(String id, String name, double baseSalary) {
this.id = id;
this.name = name;
this.baseSalary = baseSalary;
}
public String getName() { return name; }
protected double getBaseSalary() { return baseSalary; }
public abstract double calculateSalary();
public void work() {
System.out.println(name + "正在工作");
}
}
public class RegularEmployee extends Employee {
private double attendanceBonus;
public RegularEmployee(String id, String name, double baseSalary, double attendanceBonus) {
super(id, name, baseSalary);
this.attendanceBonus = attendanceBonus;
}
@Override
public double calculateSalary() {
return getBaseSalary() + attendanceBonus;
}
@Override
public void work() {
System.out.println(getName() + "正在处理业务");
}
}
public class Manager extends Employee {
private double teamBonus;
private int teamSize;
public Manager(String id, String name, double baseSalary, double teamBonus, int teamSize) {
super(id, name, baseSalary);
this.teamBonus = teamBonus;
this.teamSize = teamSize;
}
@Override
public double calculateSalary() {
return getBaseSalary() + teamBonus * teamSize;
}
@Override
public void work() {
System.out.println(getName() + "正在管理团队");
}
}
八、面试高频题(含陷阱)
Q1:Java为什么不能多继承类?
菱形继承问题:C继承A和B,A和B都有m(),调用c.m()冲突。
Java 8后接口有默认方法,处理规则:
- 类的方法优先于接口默认方法
- 子接口优先于父接口
- 冲突必须显式覆盖
interface A { default void m() {} }
interface B { default void m() {} }
class C implements A, B {
@Override
public void m() { A.super.m(); } // 必须自己选
}
Q2:构造方法为什么不能被继承?
- 语法:构造方法名必须和类名相同
- 语义:子类通过
super()初始化父类部分就够了
Q3:重写和重载的区别?
| 维度 | 重写 | 重载 |
|---|---|---|
| 位置 | 子类和父类之间 | 同一个类 |
| 参数 | 必须相同 | 必须不同 |
| 返回类型 | 协变(可返回子类) | 可以不同 |
| 访问权限 | 不能更严格 | 无关 |
| 绑定时机 | 运行时(动态分派) | 编译时(静态分派) |
Q4:初始化的完整顺序(高频陷阱题)
class Father {
int a = 1;
Father() { print(); }
void print() { System.out.println(a); }
}
class Son extends Father {
int c = 3;
Son() { print(); }
void print() { System.out.println(c); }
}
new Son(); // 输出?
答案:0 3
为什么第一个是0? 因为父类构造器执行时,子类的实例变量c内存已分配但尚未执行初始化(默认值为0),而子类的print()方法被调用时访问的就是这个未初始化的c。
完整顺序:
- 父类静态
- 子类静态
- 父类实例变量 → 父类构造器(此时子类
c默认0) - 子类实例变量 → 子类构造器
Q5:static方法能被重写吗?
不能。只能叫"隐藏"。
class Parent {
static void m() { System.out.println("Parent"); }
}
class Child extends Parent {
static void m() { System.out.println("Child"); }
}
Parent p = new Child();
p.m(); // 输出"Parent",不是"Child"
invokestatic指令在编译时就确定了调用目标,不经过动态分派。
Q6:抽象类为什么可以有构造方法?
抽象类不能new,但它的构造方法是给子类用的——子类实例化时先调用父类构造器初始化父类部分。
Q7:如何防止被继承?
final class:无法被继承private构造器 + 静态工厂方法sealed class(Java 17+):限制哪些类可以继承
九、IDE实用技巧
| 操作 | IDEA快捷键 |
|---|---|
| 查看类继承树 | Ctrl+H / Ctrl+Alt+U |
| 跳转到父类方法 | Ctrl+U |
| 查看方法重写 | Ctrl+O |
| 生成重写方法 | Ctrl+O(在子类中) |
十、总结
继承的核心不在于"子类能做什么",而在于"父类约束了什么"。
三条实用原则:
- 继承表达is-a,不是has-a
- 组合优先于继承,但不等于永远不用继承
- 只有真正满足"子类能完全替代父类"时才用继承
一句话记住:
继承是契约,不是偷懒;能说is-a,还要能替换。
面试官想听的,不是你背出来的概念,而是你能说出**“为什么这样设计"以及"什么场景下不能这样用”**。
更多推荐




所有评论(0)