当前位置: 首页 > news >正文

【Java】继承:从入门到JVM底层,一篇搞定

【Java】继承——语言根基(三)

  • 继承:从入门到JVM底层,一篇搞定
    • 一、继承到底在表达什么?
      • 1.1 is-a关系
      • 1.2 什么时候别用继承?
    • 二、语法速览
    • 三、底层原理:new一个子类对象,内存里发生了什么?
      • 3.1 对象的真实内存布局
      • 3.2 类加载阶段:继承链的解析
      • 3.3 方法调用底层:虚方法表(vtable)
      • 3.4 super是什么?
      • 3.5 完整初始化时序图
    • 四、三个必踩的坑(附解决方案)
      • 坑一:父类一改,子类就崩(脆弱的父类问题)
      • 坑二:正方形继承矩形(违反里氏替换)
      • 坑三:继承层次过深
    • 五、JDK源码中的继承设计
      • 5.1 好的设计:AbstractList → ArrayList
      • 5.2 差的设计:Stack extends Vector
    • 六、继承 vs 组合:决策框架
      • 6.1 组合长什么样?
      • 6.2 决策三问
      • 6.3 组合+接口的进阶模式(推荐)
    • 七、完整实战:员工工资系统
    • 八、面试高频题(含陷阱)
      • Q1:Java为什么不能多继承类?
      • Q2:构造方法为什么不能被继承?
      • Q3:重写和重载的区别?
      • Q4:初始化的完整顺序(高频陷阱题)
      • Q5:static方法能被重写吗?
      • Q6:抽象类为什么可以有构造方法?
      • Q7:如何防止被继承?
    • 九、IDE实用技巧
    • 十、总结

继承:从入门到JVM底层,一篇搞定

继承是OOP三大特征中最具争议的一个。

爱它的人说:继承让代码复用变得极其简单,子类可以"免费"获得父类的所有功能。

恨它的人说:继承破坏了封装,让代码变得脆弱,一改父类、子类全崩。

真相是:继承是一把锋利的刀,用好了切菜如泥,用不好砍到自己脚。

一、继承到底在表达什么?

1.1 is-a关系

继承就干一件事:表达"是一种"。

  • 是一种动物
  • 卡车是一种汽车

判断标准:你能对着代码说出口"子类对象是父类类型"?业务上说得通,就用继承。

classAnimal{}classDogextendsAnimal{}// Dog is an Animal ✓

1.2 什么时候别用继承?

汽车有引擎——这不是"是一种",是"有一个"(has-a)。

// ✗ 错误:汽车不是引擎classEngine{}classCarextendsEngine{}// ✓ 正确:汽车有一个引擎classCar{privateEngineengine;}

一句话原则:is-a用继承,has-a用组合。

二、语法速览

// 父类publicclassAnimal{protectedStringname;publicAnimal(Stringname){this.name=name;}publicvoideat(){System.out.println(name+"正在吃东西");}}// 子类publicclassDogextendsAnimal{privateStringbreed;publicDog(Stringname,Stringbreed){super(name);// 不写这行编译报错this.breed=breed;}@Override// 推荐加上,让编译器帮你检查publicvoideat(){System.out.println(name+"正在啃骨头");}}

@Override不是必须的,但强烈推荐。如果你写成了eatt(),不加注解编译器认为你写了新方法,加上注解会直接报错。

三、底层原理:new一个子类对象,内存里发生了什么?

3.1 对象的真实内存布局

Dogdog=newDog("旺财","金毛");

JVM在堆中分配的内存结构:

┌─────────────────────────────────────────────────┐ │ Dog对象(堆内存) │ ├─────────────────────────────────────────────────┤ │ 对象头(12/16字节) │ │ - Mark Word(哈希码、GC分代年龄、锁状态) │ │ - Klass Pointer → 指向Dog的类元数据 │ ├─────────────────────────────────────────────────┤ │ 实例数据(对齐填充) │ │ ┌─────────────────────────────────────────────┐ │ │ │ 父类部分(Animal的字段) │ │ │ │ name = "旺财"(引用,4/8字节) │ │ │ └─────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 子类部分(Dog的字段) │ │ │ │ breed = "金毛"(引用,4/8字节) │ │ │ └─────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────┘

三个关键点:

  1. 子类对象里确实有父类的所有字段(包括private的,只是你访问不到)
  2. 初始化顺序固定:父类构造器先执行 → 子类构造器后执行
  3. Klass Pointer是多态实现的底层基础

3.2 类加载阶段:继承链的解析

当JVM加载Dog类时,会递归加载其父类:

1. 加载Animal类 → 创建Animal的类元数据 2. 加载Dog类 → 创建Dog的类元数据,其中包含指向Animal类元数据的super指针 3. 构建虚方法表(vtable)

3.3 方法调用底层:虚方法表(vtable)

Animalanimal=newDog("旺财","金毛");animal.eat();// 输出:旺财正在啃骨头

字节码层面:

invokevirtual #4 // Method Animal.eat:()V

invokevirtual指令的执行步骤:

  1. 通过对象头的Klass Pointer找到Dog的类元数据
  2. 从类元数据中找到虚方法表
  3. 在方法表中查找eat()的实际地址(偏移量固定,查找是O(1))
  4. 执行Dog.eat()

虚方法表示例:

偏移量方法签名Animal的方法表Dog的方法表
0eat()Animal.eat()Dog.eat()← 覆盖
1sleep()Animal.sleep()Animal.sleep() ← 继承
2toString()Object.toString()Object.toString()

性能说明:虚方法调用比静态方法多一次内存间接寻址,但JIT会做内联优化(如去虚化),实际开销极小。

3.4 super是什么?

很多人以为super是父类对象的引用。不是

super编译器指令

classDogextendsAnimal{voidtest(){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) │ └─────────────────────────────────────────────────────────────────────┘

四、三个必踩的坑(附解决方案)

坑一:父类一改,子类就崩(脆弱的父类问题)

// 父类publicclassCounter{privateintcount=0;publicvoidincrement(){count++;}publicvoidincrementTwice(){increment();increment();}}// 子类publicclassLoggingCounterextendsCounter{@Overridepublicvoidincrement(){System.out.println("increment called");super.increment();}}

某天父类优化为:

publicvoidincrementTwice(){count+=2;// 不再调用increment()}

子类的日志逻辑静默失效——没有任何编译错误,运行时也不会抛异常,只是日志不打印了。

解决方案:

  • 父类中可能被子类重写的方法,用文档明确契约(@implSpec
  • final禁止重写关键方法
  • 或使用组合替代继承

坑二:正方形继承矩形(违反里氏替换)

classRectangle{protectedintwidth,height;publicvoidsetWidth(intw){width=w;}publicvoidsetHeight(inth){height=h;}publicintgetArea(){returnwidth*height;}}classSquareextendsRectangle{@OverridepublicvoidsetWidth(intw){width=w;height=w;}@OverridepublicvoidsetHeight(inth){height=h;width=h;}}voidtest(Rectangler){r.setWidth(5);r.setHeight(4);System.out.println(r.getArea());// 期望20}test(newSquare());// 输出16 ✗

解决方案:让正方形和矩形都继承更抽象的Shape类,各自实现。

坑三:继承层次过深

AnimalMammalCanineDogRetrieverGoldenRetriever

问题:

  • 理解一个类的行为需要翻6个类
  • 顶层修改影响所有下层
  • 测试成本指数增长

经验法则:继承层次不超过3层。

五、JDK源码中的继承设计

5.1 好的设计:AbstractList → ArrayList

// 模板方法模式publicabstractclassAbstractList{publicbooleanadd(Ee){add(size(),e);// 调用抽象方法returntrue;}publicabstractvoidadd(intindex,Eelement);}publicclassArrayListextendsAbstractList{@Overridepublicvoidadd(intindex,Eelement){// 具体实现}}

父类定义了算法骨架(模板方法),子类只实现变化的部分。

5.2 差的设计:Stack extends Vector

// Java早期设计失误classStack<E>extendsVector<E>{// 不该继承Vectorpublicvoidpush(Eitem){addElement(item);}}

问题:Stack本应是LIFO,但继承了Vector的所有方法,可以绕开规则直接在中间插入元素。

正确做法(组合):

classStack<E>{privateList<E>elements=newArrayList<>();publicvoidpush(Eitem){elements.add(item);}publicEpop(){if(isEmpty())thrownewEmptyStackException();returnelements.remove(elements.size()-1);}}

六、继承 vs 组合:决策框架

6.1 组合长什么样?

classEngine{voidstart(){}}classCar{privateEngineengine;// 组合Car(){engine=newEngine();}voidstart(){engine.start();// 委托}}

6.2 决策三问

问题
能说通"子类是父类的一种"?→ 考虑继承→ 用组合
子类能完全替代父类(里氏替换)?→ 考虑继承→ 用组合
父类是否会频繁变化?→ 用组合→ 考虑继承

三个都是"是",且需要多态,才用继承。

6.3 组合+接口的进阶模式(推荐)

// 接口定义能力interfaceFlyable{voidfly();}// 组合 + 委托classBirdimplementsFlyable{privateFlyBehaviorflyBehavior;// 组合Bird(FlyBehaviorflyBehavior){this.flyBehavior=flyBehavior;}@Overridepublicvoidfly(){flyBehavior.fly();// 委托}}

这比继承更灵活:飞行行为可以运行时切换。

七、完整实战:员工工资系统

publicabstractclassEmployee{privateStringid;privateStringname;privatedoublebaseSalary;publicEmployee(Stringid,Stringname,doublebaseSalary){this.id=id;this.name=name;this.baseSalary=baseSalary;}publicStringgetName(){returnname;}protecteddoublegetBaseSalary(){returnbaseSalary;}publicabstractdoublecalculateSalary();publicvoidwork(){System.out.println(name+"正在工作");}}publicclassRegularEmployeeextendsEmployee{privatedoubleattendanceBonus;publicRegularEmployee(Stringid,Stringname,doublebaseSalary,doubleattendanceBonus){super(id,name,baseSalary);this.attendanceBonus=attendanceBonus;}@OverridepublicdoublecalculateSalary(){returngetBaseSalary()+attendanceBonus;}@Overridepublicvoidwork(){System.out.println(getName()+"正在处理业务");}}publicclassManagerextendsEmployee{privatedoubleteamBonus;privateintteamSize;publicManager(Stringid,Stringname,doublebaseSalary,doubleteamBonus,intteamSize){super(id,name,baseSalary);this.teamBonus=teamBonus;this.teamSize=teamSize;}@OverridepublicdoublecalculateSalary(){returngetBaseSalary()+teamBonus*teamSize;}@Overridepublicvoidwork(){System.out.println(getName()+"正在管理团队");}}

八、面试高频题(含陷阱)

Q1:Java为什么不能多继承类?

菱形继承问题:C继承A和B,A和B都有m(),调用c.m()冲突。

Java 8后接口有默认方法,处理规则:

  1. 类的方法优先于接口默认方法
  2. 子接口优先于父接口
  3. 冲突必须显式覆盖
interfaceA{defaultvoidm(){}}interfaceB{defaultvoidm(){}}classCimplementsA,B{@Overridepublicvoidm(){A.super.m();}// 必须自己选}

Q2:构造方法为什么不能被继承?

  1. 语法:构造方法名必须和类名相同
  2. 语义:子类通过super()初始化父类部分就够了

Q3:重写和重载的区别?

维度重写重载
位置子类和父类之间同一个类
参数必须相同必须不同
返回类型协变(可返回子类)可以不同
访问权限不能更严格无关
绑定时机运行时(动态分派)编译时(静态分派)

Q4:初始化的完整顺序(高频陷阱题)

classFather{inta=1;Father(){print();}voidprint(){System.out.println(a);}}classSonextendsFather{intc=3;Son(){print();}voidprint(){System.out.println(c);}}newSon();// 输出?

答案:0 3

为什么第一个是0?因为父类构造器执行时,子类的实例变量c内存已分配但尚未执行初始化(默认值为0),而子类的print()方法被调用时访问的就是这个未初始化的c

完整顺序:

  1. 父类静态
  2. 子类静态
  3. 父类实例变量 → 父类构造器(此时子类c默认0)
  4. 子类实例变量 → 子类构造器

Q5:static方法能被重写吗?

不能。只能叫"隐藏"。

classParent{staticvoidm(){System.out.println("Parent");}}classChildextendsParent{staticvoidm(){System.out.println("Child");}}Parentp=newChild();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,还要能替换。

面试官想听的,不是你背出来的概念,而是你能说出**“为什么这样设计"以及"什么场景下不能这样用”**。

http://www.jsqmd.com/news/659888/

相关文章:

  • Windows Cleaner终极方案:一键解决C盘爆红难题的智能清理工具
  • 零配置部署mPLUG视觉问答:一键启动,开箱即用的图片分析工具
  • Driver Store Explorer:5分钟掌握Windows驱动管理,轻松释放10GB+磁盘空间
  • SAP 组织与核算要素关系清单(含层级、归属、数据流向、关键T-code)
  • Comics Downloader终极指南:8大漫画网站一键离线下载,打造个人漫画图书馆
  • NVIDIA Profile Inspector 2.4.0.1:解锁NVIDIA显卡隐藏性能的终极指南
  • Coze-Loop与Dify平台集成:全栈AI应用开发优化
  • 3048基于单片机的直流电机角度速度控制系统设计(数码管,矩阵键盘)
  • RWKV7-1.5B-G1A Java开发实战:集成SpringBoot构建智能微服务
  • javascript:void(0) 含义
  • 【THM-课程内容】:Privilege Escalation-Windows Privilege Escalation:Abusing dangerous privileges
  • LLM工程化实践——RAG基础入门(一)
  • Bitbucket代码仓库全流程指南:从创建到分支管理与忽略文件配置
  • GEO Monitor Toolkit:让你知道 AI 模型在背后怎么评价你
  • SAP 组织与核算要素全景梳理(含架构、关系、数据流转)
  • ComfyUI-VideoHelperSuite三阶架构设计:基于FFmpeg的模块化视频处理引擎
  • TR-B | 中南-北航团队:连续通勤走廊早高峰均衡,终于完整破解!
  • 飞书文档批量导出工具:从手动复制到自动化迁移的完整解决方案
  • C语言中将数字转换为字符串的方法
  • 013、Python条件判断:if、elif、else语句
  • 轻量模型不妥协:all-MiniLM-L6-v2在Ollama中保持92%+ STS-B准确率
  • 从原理到实战:深度剖析Apache Shiro Remember Me反序列化漏洞(CVE-2016-4437)的攻防博弈
  • GitHub中文界面插件终极指南:3分钟让你的GitHub全面中文化
  • 沈阳小程序制作终极攻略:2026 年精准锁定最佳开发团队
  • AI 技术日报 - 2026-04-18
  • Zstats高级版教程(4):如何进行变量统计描述(下)—针对定量变量
  • 1的GCGV不好不坏更加符合
  • 2026年终极指南:简单三步突破JetBrains IDE试用期限制
  • Python金融数据自动化:解密同花顺问财API的量化分析新范式
  • Kandinsky-5.0-I2V-Lite-5s开源可部署方案:支持中小企业私有化部署的图生视频引擎