ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 异常处理详解:把「程序炸了」变成「可控的报警器」

Java 异常处理详解:把「程序炸了」变成「可控的报警器」 提示文章写完后目录可以自动生成如何生成可参考右边的帮助文档文章目录引言一、Java异常是什么1.1 生活画面厨房里的报警器1.2 异常 vs 错误能修 vs 该跑1.3 为什么要有异常处理1.4 基本流程先建立总图1.5 顺便学Debug第一眼看堆栈二、Java异常体系2.1 四大家族关系2.2 受检 vs 非受检2.3 常见异常与场景三、常用异常处理语法3.1 try-catch接住烫手山芋3.2 try-catch-finally打扫战场3.3 throw主动扔出异常3.4 throws我处理不了交给上层3.5 try-with-resources推荐自动关资源3.6 多重 catch 与多异常捕获3.7 异常链与异常包装四、异常的传播与处理流程4.1 调用栈里的层层上报4.2 受检异常如何声明与传递4.3 未捕获异常的影响4.4 Debug用堆栈还原现场五、自定义异常5.1 为什么要自定义异常5.2 继承谁5.3 带错误码的业务异常示例六、实际开发案例案例A文件读取资源安全 友好错误案例B订单处理业务异常 不要用异常当 if例C简版数据库资源与异常七、异常处理最佳实践八、常见面试题九、总结Java 异常处理速查表引言写 Java 时几乎每个人都经历过程序跑到一半突然停住控制台刷出一长串红色英文。很多人第一反应是——「又炸了复制去搜」。其实异常不是「倒霉事件」而是 Java 给你的一套**正式报警机制**哪里出了问题、问题有多严重、该谁处理、处理完要不要继续。本文以 **Java 17** 为主示例涉及版本差异会标注目标不是背 API而是帮你建立一套能复述、能调试、能落地的心智模型。一、Java异常是什么1.1生活画面厨房里的报警器把正常执行想成「按菜谱做饭」洗菜、切菜、下锅、出锅。| 生活中的画面 | 编程里的对应 ||-------------|-------------|| 切菜时刀没磨好切到手 | 运行时出了意外状况异常 || 厨房着火整栋楼要撤 | 严重到几乎无法恢复Error || 报警器响了你决定包扎还是叫救护车 | try-catch 决定怎么处理 || 报警记录几点、哪间厨房、谁在做饭 | 堆栈跟踪Stack Trace |一句话异常 程序运行中出现的、预期之外但可被程序捕获并处理的状况。1.2异常vs 错误 能修 vs该跑Throwable所有「能抛出来的东西」的祖宗├── Error错误JVM / 系统级灾难程序一般不该 catch│ 例如OutOfMemoryError、StackOverflowError└── Exception异常业务/代码层面的问题通常应该处理├── RuntimeException运行时异常非受检│ 例如NullPointerException└── 其他 Exception受检异常例如IOException、SQLException| 对比项 | Exception异常 | Error错误 ||--------|-------------------|---------------|| 典型原因 | 业务逻辑、I/O、空指针等 | 内存耗尽、栈溢出、JVM 内部问题 || 是否建议捕获 | 通常要处理或向上声明 | 一般不捕获修根因 || 能否恢复 | 多数情况下可以 | 很难可靠恢复 || 例子 | IOException、NPE | OutOfMemoryError |提示面试里常说【Error 不处理】。生产里偶尔会监控 OutOfMemoryError做告警/优雅退出但不要指望catchError e 然后magically接着跑业务。1.3为什么要有异常处理没有异常机制时只能导出返回【神奇错误码】-1表示失败还是表示【找到了下标-1】调用方经常忘检查bug像地雷。异常处理的意义1.把【出错路径】从正常逻辑里拆出来、主流程更干净2.强制或引导调用方正视失败尤其是受检异常3.携带上下文消息 堆栈方便定位4.可用跨多层方法传递不必每一层都手动返回错误码。1.4基本流程先建立总图异常就像一个【烫手山芋】。谁扔出来就沿调用链往上找人接一直没人接线程就结束并打印完整堆栈。1.5顺便学Debug第一眼看堆栈下面这段代码会故意炸package Exercise.exception; public class DebugFirstLook { public static void main(String[] args) { String name null; //// 下一行会抛 NullPointerException System.out.println(name.length()); } }怎么读Debug 三步1.看异常类型 消息NullPointerException原因是name为null2.看最上面几行at……这是【爆炸点】3.往下看调用链谁调用了谁直到main——这就是传播路径。IDE里Idea / VS Code再第5行左边点红点设断点-Debug运行--看Variables面板里namenull。异常不是敌人堆栈是地图。二、Java异常体系2.1四大家族关系Throwable可被throw的根类型Error严重错误Exception常规异常RuntimeExceptionException的子类表示【程序员本可避免】的运行时问题。2.2受检vs非受检| 类型 | 编译期检查 | 典型代表 | 适合解决什么问题 ||------|--------------|----------|------------------|| **受检Checked** | 必须 catch 或 throws | IOException、SQLException、FileNotFoundException | 外部世界可能失败文件、网络、数据库 || **非受检Unchecked** | 不强制 | RuntimeException 及其子类、Error | 编程错误或严重错误空指针、越界、类型转换错 |比喻受检异常像【出门带伞】——编译器像老妈不带不行非受检异常像【别踩自己的脚】——你本该写对编译器不天天唠叨。2.3常见异常与场景| 异常 | 类别 | 常见触发场景 ||------|------|--------------|| NullPointerException | 非受检 | 对 null 调方法/访问字段 || ArrayIndexOutOfBoundsException | 非受检 | 数组下标越界 || ArithmeticException | 非受检 | 整数除以 0 || ClassCastException | 非受检 | 强制类型转换失败 || IOException | 受检 | 读写流、网络 I/O 失败 || FileNotFoundException | 受检IOException 子类 | 文件不存在 || SQLException | 受检 | JDBC 执行 SQL 失败 |小例子可运行package Exercise.exception; import java.util.Objects; public class CommonExceptionsDemo { public static void main(String[] args) { // 1)NPE try { String s null; s.length(); }catch (NullPointerException e){ System.out.println(NPE: e.getMessage()); } // 2)数组越界 try { int[] arr {1,2}; System.out.println(arr[5]); }catch (ArrayIndexOutOfBoundsException e){ System.out.println(越界:e.getMessage()); } // 3)算术异常(注意只有整数除零会抛浮点会得到Infinity/NaN) try { int x 10/0; }catch (ArithmeticException e){ System.out.println(除零:e.getMessage()); } // 4)类型转换 try { Object obj hello; Integer num (Integer) obj; }catch (ClassCastException e){ System.out.println(转型失败e); } } }Debug 小技巧 在 IDE 里打开 Break on exceptionIdeaRun → View Breakpoints → Java Exception Breakpoints勾选 NullPointerException一抛就停——比肉眼扫日志快得多。三、常用异常处理语法3.1try-catch接住烫手山芋package Exercise.exception; public class TryCatchDemo { public static void main(String[] args) { try { int value Integer.parseInt(abc);//会抛NumberFormatException System.out.println(value); }catch (NumberFormatException e){ //只处理【数字格式不对】这一种情况 System.out.println(请输入合法整数原因e.getMessage()); } } }解决什么问题某段代码可能失败但你希望程序继续、并给出友好提示。3.2try-catch-finally打扫战场finally里的代码几乎总会执行除非JVM崩了、或再finally前啥进程等极端情况。适合放【必须做的收尾】——但资源关闭更推荐下一节的try-with-resources。package Exercise.exception; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; public class FinallyDemo { public static void main(String[] args) { FileInputStream in null; try { in new FileInputStream(config,txt); System.out.println(文件打开成功字节数可读in.available()); } catch (FileNotFoundException e) { System.out.println(读文件失败e.getMessage()); } catch (IOException e) { throw new RuntimeException(e); }finally { if (in !null){ try { in.close();//就写法手动观流 } catch (IOException e) { System.out.println(关闭流失败e.getMessage()); } } } } }3.3throw主动扔出异常package Exercise.exception; public class ThrowDemo { public static int parsePositiveInt(String text){ if (text null || text.isBlank()){ throw new IllegalArgumentException(text不能为空); } int value Integer.parseInt(text); if (value 0){ throw new IllegalArgumentException(必须是正整数实际value); } return value; } public static void main(String[] args) { // System.out.println(parsePositiveInt(18)); parsePositiveInt(-1); } }适合参数不合法、前置条件不满足-——尽早失败fail fast。3.4 throws我处理不了交给上层package Exercise.exception; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; public class ThrowsDemo { // 受检异常必须声明告诉调用方【读文件可能失败】 public static String readConfig (Path path) throws IOException { return Files.readString(path); } public static void main(String[] args) { try { System.out.println(readConfig(Path.of(config.txt))); } catch (IOException e) { System.out.println(上层统一处理e.getMessage()); } } }3.5 try-with-resources推荐自动关资源Java 7 引入资源需实现 AutoCloseable。Java 9 还支持「 effectively final 的已有变量」直接放进 try。package Exercise.exception; import java.io.BufferedReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; public class TryWithResourcesDemo { public static void main(String[] args) { Path path Path.of(config.txt); // 推荐离开try块时自动close异常更干净 try (BufferedReader reader Files.newBufferedReader(path)){ System.out.println(reader.readLine()); } catch (IOException e) { System.out.println(读取失败e.getMessage()); } } }写法 | 问题 | 评价 ||------|------|------|| 手动 close 嵌套 try | 冗长易漏关次要异常可能被覆盖 | 旧项目遗留可接受 || **try-with-resources** | 自动关闭抑制异常机制更规范 | **现代 Java 首选** |3.6 多重 catch 与多异常捕获package Exercise.exception; public class MultiCatchDemo { public static void main(String[] args) { String input args.length0 ? args[0]:abc; //多重catch从【更具体】到【更宽泛】 try { int n Integer.parseInt(input); System.out.println(100 / n); }catch (NumberFormatException e){ System.out.println(不是数字); }catch (ArithmeticException e){ System.out.println(不能除以0); } //多异常捕获Java7—处理方式完全相同时合并 try { Object object input; System.out.println(((String) object).charAt(100)); }catch (ClassCastException | StringIndexOutOfBoundsException e){ System.out.println(同一类处理e.getClass().getSimpleName()); } } }注意多异常捕获时这些异常类型不能有继承关系否则编译错误。变量e实质是final。3.7异常链与 异常包装底层异常很技术上层需要【业务语义】——用cause把原异常串起来。package Exercise.exception; public class ExceptionChainDemo { static class OrderService{ public void placeOrder(String userId){ try { callPayment(userId); }catch (Exception e){ //推荐保留原始异常方便排查 throw new IllegalStateException(下单失败userIduserId,e); } } private void callPayment(String userId) { throw new RuntimeException(支付网关超时); } } public static void main(String[] args) { try { new OrderService().placeOrder(U1001); }catch (IllegalStateException e){ System.out.println(业务消息e.getMessage()); System.out.println(根因e.getMessage()); e.printStackTrace();//Debug完整链 } } }错误写法丢 cause throw new IllegalStateException(下单失败);推荐写法throw new IllegalStateException(下单失败, e);丢了 cause线上只剩一句「下单失败」根因蒸发——这是生产排障噩梦。四、异常的传播与处理流程4.1调用栈里的【层层上报】package Exercise.exception; public class PropagationDemo { static void level3(){ System.out.println(1/0);//爆炸点 } static void level2(){ level3();//不处理继续上传 } static void level1(){ level2(); } public static void main(String[] args) { level1(); } }堆栈大致是Exception in thread main java.lang.ArithmeticException: / by zeroat Exercise.exception.PropagationDemo.level3(PropagationDemo.java:5)at Exercise.exception.PropagationDemo.level2(PropagationDemo.java:8)at Exercise.exception.PropagationDemo.level1(PropagationDemo.java:11)at Exercise.exception.PropagationDemo.main(PropagationDemo.java:15)level3 炸了 → level2 没接 → level1 没接 → main 没接 → 线程结束。每一层 at 就是一扇门牌。4.2受检异常如何声明与传递规则很简单要么自己catch要么在方法签名上throws把责任甩给调用方。一层甩一层直到有人真正处理记录日志、返回错误响应、重试等。4.3 未捕获异常的影响主线程未捕获进程通常直接退出对命令行程序尤其明显普通子线程为捕获该线程结束其他线程可能还在跑——容易变成【半死不活】的服务可为线程设置UncaughtExceptionHandler做统一日志/告警生产可观测性常用。4.4 Debug用堆栈还原现场实操清单1.从堆栈最上行业务代码设断点跳过框架内部帧2.从局部变量是否为null、空集合、错误状态3.若有Caused by先看最底层cause——那才是根因4.日志里打印log。error(msg,e)必须把异常对象传进去而不是只打e.getMessage()。五、自定义异常5.1 为什么要自定义异常JDK 异常太「技术」IllegalArgumentException 调不通业务同学。业务需要「余额不足」「用户已存在」「订单已关闭」——这就是自定义异常的价值用领域语言表达失败。5.2继承谁| 继承 | 何时用 ||------|--------|| Exception受检 | 调用方**几乎必须处理**的可恢复失败少用避免签名污染 || RuntimeException非受检 | 现代业务异常主流框架统一拦截转 HTTP 错误码 |5.3 带错误码的业务异常示例package Exercise.exception; public class BusinessException extends RuntimeException{ private final String errorCode; public BusinessException(String message, String errorCode) { super(message); this.errorCode errorCode; } public BusinessException(String message, Throwable cause, String errorCode) { super(message, cause); this.errorCode errorCode; } public String getErrorCode() { return errorCode; } } package Exercise.exception; public class UserRegisterService { public void register(String username,String password){ if (username null || username.isBlank()){ throw new BusinessException(USER_NAME_EMPTY,用户名不能为空); } if (password null || password.length() 8){ throw new BusinessException(USER_PASSWORD_WEAK,密码至少8为、位); } if (admin.equalsIgnoreCase(username)){ throw new BusinessException(USER_ALREADT_EXISTS,用户名已存在username); } System.out.println(注册成功username); } public static void main(String[] args) { UserRegisterService service new UserRegisterService(); try { service.register(admin,12345678); }catch (BusinessException e){ System.out.println(错误码e.getErrorCode(), 信息e.getMessage()); } } }设计要点: 错误码稳定给前端/监控、消息可读给人看、需要时带 cause。六、实际开发案例案例A文件读取资源安全 友好错误package Exercise.exception; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.List; public class FileReadCase { public static ListString readLines(Path path){ try { return Files.readAllLines(path, StandardCharsets.UTF_8); } catch (NoSuchFileException e){ throw new BusinessException(FILE_NOT_FOUND,配置文件不存在 path,e); }catch (IOException e) { throw new BusinessException(FILE_READ_FAILED,读取配置失败 path,e); } } public static void main(String[] args) { try { Listlt;Stringgt;lines readLines(Path.of(application.yml)); lines.forEach(System.out::println); }catch (BusinessException e){ //生产红应打日志log.error(读取配置失败,e) System.out.println([e.getErrorCode()]e.getMessage()); } } }Debug若总报 FILE_NOT_FOUND先在断点看 path.toAbsolutePath()——十有八九是工作目录不对不是代码「神秘失灵」。案例 B订单处理业务异常 不要用异常当 ifpackage Exercise.exception; import javax.naming.NoInitialContextException; public class OrderPayCase { public record Order(String orderId,String status,int amountCents){} public void pay(Order order,int balanceCents){ //推荐正常分支用 if不要靠抛异常做流程控制 if (order null){ throw new BusinessException(ORDER_NULL,订单不能为空); } if (!CREATED.equalsIgnoreCase(order.status)){ throw new BusinessException(ORDER_STATUS_INVALID,订单状态不可支付order.status()); } if (balanceCentslt;order.amountCents()){ throw new BusinessException(BALANCE_NOT_ENOUGH,余额不足需要order.amountCents(),当前balanceCents); } System.out.println(支付成功order.orderId()); } public static void main(String[] args) { OrderPayCase orderPayCase new OrderPayCase(); Order order new Order(020260325,CREATED,9900); try { orderPayCase.pay(order,5000); }catch (BusinessException e){ System.out.println(e.getErrorCode()-gt;e.getMessage()); } } }错误写法 vs 推荐写法//错误用异常代替正常的【余额不够】分支且可能被频繁触发 try { withdraw(amount); }catch (Exception e){ return 余额不足; } //推荐先校验在执行异常留给真正的意外/约为规则破坏 if(balance lt; amount){ throw new BusinessException(BALANCE_NOT_ENOUGH,余额不足); } withdraw(amount);/code/pre 例 C简版数据库资源与异常 JDBC 时代请用 try-with-resources 同时管理 Connection / Statement / ResultSet它们都实现了 AutoCloseable。Spring 项目中更常见的是模板/ORM 帮你关资源你只需正确翻译 DataAccessException / 业务异常并在边界记录日志。 七、异常处理最佳实践 做法 | 为什么 | |------|--------| | 不要动辄 catch (Exception e) | 会吞掉你没想到的严重问题掩盖 bug | | 不要空 catch / 只 printStackTrace 就当完事 | 生产日志可能看不见控制台等于「假装没事」 | | 不要吞异常 | 至少记录日志或转换为带 cause 的业务异常 | | Checked vs Unchecked 要克制 | 库边界可用受检业务层更常用非受检 统一处理 | | 保留异常链 | new XxxException(msg, cause) | | 消息要可定位 | 带上关键 ID、参数**注意脱敏**别把密码打进日志 | | 异常不是流程控制 | 高频正常分支用 if异常昂贵且难读 | | 资源关闭优先 try-with-resources | 少写 finally 关流样板代码 | | 日志 vs 抛出职责分离 | 底层抛、边界记避免同一异常被每一层都打一遍日志 | | 生产安全与可观测 | 对外返回错误码/模糊提示对内完整日志 监控告警 | 经典反例 try { doSomething() }catch (Exception e){ //空的最危险 } 推荐边界处理示意 try { orderService.pay(cmd); } catch (BusinessException e) { log.warn(业务失败 code{}, msg{}, e.getErrorCode(), e.getMessage()); return ApiResult.fail(e.getErrorCode(), e.getMessage()); } catch (Exception e) { log.error(系统异常 orderId{}, cmd.orderId(), e); // 必须带 e return ApiResult.fail(SYSTEM_ERROR, 系统繁忙请稍后重试); } 八、常见面试题 Q1final、finally、finalize区别 final修饰不可变/不可继承/不可覆盖finally是异常语法里的收尾块finalize是已过时的对象回收钩子Java 9 deprecated18 移除相关能力趋势下更不该用。 Q2受检异常一定好吗 不一定。它能提醒处理但过度使用会污染方法签名、逼出敷衍catch。很多现代框架偏向运行时异常 统一异常处理 Q3try-with-resources 里关闭异常怎么办 主异常保留关闭时的异常进入suppressedgetSuppressed()),不会粗暴覆盖主因。 Q4为什么说【不要用异常做控制流】 异常创建堆栈成本高、打断可读性也让【正业务分支】看起来像事故。 Q5如何快速定位NPEJava14更友好 看消息里的具体null表示在开异常断点结合堆栈第二、三帧看是谁传入了null。 九、总结 1. 异常是报警器不是倒霉本身——它告诉你哪里坏了 2. 能处理就处理不能处理就带着上下文往上抛——别空 catch 3. 堆栈是地图cause 是根因日志是黑匣子——Debug 时按这个顺序找。 日常开发记住两条默认策略即可 - 资源 try-with-resources - 业务 清晰的业务异常错误码 消息 cause 边界统一翻译成用户可理解的响应。 Java 异常处理速查表 场景 | 推荐做法 | |------|----------| | 读文件 / 网络 / JDBC | 受检异常catch 或 throws优先 try-with-resources | | 参数校验失败 | IllegalArgumentException 或业务异常fail fast | | 空指针 / 越界 | 先修代码防御用异常断点定位 | | 业务规则不满足 | 自定义 BusinessException(code, msg[, cause]) | | 需要包装下层异常 | throw new X(msg, e)保留链 | | 统一对外返回 | Controller/网关边界 catch对内 log.error(..., e) | | 关闭资源 | try-with-resources 手写 finally | | 千万别 | 空 catch、吞异常、catch (Exception) 后假装成功、用异常代替 if | 会写 try-catch 只是入门会读堆栈、会留 cause、会在边界记日志才是能上生产的异常处理。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进