资讯详情

Java基础篇三:封装、继承、多态、接口与异常处理全面解析

📅 2026/9/28 9:02:33 | 华诺云谱 👁 阅读
Java基础篇三:封装、继承、多态、接口与异常处理全面解析
这一篇我拖了很久才动笔。不是我懒而是“Java基础篇三”这个范围实在太大了——封装、包结构、继承、多态、抽象类、接口、常用类、异常每一个词单独拿出来都能写一篇万字长文合在一起恰恰就是初学者从“会写代码”到“会写工程代码”的必经之路。很多人在这个阶段最容易陷入两种极端要么觉得“这就是几个关键字嘛背下来就行”要么被各种理论绕晕越学越没底气。先说结论这一篇的内容不是让你背面试题的而是让你理解 Java 这门语言当初到底是怎么设计出来的。你理解了设计者的取舍再看这些语法会发现它们全都有迹可循。我会结合这些年带新人、做项目评审时经常看到的真实问题把这些概念掰开揉碎讲清楚。内容比较长建议你备杯水分两次看完每看完一节就打开 IDE 敲一遍别只躺着看。1. 封装把数据和操作关在一个笼子里我特别想把封装放在第一位讲因为它是面向对象里“第一块多米诺骨牌”。新手常见的问题是为什么一定要写private直接public不香吗反正都是自己写的类搞那么多修饰符纯属麻烦。这个想法我理解但它是错的。封装的核心不是“隐藏数据”这么简单它真正解决的是共识与信任问题。想象一下你写了一个订单类把金额字段设为public别人可以直接写order.money -100代码不会报错程序却已经烂掉了——因为一条订单的金额为负根本不合法。而当你用private把字段藏起来只提供setMoney()方法就能在方法里加校验逻辑public class Order { private BigDecimal money; public void setMoney(BigDecimal money) { if (money null || money.signum() 0) { throw new IllegalArgumentException(订单金额不能为负); } this.money money; } }这时候“订单金额不能为负”这条业务规则就被“封装”进了对象自己身上。任何外部代码想改这个值都必须经过这道校验。这就是封装最朴素的用法不是炫技是防烂。1.1 访问修饰符到底控制了什么Java 的访问修饰符一共有四种private、default什么都不写、protected、public。很多人记不住它们的可见范围这里我给一个在实践中最好用的判断顺序private只有“本类内部”能访问连子类都不行。default默认/包访问本类 同一包下的类能访问。protected本类 同一包下的类 子类子类可以在不同包。public所有类都能访问。光看文字很绕直接看下面这段代码的注释对照着看就清楚了package com.example.demo; public class VisibilityDemo { private int a 1; int b 2; // 包访问权限 protected int c 3; public int d 4; }在同一个包里的另一个类能访问b、c、d不能访问a在不同包里的子类能访问c和d不能访问a和b任何地方的无关类只能访问d。这段关系我建议你亲手敲一遍把报错一个个触发出来看比死记表格高效得多。这里要提醒一个很多人踩过的坑protected并不完全等于“包访问 子类访问”。如果子类和父类不在同一个包子类只能通过“自己的对象实例”去访问从父类继承而来的protected成员你不能在子类里去new一个父类的对象然后访问它的protected成员。这个规则看起来细但在跨包继承的时候经常让人懵面试也很爱考。1.2 实际开发中的封装规范工作中见到的封装绝大多数不是纯理论上的“字段私有化”而是一整套约定俗成的规范。以最经典的 Java Bean 为例所有字段都用private每个字段配getter/setter。这套规范之所以能流行最大的功臣是框架——Spring MVC 从前端接收 JSON 参数时要调用对象的 setterMyBatis 把数据库字段映射到实体时要调用 setterJackson 把对象序列化成 JSON 时要调用 getter。你如果不遵守规范框架要么报错要么序列化出空对象。在分层架构里“封装”的含义会被进一步扩大。我们通常要求Controller 层只接收和返回 DTO数据传输对象Service 层处理业务逻辑DAO/Mapper 层操作数据库。层与层之间不能乱串比如 Service 层的查询结果转换成 VO 返回给前端而不是直接把数据库实体丢给前端暴露所有字段。这本质上也是一种封装——把“内部表的字段结构”封装在数据访问层里不让它泄露到表现层。我见过不少三年左右经验的开发代码能力不差但封装意识薄弱。最典型的问题是一张表对应一个实体类这个实体类在 Controller、Service、Mapper 三个层通吃数据库字段名、前端字段需求全部揉在一个类里。前期很爽后期前端说“这个字段不能返回”你只能在这个实体类上加注解屏蔽数据库改了字段名前端接口文档又要跟着改。这种为了省事放弃封装习惯的写法欠下的技术债都会在发布前让你加班还。1.3 用“按需开放”的心态看待封装说白了封装就是一句话能不让人碰的就别让人碰必须让人碰的给个门口办手续。这句话在真实项目里延伸出很多实践字段一律private必要时才加 setter。如果一个字段只允许内部使用连 getter 都不该暴露。用final标记不允许被子类覆盖的重逻辑方法防止别人为了省事直接改你的核心算法。对外暴露的公开方法要有清晰的职责边界不要一个方法干了“校验写库发短信统计数据”四件事。我见过很多新人一开始很不适应这种“约束”觉得写代码不自由。但越往后做项目越会发现自由往往意味着灾难。一个类牵一发动全身谁都可以随手改它的内部状态最后没人能说清楚数据到底在哪一步被篡改的——这种现象有个名字叫“贫血模型 脱缰访问”。理解了封装的价值你写出来的类天然就有边界感别人接你的代码第一反应是“这哥们靠谱”。2. Java 包结构没它你的代码会乱成一锅粥单看“包”这个字很多人觉得它只是把类分门别类放好的文件夹。但我想说包结构在 Java 里承担着三层作用第一物理上管理源文件第二逻辑上划分模块边界第三控制类与类之间的访问权限还记得 1.1 里的 default 和 protected 吗它们都依赖包这个维度。说说命名。国际惯例是反写公司域名比如com.example、org.apache。这样做的真正优点是全球唯一性防止不同公司都写一个common、util包合并代码时撞车。域名的反写顺序在一定程度上也是从大到小的逻辑层级com.example.project.module读起来就像地址一样清晰。新手阶段最容易遇到的问题是一句报错“程序包com.example.xxx不存在”。排查思路也很简单先看类是不是真的在那个包路径下再看 import 路径写没写对最后检查是否在同一个包内——同一个包下的类根本不需要 import。这三个问题扫一圈八成能解决。2.1 import 和类加载的误区有个流传很广的误解import 是为了“把类加载进来”。这句话是错的。Java 的类加载发生在一个类被真正使用的时候import 只是在编译阶段帮你省掉全限定名的打字过程。举个最简单的例子你写import java.util.List;实际上和直接写java.util.List是等价的编译器在处理List这个标识符时会去 import 列表里查它对应哪个全限定名。你可以试试不写 import代码里全用java.util.List这种全限定名程序照样能编译运行——只是可读性差到爆。理解了这一点就不会出现“我 import 了怎么还是报类找不到”这种迷惑了因为找不到类本质上往往是类路径classpath里没有这个 jar跟 import 写没写没关系。再说说通配符import java.util.*。很多教程说“能不用就不用”理由是影响性能。实际上现代编译器对*的处理是懒加载查找编译性能影响微乎其微真正的风险是两个不同的包下存在同名的类比如java.util.List和java.awt.List你用了通配符又两个包都引了编译器就会懵。所以我的建议是IDE 自动帮你展开成具体类名是最省心的方式别自己手敲*。2.2 三层架构的包怎么划实际项目里的包结构虽然每家公司的规范略有差异但大方向是一致的。以最常见的“Controller-Service-Mapper”三层为例com.example.project ├── controller ├── service │ └── impl ├── mapper 或者 dao ├── entity 或者 model ├── dto └── vo每个包放什么规则很明确controller 层放接收和返回结果的类service 接口放业务契约service.impl 放具体实现mapper 放数据库访问接口entity 放数据库表映射实体dto 放接口层的数据传输对象vo 放前端展示模型。这里我特别说一下service和service.impl为什么拆两层。有人觉得“接口 实现”是脱裤子放屁。但当你的项目引入 Spring 的时候接口的作用就显现出来了Spring AOP 拦截的是接口方法调用你可以给整个接口定义切面比如统一事务、统一日志测试的时候还能轻松 mock 接口不用启动整个容器。就算不依赖 Spring接口也强制你站在“调用方”的视角审视方法签名而不是一上来就考虑实现细节。这个习惯对后续学习设计模式极其重要。包之间还有一个实际好处它能避免类名冲突。比如你在entity包里定义了一个User在vo包里也定义了一个User两个类分别对应数据库实体和前端返回模型。在 Controller 里引用时写vo.User user ...在 Service 里引用时写entity.User user ...各找各妈不打架。如果所有类都堆在同一个包你连这个能力都没有。3. 继承代码复用的双刃剑继承是面向对象里的经典话题但培训班教的往往太浅——无非是“子类继承父类的属性和方法省得重复写”。这句话没错但停留在表面。继承真正的价值不是“复制代码”而是建立一种“is-a”关系。Dog extends Animal意味着“狗是一种动物”所以狗天然拥有动物的一切通用属性和行为。理解了“is-a”这个模型很多语法就顺理成章了。比如 Java 是单继承的一个类只能 extends 一个父类。为什么不能多继承因为两个父类如果有同名同参方法子类就无法确定该继承谁这在 C 里需要引入“虚继承”等复杂机制来解决而 Java 设计师为了简化语言直接一刀切类之间单继承多继承的责任交给接口后面会讲。你看看很多“限制”不是缺陷是为了避免更严重的混乱。3.1 extends 背后发生了什么当你写class B extends A时JVM 层面会为 B 构建一个包含 A 全部字段和方法信息的继承链。实例化 B 的时候会先调用父类 A 的构造器再去执行 B 自己的构造器。这个顺序是硬性规定的因为子类对象本质上是在父类对象的基础上扩展出来的如果父类的字段都没初始化好子类就没法用。这里有个高频考题父类和子类都有静态代码块、普通代码块、构造器执行顺序是什么正确答案是父类静态代码块 → 子类静态代码块 → 父类普通代码块 → 父类构造器 → 子类普通代码块 → 子类构造器。记住两条规律静态的永远最先执行只要类被加载就执行构造器是最晚执行的前提工作得先做完。3.2 方法重写和 super 的配合子类可以重写override父类的方法这是多态的基础详见下一节。重写有几个硬性规则必须刻进脑子里方法名、参数列表必须完全一致。返回值类型可以是父类返回类型的子类型协变返回。访问权限不能比父类更严格。父类是public子类不能降级成protected。声明的异常不能比父类更宽泛这条后面讲异常时会重点展开。实践中我强烈建议你在重写方法上加上Override注解。它的作用不是“必须”而是让你在写错重写签名时编译器立即报错。比如父类方法是run()你手滑写成了rnu()没有注解这只是一个新方法有注解编译器直接告诉你“这个注解不合法”能帮你拦下一批隐蔽 bug。super关键字的作用也很好理解在子类里显式调用父类的方法或构造器。最常见的使用场景是子类的构造器第一行写super(参数)来指定父类构造器如果父类没有无参构造器子类构造器就必须显式调用一个带参的super。这里有个初学者常犯的错重写父类方法时忘了在逻辑里调用super.xxx()导致父类原有的核心逻辑被直接跳过了。比如父类的save()里有审计日志逻辑子类重写后忘记调用super.save()日志就丢了。正确做法通常是先super.save()办完通用逻辑再在子类里补充自己的逻辑。3.3 继承还是组合这是个问题我见到太多人把继承当成万能的复用手段了。一个类想复用另一个类的方法直接就extends到头来发现父类的私有字段子类访问不到父类的方法行为变了子类莫名其妙跟着变有些父类方法子类根本不该继承却在接口里全暴露了。业界有个更推崇的方案叫“组合优于继承”Composition over Inheritance。就是在一个类里持有另一个类的引用然后通过调用被持有对象的方法来实现复用public class Car { private Engine engine; public void start() { engine.ignite(); } }这样做的最大好处是耦合度低。继承是编译期就确定的强绑定子类和父类一旦相连想拆开就要大改组合是运行时组装想换引擎实现直接换对象即可不需要改类之间的关系。什么时候用继承合适我个人会给三个判断标准类与类之间有真实的is-a关系。父类的行为确实是被子类需要且需要被扩展的。不存在“子类继承了一个不该有的方法”的情况。如果第一期里的《订单》类想去复用《日志》类的功能直接继承就是错误答案注入一个日志对象更优雅。这条经验我每次评审代码时都要说真不是老生常谈——见过太多因为乱继承导致的牵一发动全身事故。4. 多态让同一句话干不同的事多态这个词初学者第一次看到容易懵。但说穿了特别简单同一类型的引用指向不同子类对象调用同一个方法产生不同行为。比如Animal a new Dog(); Animal b new Cat(); a.speak(); // 汪汪汪 b.speak(); // 喵喵喵a和b的类型都是Animal但调用speak()时实际执行的是Dog和Cat各自重写的方法。这就是多态。它在代码里最典型的体现就是你可以写一个接收Animal参数的方法传入任何动物的子类都能正确工作无需为每种动物单独写方法。多态在 Java 里分两种编译时多态和运行时多态。这个区分是面试高频更是理解语言机制的分水岭。4.1 编译时多态方法重载Overload同一个类里方法名相同参数列表不同数量、类型、顺序不同这叫重载。编译器在编译阶段就能根据你传入的参数类型确定调用哪个方法所以叫编译时多态。最典型的例子就是System.out.println()你传字符串、传整数、传对象都能打印说白了就是一堆println方法的重载。还有构造器重载一个类可以有多个构造器比如User()、User(String name)、User(String name, int age)。关于重载有一个容易让人纠结的细节返回值类型不同不算重载。int f()和void f()即使同名同参也不能共存。原因很简单——调用的时候只写f();不接返回值编译器根本分不清你到底想调哪个。所以判断重载只管参数列表别管返回值。还有个有趣的行为是重载时的静态类型绑定如果你把Dog对象赋值给Animal变量再调用一个重载方法编译器是按变量的声明类型去匹配重载版本的而不是按实际运行类型。这个细节一般要专门去踩坑才能深刻体会面试里出现频率不高但遇到了能答上来说明你理解很到位。4.2 运行时多态方法重写与动态绑定重写是子类对父类方法的重新实现它是运行时多态的基础。当 JVM 执行a.speak()时它并不看a的声明类型而是看a实际指向的对象类型然后调用该对象所属类的那一版方法。这个机制叫动态绑定。很多面试官喜欢追问“动态绑定是怎么实现的”。简单说JVM 在加载类的时候会给每个类生成一张方法表类似虚方法表 vtable里面记录着该类所有方法的实际入口地址。调用对象方法时JVM 先拿到对象的实际类型再去对应的方法表里查这个方法该跳到哪段代码。子类重写了就查到子类实现没重写就查到父类实现。有了这张表多态的查找效率很高这也是为什么 Java 默认情况下方法调用不像某些语言那样“笨重”。用动态绑定的视角再看一个经典问题字段没有多态性。Animal a new Dog();如果Animal和Dog都有name字段a.name拿到的是Animal.name而不是Dog.name。因为字段访问是静态绑定的编译时根据声明类型直接确定了只有方法才走动态绑定。所以千万不要在子类里“隐藏”父类字段也不要通过对象直接访问字段一切字段访问都塞到 getter 里能少踩一堆坑。4.3 向上转型、向下转型与 instanceof把Dog对象赋值给Animal变量叫向上转型upcasting隐式完成安全的。向上转型后你只能通过这个引用调用父类里声明过的方法调用不到子类独有的方法比如Dog.fetchBall()。这时候想调用子类特有方法怎么办需要向下转型downcastingAnimal a new Dog(); if (a instanceof Dog) { Dog d (Dog) a; d.fetchBall(); }向下转型是有风险的。如果a实际指向的是Cat你强行(Dog) a运行时会抛ClassCastException。所以向下转型前用instanceof判断是标准操作。关于instanceof新版 Java 支持了模式匹配你可以在判断的同时声明变量if (a instanceof Dog d) { d.fetchBall(); }这个语法在 Java 16 之后可用代码比老写法简洁一截掌握了之后在代码里用起来很舒服。但要提醒一句instanceof用得太多说明你的多态设计可能有问题。一个对象不同子类需要不同处理那这个“不同处理”本应该放在子类自己的方法里——这就是后面要讲的接口方法论。5. 抽象类和接口设计契约的两张牌如果说封装、继承、多态是 Java 的“语法三宝”那么抽象类和接口就是把这三宝揉在一起形成设计约束的工具。我遇到过很多初学者代码敲了不少抽象类和接口的概念天天背但一写业务就不知道怎么用。我尽量从“它们各解决什么问题”讲起。抽象类解决的是“一群类有一些公共实现但有一部分行为没法统一的场景。”比如动物类狗叫“汪”猫叫“喵”共同的行为是“吃”可能都差不多那可以在抽象类里实现eat()把speak()声明成抽象方法交给子类各自完成。而接口解决的是“你不需要它们有什么公共实现只需要它们能做什么”。抽象类和接口表面上看很像——都不能实例化、都能定义抽象方法。但它们的定位完全不同一个偏“模板骨架”一个偏“能力契约”。5.1 抽象类的玩法模板方法模式抽象类是不能被实例化的类它存在的意义是被继承。类里可以有抽象方法没有方法体的方法也可以有普通方法、字段、构造器。它的设计意图是把子类共用的代码写好把差异化的部分留成抽象方法让子类填空。这就是著名的模板方法模式。举个真实例子你在做一个多类型文件的导入功能流程都是校验文件格式 - 读取数据 - 解析成实体 - 校验业务数据 - 落库。不同的文件格式Excel、CSV、JSON解析方式不一样但流程骨架完全一样。用抽象类写出来就是public abstract class FileImporter { // 模板方法流程固定 public final void importFile(String path) { checkFormat(path); ListString rawDatas readData(path); ListEntity entities parse(rawDatas); validate(entities); save(entities); } private void checkFormat(String path) { /* 通用实现 */ } private ListString readData(String path) { /* 通用实现 */ } protected abstract ListEntity parse(ListString rawDatas); protected abstract void validate(ListEntity entities); private void save(ListEntity entities) { /* 通用实现 */ } }子类只需要实现parse和validate整个导入流程自动跑通。这个方法在框架源码里极其常见你读 Spring 源码的时候会发现到处都是这种套路。importFile我特意标了final就是防止子类重写掉整个流程把模板的骨架焊死。5.2 接口的进化史Java 8 之前接口非常“纯粹”只能声明抽象方法无方法体无字段只有常量。这种纯粹带来的好处是——接口天然轻量实现类想怎么玩都行。最大的坏处是接口一发布全世界的实现类都得被迫实现新方法否则编译就挂。为了缓解这个痛点Java 8 引入了默认方法default method接口里可以写有方法体的方法实现类不重写也能用。这直接导致了一个有趣的现象接口从“纯抽象”变成了“含默认实现”和抽象类的边界模糊了一些。紧接着 Java 8 还引入了静态方法和函数式接口。一个接口里如果只有一个抽象方法它就是函数式接口可以用 Lambda 表达式直接实现。这意味着你可以把接口当成“行为参数”传递写出来的代码比匿名内部类简洁无数倍。比如Runnable task () - System.out.println(hello); ComparatorString comparator (s1, s2) - s1.length() - s2.length();Java 9 之后接口还支持私有方法用来抽取默认方法里的公共逻辑。到这里接口的能力已经很强大了。所以 5.3 的对比必须基于“现在的 Java”而不是“十年前的书本”。5.3 抽象类 vs 接口别再死背表格了这道题算是 Java 面试八股文里的常青树。网上有很多对比表格字段权限不同、方法实现不同、继承数量不同、语义不同……但光背这些面试官下一句“你在项目里怎么选”就能把你打回原形。我的实际选择方法论是这样的如果你要描述的是一组类的本质身份is-a且有通用状态和逻辑需要复用用抽象类。比如“支付处理器”微信和支付宝都是一种支付处理器它们共享签名校验、日志记录这些通用逻辑差异只在具体下单接口。如果你要描述的是一组类具备的能力can-do例如“可序列化”“可比较”“可关闭”用接口。这些能力可以被完全不相关的类共享。如果你面对的是“多角色行为组合”比如一个类既是“可持久化的实体”又是“可导出报表的对象”Java 单继承无法满足只有靠实现多个接口。还有个现实维度是演进和兼容。接口加新方法用 default method 可以向后兼容语法层面很平滑。抽象类加一个非抽象方法是没有任何问题的但如果加的是抽象方法所有子类就都要改所以抽象类的抽象方法集合要相对稳定。从架构演进来看大型系统更偏爱接口因为接口能隔离实现让模块之间只依赖契约而不是具体类。单体/小型项目里两个都用风格看团队规范。我最后给你一个很实用的判断办法打开 IDE 的类继承关系图如果一个抽象类的子类们除了继承这个父类之外没有其他共同点那大概率可以用接口替代如果一个接口的多个实现类有大量重复的实现代码那可以考虑把公共实现上提到抽象类。代码味道是骗不了人的。6. 常用的基础类每天都在用却不一定全懂Java 的 JDK 自带一大堆类这一节我只挑几个使用频率最高、面试也经常考的基础类String、StringBuilder、StringBuffer、包装类。这些类没有复杂的设计模式纯粹是“用得多了才会出问题”。6.1 String 的不可变性String 是final的且内部字符数组也是final的这意味着字符串一旦创建就无法被修改。你写的str str abc本质是创建了一个新的 String 对象然后让变量指向新对象原来的字符串还在原地变成了垃圾等待回收。为什么 String 要被设计成不可变核心原因有三点**字符串常量池String Pool**可以放心复用同一个对象因为不可变所以不会出现多个引用互相篡改数据。安全性String 被广泛用作方法参数、文件路径、网络地址如果可变恶意代码修改一个字符串对象会影响所有引用它的地方。线程安全不可变对象天然线程安全无需加锁。字符串常量池这个东西建议你做一个测试感受一下String s1 hello; String s2 hello; System.out.println(s1 s2); // true因为直接量会去常量池复用 String s3 new String(hello); System.out.println(s1 s3); // falsenew 一定创建新对象这里有两个坑要注意第一compareTo才比较内容比较的是引用地址——这几乎是 Java 新人第一年必踩的坑判断字符串相等千万别用要s1.equals(s2)第二字符串拼接时如果用循环累加会反复创建新对象性能极差遇到循环拼接请直接上 StringBuilder。6.2 StringBuilder 和 StringBuffer 的选择StringBuilder和StringBuffer几乎一样区别在于 StringBuffer 的方法是synchronized修饰的线程安全性能略低。单线程环境下99%的场景用 StringBuilder 就够了。多线程环境下你不该共享同一个 StringBuilder 变量所以多数项目的规范是优先 StringBuilderStringBuffer 基本不用。日常开发里字符串拼接最常见的场景是拼接 SQL 动态条件、拼接日志、拼接 XML/JSON 文本。这些场景我都建议用 StringBuilderStringBuilder sb new StringBuilder(); sb.append(SELECT * FROM user WHERE 11); if (name ! null) { sb.append( AND name ).append(name).append(); } return sb.toString();append方法内部有链式调用设计所以写法上可以连续点。再强调一次能用append就不要用特别是在循环里一个的性能损耗可能会让你线上接口的 TPS 掉一个量级。6.3 包装类与自动装箱的那些坑Java 是面向对象语言但int、double、boolean这些基础类型不是对象。为了把基础类型放进集合、泛型体系里Java 给每个基础类型提供了包装类Integer、Double、Boolean等。自动装箱就是int自动变Integer拆箱就是反过来。代码写起来很爽但这里埋着一个非常经典的面试陷阱Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false原因在于 Integer 内部有一个缓存池IntegerCache默认缓存-128~127之间的值。在缓存范围内的装箱操作会直接返回缓存中的同一个对象超出范围就创建新对象。所以比较包装类是否数值相等一律用equals不要用。其他包装类也有类似的缓存机制你可以举一反三。还有一个隐蔽的坑包装类参与算术运算时会自动拆箱如果引用是null一拆就会抛NullPointerException。比如Integer count null; int total count 5; // NPE这种错误在从数据库取可空字段然后做累加的场景里特别常见。我的建议是数据库字段可空就一律用包装类型做运算前先判空能用Optional就用Optional别相信“这里绝对不会是 null”这种话。7. 异常处理程序出问题时你能做的比想象的多异常处理可能是这一篇里最接近“生产环境实战”的章节。因为前面那些概念多半是设计层面的而异常是你每天都会打交道的——只不过很多人处理得很粗糙要么try-catch一包到底要么干脆throws Exception全抛出去结果线上日志啥也看不出来。7.1 Throwable 家族和两类异常Java 的异常体系根上是Throwable下面派生出Error和Exception。Error是 JVM 层面的严重错误比如OutOfMemoryError、StackOverflowError正常情况下你不需要捕获也无法处理。Exception才是程序层面的问题又分成两大类受检异常Checked Exception编译期强制你必须处理不处理编译就过不去。比如IOException、SQLException。这类异常的设计意图是“这是可能发生且你该提前应对的”。非受检异常Unchecked Exception/RuntimeException继承自RuntimeException编译期不强制处理运行时才可能抛出来。比如NullPointerException、IllegalArgumentException、ClassCastException。很多 Java 教程会告诉你“受检异常是好设计逼你处理问题”但实际大型项目里反而对受检异常怨声载道。你不处理就编译失败处理了又经常处理不了只能在方法上throws Exception一层层往上冒。所以现在很多新语言在异常设计上已经不再区分这两类Java 也越来越多地在业务代码里自定义 RuntimeException。我的经验是业务逻辑上的异常尽量用非受检异常让事务回滚和全局异常处理器去统一处理代码会更干净。7.2 try-catch-finally 和 try-with-resources经典的异常处理三板斧try { // 可能出异常的代码 } catch (IOException e) { log.error(读取文件失败, e); } finally { // 无论如何都会执行的清理代码 }写 catch 时一个重要很容易被人忽略的原则不要捕太粗的异常要按异常类型分开捕获。你写catch (Exception e)虽然能一把抓但它把FileNotFoundException和ArrayIndexOutOfBoundsException混在一起排查问题时要靠日志内容猜。宁可多写两个 catch 块让每个异常都有对应的处理路径。finally的作用是释放资源。但是 Java 7 之后try-with-resources 语法让资源释放变得更优雅只要资源实现了AutoCloseable接口try (FileInputStream in new FileInputStream(a.txt)) { // 使用 in } catch (IOException e) { log.error(读取失败, e); }你不用写 finally 关流语法自己会关而且异常堆栈信息更完整。这个特性用起来太爽了新人第一次接触时容易怀疑“这样写的代码会不会忘关资源”——不会Java 编译器会翻译成带 finally 的传统代码只是帮你省了手写。7.3 自定义异常与处理最佳实践自定义异常看起来很高大上其实很简单写一个类继承RuntimeException即可。关键是设计要有章法一个异常类代表一种业务语义比如BizException、AuthException、ParamInvalidException。异常类里可以带业务错误码方便前端根据错误码做不同提示。构造器保留一个接受Throwable cause的版本用于异常链的传递。我见过最好的异常处理实践是“Controller 层统一处理Service 层只抛不接”。也就是说业务代码里遇到异常直接throw new BizException(订单不存在)让 Spring 的RestControllerAdvice全局异常处理器统一捕获并转换成 JSON 返回给前端。这样你写的业务方法里就没有密密麻麻的 try-catch只负责表达“我这里出问题了”至于怎么响应用户、怎么记日志全部集中在全局处理器里。这套模式几乎是现代 Java 项目的标配如果你所在的项目还没有这么做强烈建议重构。处理异常时的几条军规我踩过的坑全在这里了不要在 catch 里把异常吞了比如catch (Exception e) { }空 catch 最要命线上出问题你连日志都没有。不要用异常控制正常流程。比如用异常判断用户输入是否符合规则这非常浪费性能而且会让调用方提心吊胆。该用 if 判断就 if 判断。catch 到的异常记得打日志带异常对象比如log.error(xxx失败, e)光打印e.getMessage()会丢掉堆栈信息。finally 里不要 return这个坑会直接让异常被吞掉。在 catch 中捕获具体异常后重新包装抛更上层的业务异常时要把原异常作为 cause 传进去不然根本不知道根因是哪一层出的。8. 用基础和设计应对千变万化的业务前面这些概念看着多但它们其实是一棵联动的树。不懂封装类里的字段就是公共裸奔不懂包结构项目一大了就乱成一团不懂继承和组合你永远做不好代码复用不懂多态你写 if-else 会写到怀疑人生不懂抽象类和接口你无法拆出优雅的扩展点不懂常用类和异常你的代码在性能和稳定性上到处是雷。这几个月我在点评新同事代码的时候发现一个问题特别普遍他们能写出能跑的代码但代码之间“黏连太重”。一个方法里能写五十行 if-else一个类能塞下三种职责。这不是他们不懂语法而是没把封装、多态、接口这些概念当成日常工具。面向对象编程说到底就一句让变化的地方独立变化让不变的地方稳定复用。封装是管好边界继承和组合是复用多态是替你把 if-else 消灭在对象关系里接口和抽象类是让系统留出插槽异常处理是让系统在出错时也能体面收场。按我个人的学习经验这个阶段你一定不要贪快。每学完一个概念就去看看 JDK 源码或 Spring 源码里是怎么用它——打开ArrayList看看它继承了什么、实现了什么接口、重写了哪些方法、哪些方法有Override注解比刷一百道选择题都管用。如果你想进阶下一步可以去研究 Java 集合框架里的 equals/hashCode 契约、泛型的类型擦除以及 JVM 对方法调用的优化。这些内容我会在基础篇四、五里慢慢展开你先把这个老三样——“封装继承多态”——吃透后面学什么都顺。最后分享一个“偷懒”小技巧以后写完一个类先别急着往下写在脑子里问自己三个问题这个类对谁可见它被谁继承或实现它的哪些方法允许被重写三个问题回答完你的代码大概率已经是一个经过设计的代码了。这就是把这篇文章所有知识点串成一条线的捷径。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑