对于很多刚接触Java的同学来说,异常处理大概是基础语法里最容易被“跳过”却又最影响实战体验的一环。写练习demo的时候,满屏红字似乎也能跑;可一旦进了真实项目,冷不丁一个NullPointerException或者ClassCastException冒出来,调用栈一长串,第一反应就是懵。我早年带过不少新人,发现大家不是看不懂try-catch的语法,而是根本不知道“什么时候该捕获、什么时候该抛出、什么时候该吞掉”。这篇内容就围绕异常处理这条主线,把体系结构、基础语法、选型思路、进阶技巧和实战排坑一次性讲透。适合刚学完面向对象、准备写真实业务的同学,也适合那些写了好几个月代码但异常处理全靠Ctrl+C/V的朋友。1. 入门第一课:异常是流程控制的一部分,不是“程序出错了”这么简单1.1 如果没有异常机制,代码会变成什么样回到最原始的场景。你写一个除法功能,用户传了分母0进来。没有异常机制的话,程序直接崩溃,后面的代码全部不执行。想要优雅处理,只能靠if判断:public static int divide(int a, int b) { if (b == 0) { // 返回什么?-1?0?还是弹个提示? return -1; } return a / b; }这样写有几个很别扭的地方。返回值被“污染”了,-1到底代表“计算结果是-1”还是“除数为零报错”?如果调用方忘了检查这个特殊值,问题会被悄悄带跑到更远的模块里。如果每个可能出错的地方都这样返回一个错误码,调用方就得写一大堆if-else来层层判断,代码的阅读体验直接崩塌。异常机制要解决的,正是这个问题——把“错误”作为一类独立对象显式传递,而不是混在正常返回值里。我个人理解,异常本质上是一种非正常的流程控制。正常逻辑走主流程,遇到问题就跳到异常处理分支。这样做的好处是业务代码可以线性往下写,错误处理集中放在catch块里,可读性和可维护性都大幅提升。1.2 Throwable家族图谱:三兄弟的职责划分Java的异常体系都是以Throwable为根,往下分两个大分支:Error和Exception。刚入门的人往往把所有红字都叫“报错”,其实这俩性质完全不同。Error代表JVM层面的严重问题,比如OutOfMemoryError、StackOverflowError。这类问题程序本身基本没办法“处理”,甚至捕获了也不一定有用。新人看到OutOfMemoryError会习惯性try-catch一下,其实这是很典型的误区——堆内存都炸了,捕获后该干嘛呢?清理资源?大概率做不到。所以遇到Error,正确思路是记录日志、尽快止损,而不是写一段“恢复逻辑”试图让程序继续跑。Exception下面又分两类:一类是编译器强制要求你必须处理的“检查异常”(Checked Exception),比如IOException、SQLException;另一类是运行时异常(RuntimeException),比如NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException,编译器不强制要求处理。这三兄弟的职责边界,我后面在第三章会详细展开。这里先记住一个判断口诀:Error是JVM快不行了,检查异常是外部环境的不可控因素,运行时异常是代码逻辑本身有bug。2. 捕获与抛出:try-catch-finally和throw/throws的正确用法2.1 try-catch-finally的执行顺序,你真的清楚吗先看一段最基础的代码:public class TryCatchDemo { public static void main(String[] args) { try { System.out.println("1. try开始"); int result = 10 / 0; System.out.println("2. try结束,这里不会执行"); } catch (ArithmeticException e) { System.out.println("3. 捕获到异常:" + e.getMessage()); } finally { System.out.println("4. finally块执行"); } System.out.println("5. 主流程继续"); } }输出结果是:1. try开始 3. 捕获到异常:/ by zero 4. finally块执行 5. 主流程继续这里有几个关键点值得单独拎出来讲。try块中一旦某一行抛出异常,异常点之后的代码全部跳过,直接跳转到匹配的catch块。finally块无论try是否发生异常,无论catch是否捕获到,都会执行,除非JVM直接退出了。还有个老生常谈但在面试里高频出现的点:try和catch里如果写了return,finally块依然是先执行的,只有System.exit()才能拦得住finally。有人会问,既然finally这么“必然执行”,可不可以只写try-finally不写catch?完全可以。最典型的用法是后面第四章要讲的资源释放场景——你在finally里负责关闭文件流、数据库连接,捕获交给上层调用者去处理。有一点我必须提一下:Java 7之后引入的try-with-resources语法,让finally在资源释放场景中变得不再必需,但finally本身并没有过时,它依然是“不管什么情况都必须执行某些逻辑”的标准答案。2.2 throw与throws:主动抛出异常的两个层次很多新手分不清throw和throws,其实就是一句话:throw是“干”动作,真的创建一个异常对象并抛出去;throws是“声明”,告诉调用者“我这个方法可能抛这些异常,你看着办”。public class ThrowDemo { public static void main(String[] args) { try { checkAge(-1); } catch (IllegalArgumentException e) { System.out.println("参数校验失败:" + e.getMessage()); } } // throws声明这个方法可能抛出IllegalArgumentException public static void checkAge(int age) throws IllegalArgumentException { if (age < 0) { // throw实际抛出一个异常对象 throw new IllegalArgumentException("年龄不能为负数,当前值:" + age); } System.out.println("年龄校验通过:" + age); } }这里的IllegalArgumentException是运行时异常,throws可写可不写。但如果方法里可能抛出检查异常,比如读取文件时的IOException,那throws就必须在方法签名上明确声明,否则编译不过。我自己在实际开发中的体会是,throws的声明更像是一种“契约”——方法对外承诺的出错边界。调用方看到throws就知道这里可能出现哪些问题,要么捕获处理,要么继续往上抛。不过“契约”容易被破坏,这也是检查异常在社区里备受争议的原因,后面第三章再展开。2.3 异常在调用链中的传播路径异常传播的机制理解起来不难,但它是排查问题的基础。假设A方法调用B方法,B方法调用C方法,C方法里抛了一个异常。异常会先尝试匹配C方法里的catch,没有就抛给B,B匹配不到继续抛给A,直到main方法,还处理不了就抛给JVM,程序中断。public class PropagationDemo { public static void main(String[] args) { try { methodA(); } catch (Exception e) { System.out.println("main方法最终捕获:" + e.getClass().getSimpleName()); } } public static void methodA() { methodB(); // 不捕获,继续上抛 } public static void methodB() { methodC(); // 不捕获,继续上抛 } public static void methodC() { throw new IllegalArgumentException("C方法中主动抛出"); } }排查线上问题的时候,异常打印出来的StackTrace(调用栈信息)就是从最底层方法一路记录上来的“案发现场”。很多人不看堆栈只看异常类型,这是最浪费资源的排查方式。Exception.getMessage()只告诉你“发生了什么”,printStackTrace()才告诉你“在哪里发生的”以及“怎么一步步走到这里的”。一个小技巧:排查异常时从堆栈最顶端(离main最远的那个at xxx.ClassName.methodName(文件:行号))开始看,那是异常真正被抛出的位置,解决的核心线索往往都在那儿。3. 选型指南:检查异常与运行时异常,哪种场景用哪个3.1 核心区别:编译期强制 vs 运行期发现这是异常处理里最核心的“为什么”,也是很多新人卡住的地方。检查异常(Checked Exception)在编译阶段就得“处理”,要么catch要么throws,二选一。举例来说:public static void readFile(String path) throws IOException { FileInputStream fis = new FileInputStream(path); byte[] data = new byte[1024]; fis.read(data); fis.close(); }这段代码如果不写throws IOException或者不catch一下,编译会直接报错。为什么这样设计?因为文件是否存在、是否有权限读,这些不是程序员能控制的外部因素,编译器强制要求你提前想清楚“文件不存在怎么办”,避免到时候措手不及。运行时异常(RuntimeException)则相反,编译期不需要任何声明和捕获。NullPointerException的根源往往是某个变量的值没被正确初始化,这属于程序自身的缺陷,不是外部环境问题。你可以在代码里到处写try-catch去兜底,但真正改法应该是把那个变量的值处理好,而不是“捕获它”。一个很容易混淆的点:运行时异常如果不被catch,也是可以在方法签名上主动throws的。这样至少能让调用方知道这个方法可能会出现某种运行时异常。但标不标是可选行为,很多人不写其实也没问题。3.2 我的分类标准:这个异常该由谁负责我每次写业务代码,判断用检查异常还是运行时异常时,都问自己一个问题:如果真的发生了这个异常,能指望调用方做什么有意义的处理吗?如果答案是“能”,那用检查异常。比如网络请求超时,调用方可以重试;比如文件不存在,调用方可以换一个文件路径重新调用。这类异常有明确的恢复策略,强制的“处理声明”就很有价值。如果答案是“大概率不能”,那就用运行时异常。比如传入的参数为null,比如索引越界,比如对象类型不匹配——这些本质上就是代码bug,调用方就算catch住也只能把它当成崩溃处理,不能真正修复什么。与其在每一层都强塞一个catch块,不如直接让异常向上浮出水面,尽早暴露问题。举个实际业务例子。我在做一个用户注册接口时,用户名已经存在——这个算不算异常?很多新手会倾向于抛出一个Exception让上层捕获。但“用户名已被注册”是业务规则可以预见的结果,属于分支流程不是系统故障。这种情况下更合理的做法是:在service层返回一个结果对象(比如Result封装),里面带上注册失败的原因码,而不是用异常来驱动业务分支。异常机制的血统是给“意外情况”设计的,不是给“可选分支”设计的。拿异常当分支流程的唯一后果是——代码里塞满了catch,业务逻辑被搅成一团浆糊。3.3 自定义异常的正确设计姿势Java自带的异常类覆盖了很多常见场景,但业务里总会有一些没法直接套用的错误类型,这时候就需要自定义异常。自定义异常其实非常简单,核心就是继承合适的父类:public class BizException extends RuntimeException { private final String errorCode; public BizException(String errorCode, String message) { super(message); this.errorCode = errorCode; } public BizException(String errorCode, String message, Throwable cause) { super(message, cause); this.errorCode = errorCode; } public String getErrorCode() { return errorCode; } }这里我故意让BizException继承RuntimeException而不是Exception,是一种非常主流的设计选择。理由有两个:业务系统的调用链路往往很长,如果每个中间方法都用throws声明各种业务异常,改动一处就要连锁改动一串方法签名,维护成本极高;业务异常通常不具备“可恢复性”,需要中断当前操作并提示用户,运行时异常的传播方式更契合这种需求。自定义异常设计时有三个建议。第一,一定要定义一个errorCode字段,方便前端展示或国际化处理,纯message是靠不住的,用户端的提示和开发者的错误日志是两套文案;第二,至少提供一个带cause的构造方法,这样在异常链的场景下可以把底层异常原因带出来,第4.2节会讲;第三,业务异常不要设计得太多太细,一个系统几十个异常类只会让调用方疲于匹配,按模块抽象成几个大类更实用。4. 进阶实操:try-with-resources与异常日志的最佳实践4.1 try-with-resources:资源关闭的现代写法以前写文件读取,标准模板是:FileInputStream fis = null; try { fis = new FileInputStream("data.txt"); int data = fis.read(); // 业务处理 } catch (IOException e) { e.printStackTrace(); } finally { if (fis != null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } }这个写法又臭又长,而且隐藏着一个经典BUG:finally块里的close方法本身也可能抛IOException,如果处理不当反而会覆盖掉原始异常。Java 7推出的try-with-resources就是来解决这个痛点的:try (FileInputStream fis = new FileInputStream("data.txt")) { int data = fis.read(); // 业务处理 } catch (IOException e) { e.printStackTrace(); }想用这种写法,资源对象必须实现AutoCloseable接口。Java标准库里的文件流、网络连接、数据库连接这些类基本都实现了,直接放心用。如果你自己定义了需要关闭的资源类(比如Socket连接池工具),也建议实现一下AutoCloseable,体验立刻升级。try-with-resources比传统finally写法优雅在哪儿?官方对try块中抛出的异常和close时抛出的异常进行了智能聚合——优先抛出业务代码中的异常,close方法里的异常会被抑制但保留记录,需要时通过Throwable.getSuppressed()获取被抑制的异常列表。这种细节传统写法很难做到,理解了这个设计,你就明白为什么业界都推荐用这个新语法了。4.2 异常链:把最根本的原因保存下来我在实际项目中见过太多这样的代码:public void process() { try { readDataFromFile(); } catch (IOException e) { throw new BizException("文件读取失败"); } }这样写等于把“案发现场”直接销毁了。BizException只知道文件读取失败,但到底是文件不存在、没有访问权限、还是磁盘坏了?底层IOException的信息和堆栈全丢了。线上排查时看到这种日志,基本等于要重现场景重新猜测。正确做法是把底层异常“链”上来:public void process() { try { readDataFromFile(); } catch (IOException e) { // 第二个参数传入cause,保留完整异常链 throw new BizException("E001", "文件读取失败", e); } }有经验的排查人员在日志里看到Caused by: java.io.FileNotFoundException这种段落时,就知道异常链是完整保留的。从BizException出发一路顺着Caused by找下去,就能定位到最底层的原因。有人会问,那我到底在每一层都包装一遍还是干脆一直往上抛?实践里我会分层判断。底层工具类、中间层service方法,如果异常对上层没有额外语义增益,直接throws或原样抛出就好,不必每一层都套一个新异常;但到了事务边界、接口边界这种需要统一错误码的位置,就应该做一次包装,把底层技术异常翻译成业务可读的错误码和提示信息。4.3 日志记录:信息足够多的异常才是好异常不少新人在catch块里写e.printStackTrace()就算完事了,代码看着简单,可生产环境根本不会把堆栈打到控制台——日志系统收集的往往是log4j2或者logback的日志文件,控制台根本没人看。正确姿势是使用日志框架的error方法:private static final Logger logger = LoggerFactory.getLogger(SomeService.class); try { // 业务逻辑 } catch (SQLException e) { logger.error("用户订单查询失败, userId={}, orderId={}", userId, orderId, e); }这里最值得强调的是日志中要带上业务上下文信息。看到“查询失败”不会有人理解,但看到“查询失败,userId=1023, orderId=998”才能定位。大括号{}占位符能避免字符串拼接损耗,logger的最后一个参数自动把异常堆栈打进去。日志级别也要讲一下。error用于“需要人介入处理的错误”。业务上的正常校验失败(比如密码错误),用warn级别记录更合适。把本来不算错误的事情打成error,日志告警系统会被刷爆,等到真出大事反而没人看了。5. 实战排坑:高频异常场景与五个我踩过的坑5.1 Java开发中最常见的异常速查表给新手整理一个高频异常速查表,每个都备注了最常见的触发场景和核心排查方向:异常类型常见触发场景排查方向NullPointerException调用了null对象的方法或属性检查变量是否赋值、集合取出的元素是否为nullArrayIndexOutOfBoundsException访问了数组或List中不存在的下标检查索引边界,List用get时尤其小心ClassCastException把对象强制转换为不兼容的类型检查泛型使用是否规范、实际对象类型是什么ArithmeticException整数除零检查除数来源,特别是外部传入的参数IllegalArgumentException参数不合法被主动抛出看调用方传参的校验逻辑IllegalStateException当前状态不允许执行该操作检查前置状态是否满足,比如未初始化就被调用NumberFormatException字符串转数字失败检查字符串内容是否包含非数字字符ConcurrentModificationException遍历集合时修改集合结构改用Iterator或Stream处理,避免边遍历边删除SQLException数据库连接、SQL执行失败看数据库配置、SQL语法、驱动版本很多运行时异常的触发根源都长得很像——空值和越界。养成防御性编程习惯是减少这类异常的根本手段:参数校验前置、对象创建后确认非null再使用、集合操作前判断大小和元素存在性。5.2 五个我踩过的异常处理坑第一个坑:catch了异常却不记录任何信息。这是我早期犯过的错误。catch逻辑里写个// ignore然后就继续往下走,结果线上功能出问题完全查不到原因,白白定位了好几个小时。忽略异常的唯一合理解释是“你清楚地知道这个异常可以忽略且不会影响核心结果”,即便如此也应该加一行日志说明忽略原因。第二个坑:捕获范围过大。catch (Exception e)一网打尽,把所有异常都吞到同一个处理逻辑里。这样做最大的副作用是掩盖了不同异常类型的差异化处理需求,比如IOException是可重试的,IllegalArgumentException重试一万次也没用,两者混在一起处理会让程序行为非常怪异。真要兜底捕获所有异常,也应该在catch里根据异常类型重新判断分发。第三个坑:try块范围写得过大。一个方法里从头到尾都用try包起来。这不仅会增大性能开销(虽然现代JVM已经优化不少),更重要的是会让异常定位变得困难——堆栈里只能看到方法级信息,却难以判断具体是哪段业务逻辑出问题。范围合理的try块应该只包住可能会抛异常的那几行代码。第四个坑:finally里return。这算是一个隐蔽的经典面试题。如果finally块中写了return语句,它会覆盖try/catch里的所有return,甚至吞掉异常。一个bug就曾藏在这种写法里,排查到最后发现是finally里return了好几个分支都返回不了的兜底值。强烈建议:永远不要在finally里写return。除非你真的非常清楚自己在做什么。第五个坑:用异常做业务流程控制。比如为了停止一个循环,故意抛一个BreakException再在外部捕获。这种做法让代码的意图变得很不直观,而且异常对象创建是有堆栈快照开销的,在循环里高频抛出会有性能损耗,最致命的是它把“正常流程”和“异常流程”混为一谈。正常的分支条件用if判断,只有真正的意外情况才用异常。5.3 异常处理的黄金法则清单把前面所有内容浓缩成一张可对照的操作清单,每次写完异常相关代码过一遍:场景推荐做法避免做法可预见的业务分支if/else或返回结果对象用异常驱动外部环境异常(文件、网络、数据库)声明throws或捕获处理后转换直接忽略catch到异常记录完整日志(含上下文和堆栈)空catch或只printStackTrace包装为什么新异常一定带上原cause丢掉原始异常资源关闭try-with-resources手动close不处理异常方法参数校验抛出IllegalArgumentException等运行时异常返回null让上层猜捕获类型精确到具体异常类型一网打尽Exception或Throwable这套清单不是死规矩。在一些框架代码里,捕获Exception然后做兜底是常见做法;在一些性能敏感代码里,禁用异常也有道理。但“知道自己在打破常规”和“因为不会写所以随便写”是两种完全不同的状态。先把黄金法则吃透,再谈突破和变通。异常处理这门手艺,本质上不是“把错误藏起来让程序继续跑”,而是“把错误说明白,让维护者能快速定位、快速决策”。我见过太多系统崩溃不是因为异常太多,而是因为异常信息太少、处理方式太粗暴。如果你把这篇文章里的体系结构、选型思路和排坑经验真正带进自己项目里,debug效率至少能翻一倍。最后再分享一个小建议:写异常处理时把自己想象成那个三个月后深夜被叫起来排查问题的倒霉同事——你希望他能从日志里看到什么?按这个标准写,一定不会错。
对于很多刚接触Java的同学来说,异常处理大概是基础语法里最容易被“跳过”却又最影响实战体验的一环。写练习demo的时候,满屏红字似乎也能跑;可一旦进了真实项目,冷不丁一个NullPointerException或者ClassCastException冒出来,调用栈一长串,第一反应就是懵。我早年带过不少新人,发现大家不是看不懂try-catch的语法,而是根本不知道“什么时候该捕获、什么时候该抛出、什么时候该吞掉”。这篇内容就围绕异常处理这条主线,把体系结构、基础语法、选型思路、进阶技巧和实战排坑一次性讲透。适合刚学完面向对象、准备写真实业务的同学,也适合那些写了好几个月代码但异常处理全靠Ctrl+C/V的朋友。1. 入门第一课:异常是流程控制的一部分,不是“程序出错了”这么简单1.1 如果没有异常机制,代码会变成什么样回到最原始的场景。你写一个除法功能,用户传了分母0进来。没有异常机制的话,程序直接崩溃,后面的代码全部不执行。想要优雅处理,只能靠if判断:public static int divide(int a, int b) { if (b == 0) { // 返回什么?-1?0?还是弹个提示? return -1; } return a / b; }这样写有几个很别扭的地方。返回值被“污染”了,-1到底代表“计算结果是-1”还是“除数为零报错”?如果调用方忘了检查这个特殊值,问题会被悄悄带跑到更远的模块里。如果每个可能出错的地方都这样返回一个错误码,调用方就得写一大堆if-else来层层判断,代码的阅读体验直接崩塌。异常机制要解决的,正是这个问题——把“错误”作为一类独立对象显式传递,而不是混在正常返回值里。我个人理解,异常本质上是一种非正常的流程控制。正常逻辑走主流程,遇到问题就跳到异常处理分支。这样做的好处是业务代码可以线性往下写,错误处理集中放在catch块里,可读性和可维护性都大幅提升。1.2 Throwable家族图谱:三兄弟的职责划分Java的异常体系都是以Throwable为根,往下分两个大分支:Error和Exception。刚入门的人往往把所有红字都叫“报错”,其实这俩性质完全不同。Error代表JVM层面的严重问题,比如OutOfMemoryError、StackOverflowError。这类问题程序本身基本没办法“处理”,甚至捕获了也不一定有用。新人看到OutOfMemoryError会习惯性try-catch一下,其实这是很典型的误区——堆内存都炸了,捕获后该干嘛呢?清理资源?大概率做不到。所以遇到Error,正确思路是记录日志、尽快止损,而不是写一段“恢复逻辑”试图让程序继续跑。Exception下面又分两类:一类是编译器强制要求你必须处理的“检查异常”(Checked Exception),比如IOException、SQLException;另一类是运行时异常(RuntimeException),比如NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException,编译器不强制要求处理。这三兄弟的职责边界,我后面在第三章会详细展开。这里先记住一个判断口诀:Error是JVM快不行了,检查异常是外部环境的不可控因素,运行时异常是代码逻辑本身有bug。2. 捕获与抛出:try-catch-finally和throw/throws的正确用法2.1 try-catch-finally的执行顺序,你真的清楚吗先看一段最基础的代码:public class TryCatchDemo { public static void main(String[] args) { try { System.out.println("1. try开始"); int result = 10 / 0; System.out.println("2. try结束,这里不会执行"); } catch (ArithmeticException e) { System.out.println("3. 捕获到异常:" + e.getMessage()); } finally { System.out.println("4. finally块执行"); } System.out.println("5. 主流程继续"); } }输出结果是:1. try开始 3. 捕获到异常:/ by zero 4. finally块执行 5. 主流程继续这里有几个关键点值得单独拎出来讲。try块中一旦某一行抛出异常,异常点之后的代码全部跳过,直接跳转到匹配的catch块。finally块无论try是否发生异常,无论catch是否捕获到,都会执行,除非JVM直接退出了。还有个老生常谈但在面试里高频出现的点:try和catch里如果写了return,finally块依然是先执行的,只有System.exit()才能拦得住finally。有人会问,既然finally这么“必然执行”,可不可以只写try-finally不写catch?完全可以。最典型的用法是后面第四章要讲的资源释放场景——你在finally里负责关闭文件流、数据库连接,捕获交给上层调用者去处理。有一点我必须提一下:Java 7之后引入的try-with-resources语法,让finally在资源释放场景中变得不再必需,但finally本身并没有过时,它依然是“不管什么情况都必须执行某些逻辑”的标准答案。2.2 throw与throws:主动抛出异常的两个层次很多新手分不清throw和throws,其实就是一句话:throw是“干”动作,真的创建一个异常对象并抛出去;throws是“声明”,告诉调用者“我这个方法可能抛这些异常,你看着办”。public class ThrowDemo { public static void main(String[] args) { try { checkAge(-1); } catch (IllegalArgumentException e) { System.out.println("参数校验失败:" + e.getMessage()); } } // throws声明这个方法可能抛出IllegalArgumentException public static void checkAge(int age) throws IllegalArgumentException { if (age < 0) { // throw实际抛出一个异常对象 throw new IllegalArgumentException("年龄不能为负数,当前值:" + age); } System.out.println("年龄校验通过:" + age); } }这里的IllegalArgumentException是运行时异常,throws可写可不写。但如果方法里可能抛出检查异常,比如读取文件时的IOException,那throws就必须在方法签名上明确声明,否则编译不过。我自己在实际开发中的体会是,throws的声明更像是一种“契约”——方法对外承诺的出错边界。调用方看到throws就知道这里可能出现哪些问题,要么捕获处理,要么继续往上抛。不过“契约”容易被破坏,这也是检查异常在社区里备受争议的原因,后面第三章再展开。2.3 异常在调用链中的传播路径异常传播的机制理解起来不难,但它是排查问题的基础。假设A方法调用B方法,B方法调用C方法,C方法里抛了一个异常。异常会先尝试匹配C方法里的catch,没有就抛给B,B匹配不到继续抛给A,直到main方法,还处理不了就抛给JVM,程序中断。public class PropagationDemo { public static void main(String[] args) { try { methodA(); } catch (Exception e) { System.out.println("main方法最终捕获:" + e.getClass().getSimpleName()); } } public static void methodA() { methodB(); // 不捕获,继续上抛 } public static void methodB() { methodC(); // 不捕获,继续上抛 } public static void methodC() { throw new IllegalArgumentException("C方法中主动抛出"); } }排查线上问题的时候,异常打印出来的StackTrace(调用栈信息)就是从最底层方法一路记录上来的“案发现场”。很多人不看堆栈只看异常类型,这是最浪费资源的排查方式。Exception.getMessage()只告诉你“发生了什么”,printStackTrace()才告诉你“在哪里发生的”以及“怎么一步步走到这里的”。一个小技巧:排查异常时从堆栈最顶端(离main最远的那个at xxx.ClassName.methodName(文件:行号))开始看,那是异常真正被抛出的位置,解决的核心线索往往都在那儿。3. 选型指南:检查异常与运行时异常,哪种场景用哪个3.1 核心区别:编译期强制 vs 运行期发现这是异常处理里最核心的“为什么”,也是很多新人卡住的地方。检查异常(Checked Exception)在编译阶段就得“处理”,要么catch要么throws,二选一。举例来说:public static void readFile(String path) throws IOException { FileInputStream fis = new FileInputStream(path); byte[] data = new byte[1024]; fis.read(data); fis.close(); }这段代码如果不写throws IOException或者不catch一下,编译会直接报错。为什么这样设计?因为文件是否存在、是否有权限读,这些不是程序员能控制的外部因素,编译器强制要求你提前想清楚“文件不存在怎么办”,避免到时候措手不及。运行时异常(RuntimeException)则相反,编译期不需要任何声明和捕获。NullPointerException的根源往往是某个变量的值没被正确初始化,这属于程序自身的缺陷,不是外部环境问题。你可以在代码里到处写try-catch去兜底,但真正改法应该是把那个变量的值处理好,而不是“捕获它”。一个很容易混淆的点:运行时异常如果不被catch,也是可以在方法签名上主动throws的。这样至少能让调用方知道这个方法可能会出现某种运行时异常。但标不标是可选行为,很多人不写其实也没问题。3.2 我的分类标准:这个异常该由谁负责我每次写业务代码,判断用检查异常还是运行时异常时,都问自己一个问题:如果真的发生了这个异常,能指望调用方做什么有意义的处理吗?如果答案是“能”,那用检查异常。比如网络请求超时,调用方可以重试;比如文件不存在,调用方可以换一个文件路径重新调用。这类异常有明确的恢复策略,强制的“处理声明”就很有价值。如果答案是“大概率不能”,那就用运行时异常。比如传入的参数为null,比如索引越界,比如对象类型不匹配——这些本质上就是代码bug,调用方就算catch住也只能把它当成崩溃处理,不能真正修复什么。与其在每一层都强塞一个catch块,不如直接让异常向上浮出水面,尽早暴露问题。举个实际业务例子。我在做一个用户注册接口时,用户名已经存在——这个算不算异常?很多新手会倾向于抛出一个Exception让上层捕获。但“用户名已被注册”是业务规则可以预见的结果,属于分支流程不是系统故障。这种情况下更合理的做法是:在service层返回一个结果对象(比如Result封装),里面带上注册失败的原因码,而不是用异常来驱动业务分支。异常机制的血统是给“意外情况”设计的,不是给“可选分支”设计的。拿异常当分支流程的唯一后果是——代码里塞满了catch,业务逻辑被搅成一团浆糊。3.3 自定义异常的正确设计姿势Java自带的异常类覆盖了很多常见场景,但业务里总会有一些没法直接套用的错误类型,这时候就需要自定义异常。自定义异常其实非常简单,核心就是继承合适的父类:public class BizException extends RuntimeException { private final String errorCode; public BizException(String errorCode, String message) { super(message); this.errorCode = errorCode; } public BizException(String errorCode, String message, Throwable cause) { super(message, cause); this.errorCode = errorCode; } public String getErrorCode() { return errorCode; } }这里我故意让BizException继承RuntimeException而不是Exception,是一种非常主流的设计选择。理由有两个:业务系统的调用链路往往很长,如果每个中间方法都用throws声明各种业务异常,改动一处就要连锁改动一串方法签名,维护成本极高;业务异常通常不具备“可恢复性”,需要中断当前操作并提示用户,运行时异常的传播方式更契合这种需求。自定义异常设计时有三个建议。第一,一定要定义一个errorCode字段,方便前端展示或国际化处理,纯message是靠不住的,用户端的提示和开发者的错误日志是两套文案;第二,至少提供一个带cause的构造方法,这样在异常链的场景下可以把底层异常原因带出来,第4.2节会讲;第三,业务异常不要设计得太多太细,一个系统几十个异常类只会让调用方疲于匹配,按模块抽象成几个大类更实用。4. 进阶实操:try-with-resources与异常日志的最佳实践4.1 try-with-resources:资源关闭的现代写法以前写文件读取,标准模板是:FileInputStream fis = null; try { fis = new FileInputStream("data.txt"); int data = fis.read(); // 业务处理 } catch (IOException e) { e.printStackTrace(); } finally { if (fis != null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } }这个写法又臭又长,而且隐藏着一个经典BUG:finally块里的close方法本身也可能抛IOException,如果处理不当反而会覆盖掉原始异常。Java 7推出的try-with-resources就是来解决这个痛点的:try (FileInputStream fis = new FileInputStream("data.txt")) { int data = fis.read(); // 业务处理 } catch (IOException e) { e.printStackTrace(); }想用这种写法,资源对象必须实现AutoCloseable接口。Java标准库里的文件流、网络连接、数据库连接这些类基本都实现了,直接放心用。如果你自己定义了需要关闭的资源类(比如Socket连接池工具),也建议实现一下AutoCloseable,体验立刻升级。try-with-resources比传统finally写法优雅在哪儿?官方对try块中抛出的异常和close时抛出的异常进行了智能聚合——优先抛出业务代码中的异常,close方法里的异常会被抑制但保留记录,需要时通过Throwable.getSuppressed()获取被抑制的异常列表。这种细节传统写法很难做到,理解了这个设计,你就明白为什么业界都推荐用这个新语法了。4.2 异常链:把最根本的原因保存下来我在实际项目中见过太多这样的代码:public void process() { try { readDataFromFile(); } catch (IOException e) { throw new BizException("文件读取失败"); } }这样写等于把“案发现场”直接销毁了。BizException只知道文件读取失败,但到底是文件不存在、没有访问权限、还是磁盘坏了?底层IOException的信息和堆栈全丢了。线上排查时看到这种日志,基本等于要重现场景重新猜测。正确做法是把底层异常“链”上来:public void process() { try { readDataFromFile(); } catch (IOException e) { // 第二个参数传入cause,保留完整异常链 throw new BizException("E001", "文件读取失败", e); } }有经验的排查人员在日志里看到Caused by: java.io.FileNotFoundException这种段落时,就知道异常链是完整保留的。从BizException出发一路顺着Caused by找下去,就能定位到最底层的原因。有人会问,那我到底在每一层都包装一遍还是干脆一直往上抛?实践里我会分层判断。底层工具类、中间层service方法,如果异常对上层没有额外语义增益,直接throws或原样抛出就好,不必每一层都套一个新异常;但到了事务边界、接口边界这种需要统一错误码的位置,就应该做一次包装,把底层技术异常翻译成业务可读的错误码和提示信息。4.3 日志记录:信息足够多的异常才是好异常不少新人在catch块里写e.printStackTrace()就算完事了,代码看着简单,可生产环境根本不会把堆栈打到控制台——日志系统收集的往往是log4j2或者logback的日志文件,控制台根本没人看。正确姿势是使用日志框架的error方法:private static final Logger logger = LoggerFactory.getLogger(SomeService.class); try { // 业务逻辑 } catch (SQLException e) { logger.error("用户订单查询失败, userId={}, orderId={}", userId, orderId, e); }这里最值得强调的是日志中要带上业务上下文信息。看到“查询失败”不会有人理解,但看到“查询失败,userId=1023, orderId=998”才能定位。大括号{}占位符能避免字符串拼接损耗,logger的最后一个参数自动把异常堆栈打进去。日志级别也要讲一下。error用于“需要人介入处理的错误”。业务上的正常校验失败(比如密码错误),用warn级别记录更合适。把本来不算错误的事情打成error,日志告警系统会被刷爆,等到真出大事反而没人看了。5. 实战排坑:高频异常场景与五个我踩过的坑5.1 Java开发中最常见的异常速查表给新手整理一个高频异常速查表,每个都备注了最常见的触发场景和核心排查方向:异常类型常见触发场景排查方向NullPointerException调用了null对象的方法或属性检查变量是否赋值、集合取出的元素是否为nullArrayIndexOutOfBoundsException访问了数组或List中不存在的下标检查索引边界,List用get时尤其小心ClassCastException把对象强制转换为不兼容的类型检查泛型使用是否规范、实际对象类型是什么ArithmeticException整数除零检查除数来源,特别是外部传入的参数IllegalArgumentException参数不合法被主动抛出看调用方传参的校验逻辑IllegalStateException当前状态不允许执行该操作检查前置状态是否满足,比如未初始化就被调用NumberFormatException字符串转数字失败检查字符串内容是否包含非数字字符ConcurrentModificationException遍历集合时修改集合结构改用Iterator或Stream处理,避免边遍历边删除SQLException数据库连接、SQL执行失败看数据库配置、SQL语法、驱动版本很多运行时异常的触发根源都长得很像——空值和越界。养成防御性编程习惯是减少这类异常的根本手段:参数校验前置、对象创建后确认非null再使用、集合操作前判断大小和元素存在性。5.2 五个我踩过的异常处理坑第一个坑:catch了异常却不记录任何信息。这是我早期犯过的错误。catch逻辑里写个// ignore然后就继续往下走,结果线上功能出问题完全查不到原因,白白定位了好几个小时。忽略异常的唯一合理解释是“你清楚地知道这个异常可以忽略且不会影响核心结果”,即便如此也应该加一行日志说明忽略原因。第二个坑:捕获范围过大。catch (Exception e)一网打尽,把所有异常都吞到同一个处理逻辑里。这样做最大的副作用是掩盖了不同异常类型的差异化处理需求,比如IOException是可重试的,IllegalArgumentException重试一万次也没用,两者混在一起处理会让程序行为非常怪异。真要兜底捕获所有异常,也应该在catch里根据异常类型重新判断分发。第三个坑:try块范围写得过大。一个方法里从头到尾都用try包起来。这不仅会增大性能开销(虽然现代JVM已经优化不少),更重要的是会让异常定位变得困难——堆栈里只能看到方法级信息,却难以判断具体是哪段业务逻辑出问题。范围合理的try块应该只包住可能会抛异常的那几行代码。第四个坑:finally里return。这算是一个隐蔽的经典面试题。如果finally块中写了return语句,它会覆盖try/catch里的所有return,甚至吞掉异常。一个bug就曾藏在这种写法里,排查到最后发现是finally里return了好几个分支都返回不了的兜底值。强烈建议:永远不要在finally里写return。除非你真的非常清楚自己在做什么。第五个坑:用异常做业务流程控制。比如为了停止一个循环,故意抛一个BreakException再在外部捕获。这种做法让代码的意图变得很不直观,而且异常对象创建是有堆栈快照开销的,在循环里高频抛出会有性能损耗,最致命的是它把“正常流程”和“异常流程”混为一谈。正常的分支条件用if判断,只有真正的意外情况才用异常。5.3 异常处理的黄金法则清单把前面所有内容浓缩成一张可对照的操作清单,每次写完异常相关代码过一遍:场景推荐做法避免做法可预见的业务分支if/else或返回结果对象用异常驱动外部环境异常(文件、网络、数据库)声明throws或捕获处理后转换直接忽略catch到异常记录完整日志(含上下文和堆栈)空catch或只printStackTrace包装为什么新异常一定带上原cause丢掉原始异常资源关闭try-with-resources手动close不处理异常方法参数校验抛出IllegalArgumentException等运行时异常返回null让上层猜捕获类型精确到具体异常类型一网打尽Exception或Throwable这套清单不是死规矩。在一些框架代码里,捕获Exception然后做兜底是常见做法;在一些性能敏感代码里,禁用异常也有道理。但“知道自己在打破常规”和“因为不会写所以随便写”是两种完全不同的状态。先把黄金法则吃透,再谈突破和变通。异常处理这门手艺,本质上不是“把错误藏起来让程序继续跑”,而是“把错误说明白,让维护者能快速定位、快速决策”。我见过太多系统崩溃不是因为异常太多,而是因为异常信息太少、处理方式太粗暴。如果你把这篇文章里的体系结构、选型思路和排坑经验真正带进自己项目里,debug效率至少能翻一倍。最后再分享一个小建议:写异常处理时把自己想象成那个三个月后深夜被叫起来排查问题的倒霉同事——你希望他能从日志里看到什么?按这个标准写,一定不会错。