Java异常处理实战:高频错误排查、性能优化与面试指南

发布时间:2026/9/30 15:35:49
Java异常处理实战:高频错误排查、性能优化与面试指南 做Java开发这些年天天跟异常打交道。NullPointerException、ConcurrentModificationException、ClassCastException……光是这几个高频错误就已经劝退了不少刚入行的朋友。异常处理这件事看起来只是try-catch-finally几个关键字但真正到了线上排查、性能优化和面试扯淡的时候才发现里面的门道比想象中多得多。这篇内容不打算讲教科书里那套分类我直接把自己踩过的高频错误坑、排查套路和优化技巧整理出来适合正在写业务代码的初中级Java工程师也适合准备Java面试的人拿去做复习提纲。异常处理其实反映了一个人对代码边界条件的敏感度。很多项目前期跑得欢一上线就崩问题往往不是出在业务逻辑复杂度上而是出在异常被吞掉、资源没释放、边界没控制这些“不起眼”的地方。接下来的内容全部来自我真实踩坑后的复盘不绕弯子每条都能直接落到代码里。1. 高频异常类型深度解析与排查套路1.1 NullPointerException空指针其实最值得深挖空指针在Java高频错误里排名第一几乎每个Java开发者每天都要见几次。但很多人对空指针的理解停留在“某个对象是null”却没有真正建立一套系统的排查思路。最常见的触发场景有这么几类方法返回值为null后直接调方法、从Map或JSON里取出的值没有判空、ORM框架查询单条数据没查到返回null、外部接口响应体为null但没有做防护。这些场景有一个共同特征调用链上某个环节“静默”地返回了null而代码里没有提前拦截。排查空指针时我常用的套路是三步走。第一步看异常日志最底部“Caused by”信息确认到底是哪一行代码报的错别光看异常类型就慌了行号才是定位的关键。第二步沿调用链往上反向追踪看这个对象是从哪来的是参数传入、方法返回还是容器取出重点排查数据源头。第三步如果日志行号不明确经常发生在lambda表达式或动态代理场景里就在可疑位置临时加日志把关键变量的值打出来不要凭感觉乱猜。这里有一个很大的优化空间Java 14开始提供了Helpful NullPointerException启动时加上-XX:ShowCodeDetailsInExceptionMessages参数JVM会直接告诉你哪个引用是null比如Cannot invoke String.length() because name is null。这个参数强烈建议在开发环境开启能省掉一大半猜空指针的时间。防御层面我个人的实践原则是外部数据边界一律判空内部逻辑尽量fail-fast。对外部接口返回值、JSON解析结果、数据库查询结果用Objects.requireNonNull或者显式判空提前拦截对内部方法调用如果有参数不合法尽早抛出带上下文的异常而不是让NPE在几十层调用之后爆出来。很多团队用Optional满天飞我反而建议谨慎Optional适合作为方法返回值提示调用方“可能为空”但不适合做字段类型、不适合做方法参数滥用Optional只会让代码更绕排查起来更痛苦。注意千万别在catch了NullPointerException之后写一句return null或者return new Object()糊弄过去。空指针的本质是代码有漏洞吞掉它只会让问题下沉到更深的调用链变成某个诡异的数据错乱到时候你连怎么死的都不知道。1.2 ClassCastException与数组越界异常类型和边界的双重考验ClassCastException类型转换异常在高频错误里排名也很靠前。它最常出现的地方是从List或Map里取出Object后强转成具体类型、JSON反序列化时泛型丢失导致的类型不匹配、反射调用时Class与预期类型不一致。Java的泛型是擦除式的ListString在运行时等价于ListObject所以从集合里取元素再强转编译器不会报错但运行时就可能炸。排查这类异常核心思路是确认“实际类型”和“期望类型”是否一致。如果数据来源是第三方接口或者数据库字段先用反射或者toString看看真实类型别急着改代码很多时候是上游给你塞了意想不到的数据结构。预防ClassCastException我在代码规范里定了两条硬性要求。第一从集合取元素后如果需要强转一定要先instanceof校验特殊业务场景可以写一个类型安全的转换工具类统一处理。第二涉及JSON序列化和反序列化的地方优先使用带TypeReference的API比如Jackson的TypeReferenceMapString, User避免泛型信息丢失。ArrayIndexOutOfBoundsException和StringIndexOutOfBoundsException本质上是边界问题。数组越界大多发生在循环里i1、i-1这类索引运算中或者分页计算起始位置时出现负数。字符串越界则常见于substring(beginIndex, endIndex)的参数没做范围校验。这类问题没什么玄学就是写循环和截取操作前算清楚边界条件尤其是循环的终止条件。我踩过一个印象很深的坑处理Excel导入时按行读取数据某行数据缺失导致数组长度为0但我直接取了row[2]结果ArrayIndexOutOfBoundsException。后来总结的教训是从外部数据源获取数组或列表后不能只判断是否为null还要判断长度是否符合预期。判断长度这件事写一个requireLength之类的工具方法比在业务代码里手工写一大堆if (arr.length 3)要清爽得多。1.3 ConcurrentModificationException遍历时修改集合的经典陷阱ConcurrentModificationException是集合遍历过程中最常见的并发相关问题但它不只是多线程才会触发单线程里同样会踩。只要在遍历ArrayList的同时执行add或remove操作就会触发这个异常。原因是ArrayList内部的modCount和expectedModCount不一致时迭代器会快速失败fail-fast。很多人一看到“Concurrent”就以为是并发问题实际上单线程的for-each里删除元素是最典型的触发场景。比如这段代码就会炸for (String item : list) { if (item.startsWith(test)) { list.remove(item); // 直接改集合迭代器检测到modCount变化 } }正确的处理方式有三种。如果你用的是普通ArrayList想遍历时删元素必须使用迭代器的remove方法IteratorString iterator list.iterator(); while (iterator.hasNext()) { String item iterator.next(); if (item.startsWith(test)) { iterator.remove(); // 迭代器自己删除不会抛异常 } }如果数据量小、读多写少直接换成CopyOnWriteArrayList也可以。它的迭代器基于snapshot遍历时随便改原集合都不会抛异常。但注意CopyOnWriteArrayList每次写操作都会复制底层数组写频繁的场景性能开销很大不能盲目替换。如果是先收集再统一删除那就用一个临时List记录要删的元素遍历结束后一次性removeAll。还有一个容易被忽略的细节多线程环境下如果遍历的同时其他线程修改集合也会抛ConcurrentModificationException。这种情况下光改遍历逻辑没用要加锁或者换并发容器。我的经验是业务代码里如果出现这个异常先别急着换容器先看是“遍历时修改”还是“真正的并发写”两者的解决方案完全不一样。2. 异常处理的反模式与正确姿势2.1 吞异常与日志规范别让错误变成噪音异常处理最大的反模式就是空catch块。catch (Exception e) { e.printStackTrace(); }这种代码除了把堆栈打到控制台之外什么都没做在高并发场景下还可能把日志系统刷爆。更可怕的是catch (Exception e) { }一个字都不写出了错误你连看都看不到完全是在给线上埋雷。我理解很多人的心理catch了异常总得做点什么但又不知道该做什么于是就打出来或者写一句空日志。这其实是设计问题不是编码问题。异常处理只有三个正确方向第一可以处理的异常处理完及时恢复。比如重试一次远程调用、使用降级数据、把当前任务丢进死信队列。第二不需要处理的异常往上抛让上层统一处理。比如业务规则校验失败抛一个带错误码的业务异常。第三无法恢复的异常记录下来并标记失败状态在合适的出口如Controller的ExceptionHandler统一返回而不是在catch点打一枪就跑。日志规范方面我有几个实操建议。一是永远不要用System.out.println打日志线上环境没人会盯着控制台看必须走SLF4JLogback这套标准组合。二是日志要带上业务上下文比如订单号、用户ID、请求链路ID。排查线上问题时“订单号为null”和“订单号12345在处理xx步骤时失败”是完全不同的两个日志后者能帮你直接定位问题。三是异常日志要保留堆栈。很多同事图省事只打e.getMessage()结果线上报错只说了一句“null”或者“timeout”堆栈在哪、哪一行报的、调用链是什么样全部丢失这种日志等于没有。注意catch (Exception e) { log.error(e.getMessage()); }是典型的错误用法应该写成log.error(业务描述, 关键参数{}, 参数, e);最后一个参数e会把完整堆栈打出来。这是最基础也最容易被忽视的日志规范。2.2 finally、return与资源释放的坑finally块里写return是异常处理里非常经典的一个坑。如果方法在try块里已经决定要return一个结果而finally块里又有一个return语句那finally里的return会覆盖try里的返回值。同理如果try块里抛出了异常finally里有return的话异常会被直接吞掉调用方什么都感知不到。这段代码就是反面教材public int getResult() { try { return compute(); // 可能抛异常 } finally { return 0; // 如果compute抛异常返回值变成0异常被吞掉了 } }为什么Java允许这种写法这是语言层面遗留的坑。finally的语义是“无论是否发生异常都必须执行”但如果在finally里写了return它就会覆盖try里的所有结果包括异常。业界普遍的观点是永远不要在finally里写return。如果非要返回默认值放在catch块里处理至少异常还有机会被记录。资源释放是另一个高频问题。流、连接、锁这类资源如果不在finally里释放就会出现连接泄漏、锁无法释放、文件句柄耗尽这些问题。Java 7之后有了try-with-resources这个语法应该是处理AutoCloseable资源的默认首选try (FileInputStream fis new FileInputStream(file); BufferedReader reader new BufferedReader(new InputStreamReader(fis))) { // 读写逻辑 } catch (IOException e) { log.error(读取文件失败, e); }try-with-resources会自动按逆序关闭所有实现了AutoCloseable的资源不需要手动写finally。这里有个小细节try-with-resources的close方法本身也可能抛异常如果close异常和业务逻辑异常同时发生业务异常会被优先抛出close异常会被添加到suppressed列表里。排查异常时可以看getSuppressed()方法不要因为少了某个异常信息就一脸蒙。锁的释放同样要放在finally。JVM的synchronized会自动释放锁但Lock.lock()这种显式锁不会必须在finally里unlock。我见过生产事故就是因为Lock使用不当线程直接卡死锁永远不释放最后只能重启应用。规则很简单lock之后立刻跟tryfinally里unlock中间不要混入多余逻辑。2.3 自定义异常与异常粒度让错误码说话很多Java项目的异常体系是混乱的要么所有方法都抛Exception要么干脆全局捕获。真正成熟的工程里会有一套清晰的自定义异常体系。我的实践方案是把异常分成两类业务异常和系统异常。业务异常指的是可以预见的规则性错误比如“库存不足”“金额超过限制”“用户已存在”这类异常应该有明确的错误码和错误消息抛出后由全局统一拦截返回给前端。系统异常指的是数据库连接失败、Redis不可用、网络超时这类底层错误这类异常通常不需要对外暴露细节但必须完整记录堆栈方便运维排查。自定义业务异常的基础结构通常是public class BizException extends RuntimeException { private final String code; public BizException(String code, String message) { super(message); this.code code; } // getter... }为什么继承RuntimeException而不是Exception核心原因是非检查异常不需要在方法签名里强制声明业务代码里可以任意抛出不会被编译器的throws约束绑架。我见过很多老项目把自定义异常设计成检查异常结果每个方法都要加throws声明改一处异常影响一大片调用方维护成本极高。当然如果你们团队的项目规范就是要求显式处理异常那另说。但从工程实践角度看业务异常用RuntimeException是更普遍、更省心的方案。异常粒度的设计也值得讲究。我见过有人为每一个业务场景定义一个异常类比如OrderNotExistException、UserBlackListException、GoodsSoldOutException类数量爆炸维护起来像灾难。更合理的做法是定义一个BizException配合枚举错误码一个异常类覆盖所有业务错误。错误码的枚举设计要分模块、分区间比如用户模块10001-20000、订单模块20001-30000通过错误码就能快速定位是哪个模块的哪个业务分支出了错。3. 异常性能优化与JVM参数调整技巧3.1 异常创建的隐藏成本为什么循环里抛异常这么慢Java里创建异常的代价比想象中高得多核心开销在fillInStackTrace()。每创建一个Throwable对象JVM都要去抓取当前线程的方法调用栈然后填充到异常对象里。这个操作涉及栈帧遍历和内存分配在高频调用场景下非常昂贵。我自己做过一个粗糙的性能对比在百万次循环里分别用“抛异常标记错误”和“返回值判断错误”两种方式处理前者的耗时大概是后者的几十倍。这不是危言耸听异常创建和抛出确实会拖慢系统。这个问题的本质是异常应该用于“异常情况”而不是常规流程控制。比如检查参数格式是否正确不应该靠抛异常来反馈结果数据不存在时返回null或Optional.empty()也比抛异常更合适。很多从其他语言转Java的同学容易犯这个错用异常做业务分支判断结果系统TPS上不去一压测就超时。3.2 OmitStackTraceInFastThrow与日志“缺堆栈”之谜这里有个很有意思的坑我在排查线上问题时遇到过某个NullPointerException在测试环境能打出完整堆栈到了线上日志里却只剩一行“java.lang.NullPointerException”没有at xxx堆栈信息。很多人以为是日志框架配置问题实际上这是JIT编译器做的优化。HotSpot虚拟机有一个优化开关叫OmitStackTraceInFastThrow默认是开启的。当一个异常类型在同一个位置反复抛出比如循环里的NPEJIT会把异常对象重新创建成一个“轻量级异常”不再填充堆栈信息。这是为了节省性能开销但结果就是日志里看不到堆栈。要解决这个问题线上JVM启动参数可以加上-XX:-OmitStackTraceInFastThrow强制关闭这个优化。代价是每次抛异常都会填充完整堆栈性能上有一点损耗。但对比排查问题的难度这点损耗完全可以接受。我的建议是线上环境统一加上这个参数反正解决了大量“日志没堆栈”的困惑。另外还有一个小技巧如果某个异常确实高频发生、又不需要完整堆栈可以覆写fillInStackTrace()方法让它什么都不做这样创建异常的开销会大幅下降public class FastException extends RuntimeException { Override public synchronized Throwable fillInStackTrace() { return this; // 不填充堆栈降低创建耗时 } }但注意用这种方式创建的异常没有堆栈信息出了问题难以追踪只适合把它当流程标记用不适合常规异常。常规异常还是老老实实保留堆栈。3.3 try-with-resources与事务、锁的边界控制异常处理和资源管理经常纠缠在一起除了前文说的流和连接事务和锁的边界控制也容易出问题。Spring的声明式事务Transactional和try-catch组合有一个经典陷阱。事务方法内部如果catch了异常但没有重新抛出Spring的默认回滚策略就失效了异常被吞掉事务照样提交数据一致性直接崩掉。我见过一个真实事故转账服务里catch了余额不足的异常打了日志就return了结果事务没有回滚钱还是扣了用户投诉一片。排查思路是在Transactional方法里如果必须要catch异常确保业务失败时重新抛出RuntimeException让AOP感知到并触发回滚。或者使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记事务回滚但这种方式不推荐代码侵入性太强还是抛异常最自然。锁的问题也类似。ReentrantLock等显式锁必须保证try-finally释放但有一种更深层的坑是锁内调用远程服务或者数据库操作万一远程超时锁的持有时间异常拉长阻塞其他线程。异常处理方案是给锁操作加tryLock(timeout)超时后直接走降级分支不要无限等待。3.4 异常链路追踪从堆栈到业务链路传统堆栈只能告诉你“在哪一行出错了”但回答不了“这个请求从哪来、参数是什么、往哪走”。在微服务和分布式系统里只靠堆栈远远不够。所以异常排查一定要结合TraceId、链路追踪和业务日志。一种低成本的做法是在入口网关生成一个TraceId塞到MDCMapped Diagnostic Context里日志配置里统一打印TraceId这样同一个请求的所有日志包括异常堆栈都带同一个ID日志系统里一搜就全出来了。如果用了SkyWalking这类全链路监控工具异常还要关联spanId和serviceName排查跨服务问题时能快速在地图上看到链路断点在哪个节点。具体的日志格式建议是[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%X{traceId}] [%-5level] [%logger{36}] - %msg%n。这个格式里TraceId打出来配合日志采集系统按TraceId搜索效率提升非常明显。4. 高频框架异常排查实录4.1 Spring容器与事务类异常Spring应用里最常见的容器异常是NoSuchBeanDefinitionException含义就是找不到对应的Bean。这个异常本身很好懂但排查起来有几个隐蔽的点Bean有没有加Component等注解、包路径有没有被扫描到、Bean的构造是不是抛了异常导致创建失败、是否存在多个同类型的Bean导致类型注入失败。我记得有个项目启动时报NoSuchBeanDefinitionException查了半天发现新写的Service类忘记加Service注解包扫描路径也不包含这个子包。这类问题就是检查组件扫描范围。另外如果配置类里定义了模糊的Bean方法返回的Bean名称和类名不一致也会导致按名称注入失败。事务相关的Transaction rolled back because it has been marked as rollback-only也很经典。这个异常往往发生在两个Transactional方法嵌套调用时内层方法把事务标记为rollback-only外层方法在try-catch里捕获了异常但没重新抛出提交事务时发现事务已经被标记回滚就抛这个异常。出现这个问题的根源是Spring事务默认采用代理机制同一个线程内嵌套事务如果内层标记了回滚外层没有办法改变这个决定。解决方案有三种内层方法事务传播改为REQUIRES_NEW让内层事务独立外层方法不要catch内层的异常直接让它往上抛或者内层方法不要用事务把业务逻辑调整成无事务模式。具体用哪种要看业务语义不能无脑套一个方案。4.2 MyBatis与数据访问异常TooManyResultsException是MyBatis里的高频异常语义是“期望查询一条记录结果却返回了多条”。这类异常常见于使用selectOne方法时SQL条件没有命中索引、联表查询产生重复数据、或者数据库里存在并发写入的脏数据。排查这类异常第一件事是把SQL日志打开看一下MyBatis打印的SQL日志里有完整的SQL语句和参数直接复制到数据库里执行一遍就能看到到底是查出了几条记录。然后分析导致重复的条件是逻辑问题还是数据脏再决定是修SQL还是修数据。BindingException: Invalid bound statement (not found)也是让很多新手崩溃的异常。出现这个错误要么是Mapper接口的方法在XML里没有对应的Statement要么是XML的namespace写错要么是mapper-locations配置没扫描到XML路径。排查顺序先看接口名和XML的namespace完全一致再看方法ID和方法名一致最后看target/classes目录里XML有没有被打包进去。Spring Boot项目里XML放到了resources目录但构建插件没有把它复制到最终jar包里会出现开发环境正常、打包后全部报BindingException的情况。还有一类数据访问异常是权限相关的问题比如行级权限的控制条件没生效时用户能查到越权数据这种问题不会报异常但比异常更难排查。我的建议是行级权限的SQL条件统一注入在MyBatis拦截器里动态拼接不要分散在每个SQL里手写否则权限条件漏一步线上就是数据安全事件。4.3 并发与分布式场景异常Java并发编程里的异常排查最让人头疼的是OutOfMemoryError: Java heap space和OutOfMemoryError: unable to create new native thread。前者是堆内存不够常出现在高并发查询、批量处理过多数据、缓存无上限增长的场景中后者是操作系统的线程数达到上限多出现在线程池配置不合理、每请求创建一个线程的项目里。排查OOM的方法最关键的是拿到堆转储heap dump。启动参数加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/dumpOOM发生时自动生成dump文件然后用MAT或JProfiler分析。为什么OOM只靠堆栈不够因为堆栈只能告诉你哪一行分配了大对象但真正的问题是整个堆里对象占用情况需要看dump才能分析出是缓存存了太多数据、还是某个集合无限增长、还是大数组一次性加载了过多数据。线程数耗尽的问题更隐蔽。unable to create new native thread不是Java堆空间的问题而是JVM向操作系统请求创建线程失败。排查要看操作系统的ulimit -u线程数限制、进程本身的线程数、线程池大小配置。很多项目的线程池用Executors.newCachedThreadPool()在高并发下线程数可以无限膨胀直到系统资源耗尽。解决方案是把线程池改成有界队列、有最大线程数的ThreadPoolExecutor并配合合理的拒绝策略。数据一致性相关的异常常见于分布式事务场景。比如分布式锁释放时抛异常、MQ消息重复消费导致的幂等冲突。排查分布式锁问题核心是确认锁的加锁、业务操作、解锁三个环节的原子性和异常路径。如果业务执行过程中抛了异常导致锁没释放后续请求会全部阻塞如果锁的过期时间设置太短业务还没执行完锁就自动释放了其他线程就会拿到锁造成并发冲突。解决方案是使用Redisson这类支持看门狗自动续期的分布式锁过期时间设长一点解锁放在finally里。4.4 高频异常速查表异常类型典型场景核心排查思路推荐解决方案NullPointerException调用为null的对象方法看堆栈行号反查数据来源外部数据判空、fail-fast、开启Helpful NPEClassCastException强制类型转换失败确认实际类型与期望类型instanceof校验、TypeReference反序列化ArrayIndexOutOfBoundsException循环索引越界检查循环边界和数组长度长度校验、边界工具方法ConcurrentModificationException遍历时修改集合确认是单线程还是并发修改iterator.remove或CopyOnWriteArrayListNoSuchBeanDefinitionException找不到Spring Bean检查组件扫描和Bean定义修正包路径、检查注解TooManyResultsExceptionMyBatis查询返回多条打开SQL日志复现修正SQL条件、处理脏数据BindingExceptionMapper绑定关系缺失检查namespace/ID/XML打包统一配置mapper-locationsTransaction rolled back事务回滚标记冲突分析嵌套事务传播行为调整Propagation或异常处理逻辑OutOfMemoryError堆内存/线程数不足获取heap dump分析对象占用优化缓存、线程池参数、启动参数5. 面试中的异常处理高频考点与回答思路既然Java面试是很多人搜索这个话题的初衷我这里把异常处理相关的面试核心考点也梳理一遍。关于“checked exception和unchecked exception的区别”回答要点是检查异常继承Exception但不继承RuntimeException必须在编译期显式处理try-catch或throws非检查异常继承RuntimeException编译期不强制处理。实际工程中大部分框架Spring、MyBatis等抛出的都是非检查异常业务自定义异常也通常设计成RuntimeException。关于“catch块中异常的处理顺序”面试官主要想考察你是否理解多态和异常匹配。多个catch块的顺序必须是子类异常在前、父类异常在后否则子类异常永远无法被匹配到。比如先写catch(Exception e)再写catch(IOException e)编译器会直接报错。关于“finally块是否一定会执行”这个经典问题的答案是一般情况下finally块一定执行但如果JVM在try块中执行了System.exit()、或者所在线程被强制终止finally可能不会执行。前面提到的finally里写return会吞掉异常也是面试官常挖的坑务必记得这个点。关于“try-with-resources的底层原理”要能回答出它本质上是语法糖编译器会生成调用close()的代码并处理主异常和close异常的suppressed关系。能提到查看Throwable.getSuppressed()方法是一个加分项。关于“如何设计一个合理的全局异常处理”建议回答成定义统一异常基类和错误码枚举业务异常继承RuntimeException携带code和messageController层用RestControllerAdvice做全局拦截配合ExceptionHandler统一返回响应体。同时细分业务异常、参数校验异常、系统异常的处理分支外层还要保证完整堆栈日志。这个回答基本能覆盖大多数面试官的考核点。关于“异常对性能的影响”能说出异常创建时的fillInStackTrace开销、JIT的OmitStackTraceInFastThrow优化、以及不要用异常做流程控制这个实践原则面试官会觉得你是真正做过性能优化的人而不只是背概念。面试中还有一个容易被追问的场景题“线上某个接口偶发报错如何一步步排查”我推荐的回答思路先看异常类型和堆栈行号定位是哪个模块的哪一行代码再查该接口最近是否有代码变更和配置变更然后用日志系统按TraceId拉取这个请求的完整链路日志找出异常发生前的业务上下文如果堆栈信息不足再判断是否触发了JIT的堆栈裁剪最后如果是偶发性问题检查并发量、连接池状态、外部依赖的稳定性。能按照这个顺序回答基本可以证明你具备真实的线上问题排查能力。我个人在实际操作中最深的体会是异常处理的水平不是看你会不会写try-catch而是看你有没有一套“异常发生之后怎么办”的完整预案。空指针、类型转换、资源泄漏、事务回滚每一个高频错误背后都对应一个可以被预防的设计缺陷。把这些坑在代码评审阶段就堵住远比线上出了故障再排查来得轻松。如果你所在的项目还在靠一个个catch块去堵漏洞我建议你从今天开始把异常设计当成和业务设计同等重要的事来做。最后再分享一个小技巧新写的方法先在注释里写清楚“什么情况下会抛异常、调用方该怎么处理”再写实现代码坚持一年你会发现代码的坑会明显变少。