第22章 异常处理:让程序面对错误时有章法
前面三阶段写的程序,大多数都在“正确输入、正确流程”下运行。真实程序不会这么顺利。
用户会输入空姓名。文件可能不存在。日期格式可能写错。手机号可能重复。网络可能断开。数据库可能连接失败。程序员自己也可能传错参数。
如果每个错误都让程序直接崩掉,用户体验会很差;如果把错误全部吞掉,问题会更可怕,因为数据可能悄悄错了。
异常处理要解决的不是“让程序永远不报错”,而是:
错误发生时,程序能明确知道哪里错了;
能在合适的位置处理;
处理不了时能带着清楚原因继续向上报告。
这一章会讲:
- 异常是什么。
- 为什么不能只靠 if 返回错误码。
- try/catch/finally 怎么用。
- throw 和 throws 的区别。
- 受检异常和运行时异常。
- 常见异常类怎么判断。
- 如何设计自定义业务异常。
- 为什么不要吞异常。
- 如何把异常用到图书馆和联系人项目里。
一、程序失败是正常情况
先看一个简单输入程序:
Scanner scanner = new Scanner(System.in);
System.out.print("请输入年龄:");
int age = scanner.nextInt();
System.out.println("年龄:" + age);
如果用户输入:
18
没问题。
如果用户输入:
abc
程序会报错。
这不是 Java 脆弱,而是程序收到的数据不符合预期。真实系统里,这种事情每天都在发生。
错误大概可以分几类:
| 参数错误 | 姓名为空、年龄为负数 | 尽早拒绝,给清楚提示 |
| 状态错误 | 没库存还要借书 | 阻止操作,说明状态不允许 |
| 外部错误 | 文件不存在、网络失败 | 捕获后提示或重试 |
| 程序错误 | 数组越界、空指针 | 修代码,不能假装没事 |
异常处理不是为了掩盖错误,而是让错误有路径、有语义、有边界。
二、异常是什么
异常是 Java 用来表示程序运行中出现问题的一种对象。
比如:
throw new IllegalArgumentException("姓名不能为空");
这里创建了一个 IllegalArgumentException 对象,并把它抛出去。
一旦抛出异常,当前方法会停止继续往下执行,异常会沿着调用链向上传递,直到被某个 catch 捕获,或者一直没人处理导致程序终止。
流程大概是:
#mermaid-svg-5yg4DxlIFD6LhTgr{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5yg4DxlIFD6LhTgr .error-icon{fill:#552222;}#mermaid-svg-5yg4DxlIFD6LhTgr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5yg4DxlIFD6LhTgr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5yg4DxlIFD6LhTgr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5yg4DxlIFD6LhTgr .marker.cross{stroke:#333333;}#mermaid-svg-5yg4DxlIFD6LhTgr svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5yg4DxlIFD6LhTgr p{margin:0;}#mermaid-svg-5yg4DxlIFD6LhTgr .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-5yg4DxlIFD6LhTgr .cluster-label text{fill:#333;}#mermaid-svg-5yg4DxlIFD6LhTgr .cluster-label span{color:#333;}#mermaid-svg-5yg4DxlIFD6LhTgr .cluster-label span p{background-color:transparent;}#mermaid-svg-5yg4DxlIFD6LhTgr .label text,#mermaid-svg-5yg4DxlIFD6LhTgr span{fill:#333;color:#333;}#mermaid-svg-5yg4DxlIFD6LhTgr .node rect,#mermaid-svg-5yg4DxlIFD6LhTgr .node circle,#mermaid-svg-5yg4DxlIFD6LhTgr .node ellipse,#mermaid-svg-5yg4DxlIFD6LhTgr .node polygon,#mermaid-svg-5yg4DxlIFD6LhTgr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5yg4DxlIFD6LhTgr .rough-node .label text,#mermaid-svg-5yg4DxlIFD6LhTgr .node .label text,#mermaid-svg-5yg4DxlIFD6LhTgr .image-shape .label,#mermaid-svg-5yg4DxlIFD6LhTgr .icon-shape .label{text-anchor:middle;}#mermaid-svg-5yg4DxlIFD6LhTgr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5yg4DxlIFD6LhTgr .rough-node .label,#mermaid-svg-5yg4DxlIFD6LhTgr .node .label,#mermaid-svg-5yg4DxlIFD6LhTgr .image-shape .label,#mermaid-svg-5yg4DxlIFD6LhTgr .icon-shape .label{text-align:center;}#mermaid-svg-5yg4DxlIFD6LhTgr .node.clickable{cursor:pointer;}#mermaid-svg-5yg4DxlIFD6LhTgr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5yg4DxlIFD6LhTgr .arrowheadPath{fill:#333333;}#mermaid-svg-5yg4DxlIFD6LhTgr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5yg4DxlIFD6LhTgr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5yg4DxlIFD6LhTgr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5yg4DxlIFD6LhTgr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5yg4DxlIFD6LhTgr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5yg4DxlIFD6LhTgr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5yg4DxlIFD6LhTgr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5yg4DxlIFD6LhTgr .cluster text{fill:#333;}#mermaid-svg-5yg4DxlIFD6LhTgr .cluster span{color:#333;}#mermaid-svg-5yg4DxlIFD6LhTgr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5yg4DxlIFD6LhTgr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5yg4DxlIFD6LhTgr rect.text{fill:none;stroke-width:0;}#mermaid-svg-5yg4DxlIFD6LhTgr .icon-shape,#mermaid-svg-5yg4DxlIFD6LhTgr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5yg4DxlIFD6LhTgr .icon-shape p,#mermaid-svg-5yg4DxlIFD6LhTgr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5yg4DxlIFD6LhTgr .icon-shape .label rect,#mermaid-svg-5yg4DxlIFD6LhTgr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5yg4DxlIFD6LhTgr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5yg4DxlIFD6LhTgr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5yg4DxlIFD6LhTgr :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
main 调用 addContact
addContact 调用 Contact 构造方法
Contact 检查 name
name 为空?
创建成功
throw IllegalArgumentException
异常返回 addContact
异常返回 main
catch 捕获并打印提示
异常会打断正常流程,这正是它的作用。
如果姓名为空,程序就不应该继续创建一个非法联系人。
三、为什么不用返回值表示所有错误
不用异常时,有人会写:
public boolean addContact(Contact contact) {
if (contact == null) {
return false;
}
if (contactsByPhone.containsKey(contact.getPhone())) {
return false;
}
contactsByPhone.put(contact.getPhone(), contact);
return true;
}
调用方:
boolean success = manager.addContact(contact);
if (!success) {
System.out.println("添加失败");
}
问题是:失败原因丢了。
到底是联系人为空?手机号重复?手机号格式不对?内部状态错误?
你可以返回错误码:
return 1; // 成功
return –1; // 联系人为空
return –2; // 手机号重复
但错误码会让代码变得难读,而且调用方很容易忘记检查。
异常的优势是:
- 可以携带错误消息。
- 可以携带异常类型。
- 可以中断当前流程,避免继续错误执行。
- 可以在合适层统一处理。
这不表示所有失败都必须用异常。比如搜索联系人找不到,可以返回空列表;判断权限没有某个权限,可以返回 false。异常更适合表示“不应该继续正常执行”的情况。
四、try/catch 基本用法
捕获异常:
try {
int number = Integer.parseInt("abc");
System.out.println(number);
} catch (NumberFormatException e) {
System.out.println("数字格式不正确:" + e.getMessage());
}
Integer.parseInt("abc") 会抛出 NumberFormatException。
catch 捕获后,程序不会直接终止,而是执行提示。
输出:
数字格式不正确:For input string: "abc"
try 里放可能出错的代码。
catch 里放处理错误的代码。
不要把整个程序都塞进一个巨大的 try:
try {
// 几百行代码
} catch (Exception e) {
}
这样你很难知道到底是哪一步出了问题。
五、多个 catch
不同异常可以分别处理:
try {
String text = null;
int number = Integer.parseInt(text);
System.out.println(number);
} catch (NumberFormatException e) {
System.out.println("数字格式错误");
} catch (NullPointerException e) {
System.out.println("文本不能为null");
}
catch 顺序要从具体到宽泛。
错误:
try {
int number = Integer.parseInt("abc");
} catch (Exception e) {
System.out.println("异常");
} catch (NumberFormatException e) {
System.out.println("数字格式异常");
}
Exception 已经能捕获 NumberFormatException,后面的 catch 永远到不了,编译器会报错。
正确:
try {
int number = Integer.parseInt("abc");
} catch (NumberFormatException e) {
System.out.println("数字格式异常");
} catch (Exception e) {
System.out.println("其他异常");
}
六、finally
finally 表示无论是否异常,最后都要执行。
try {
System.out.println("打开资源");
int number = Integer.parseInt("abc");
System.out.println(number);
} catch (NumberFormatException e) {
System.out.println("处理异常");
} finally {
System.out.println("关闭资源");
}
输出:
打开资源
处理异常
关闭资源
finally 常用于释放资源,比如关闭文件、关闭连接。
不过现代 Java 处理文件资源,更常用 try-with-resources:
try (BufferedReader reader = Files.newBufferedReader(path)) {
String line = reader.readLine();
}
下一章讲文件 IO 时会重点使用它。
七、throw:主动抛出异常
throw 用来抛出一个异常对象。
public void rename(String newName) {
if (newName == null || newName.trim().isEmpty()) {
throw new IllegalArgumentException("联系人姓名不能为空");
}
this.name = newName.trim();
}
如果姓名不合法,方法立刻停止。
后面的赋值不会执行。
这是好事。因为非法数据不应该进入对象。
常见主动抛出的运行时异常:
| IllegalArgumentException | 参数不合法 |
| IllegalStateException | 当前对象状态不允许这个操作 |
| NullPointerException | 某个必须非空的对象为 null,通常可用 Objects.requireNonNull |
| UnsupportedOperationException | 方法不支持当前操作 |
图书馆里:
if (!book.isAvailable()) {
throw new IllegalStateException("图书暂无可借副本:" + book.getTitle());
}
这里不是参数格式错,而是图书当前状态不允许借出,所以用 IllegalStateException。
八、throws:声明方法可能抛出异常
throws 写在方法声明上,表示这个方法可能抛出某种异常。
public String readText(String fileName) throws IOException {
return Files.readString(Path.of(fileName));
}
throws IOException 告诉调用者:这个方法可能遇到 IO 错误,比如文件不存在、没有权限、读取失败。
调用方必须处理:
try {
String text = readText("data.txt");
System.out.println(text);
} catch (IOException e) {
System.out.println("读取文件失败:" + e.getMessage());
}
throw 和 throws 不一样:
throw:真的抛出一个异常对象。
throws:声明这个方法可能把异常抛出去。
例子:
public void save() throws IOException {
throw new IOException("保存失败");
}
方法声明用 throws,方法体里用 throw。
九、受检异常和运行时异常
Java 异常大致分为:
Throwable
Error
Exception
RuntimeException
其他受检异常
1. RuntimeException
运行时异常不强制你 catch。
比如:
- IllegalArgumentException
- IllegalStateException
- NullPointerException
- IndexOutOfBoundsException
- NumberFormatException
它们通常表示程序逻辑、参数、状态问题。
2. Checked Exception
受检异常必须处理或声明抛出。
比如:
- IOException
- SQLException
这些通常和外部资源有关。文件、网络、数据库都可能失败,Java 要求你面对这些失败。
3. Error
Error 表示严重问题,比如:
- OutOfMemoryError
- StackOverflowError
普通业务代码不要捕获 Error。这类问题通常不是业务层能恢复的。
十、方法里应该 catch 还是继续 throws
一个方法遇到异常,有两种选择:
第一,在这里处理。
try {
String text = readText("data.txt");
System.out.println(text);
} catch (IOException e) {
System.out.println("读取失败,请检查文件");
}
第二,自己不处理,继续抛给上层。
public String loadConfig() throws IOException {
return Files.readString(Path.of("config.txt"));
}
怎么选?
看这个方法有没有能力做出正确处理。
如果当前方法只是底层读取文件,它不知道失败后应该提示用户、重试、还是使用默认配置,那就继续抛出。
如果当前方法是命令行入口,能给用户明确提示,那就 catch。
不要为了消除编译错误随便 catch:
try {
readText("data.txt");
} catch (IOException e) {
}
这叫吞异常,是非常危险的。
十一、不要吞异常
错误写法:
try {
saveContacts();
} catch (IOException e) {
}
如果保存失败,用户以为保存成功,实际上数据没写进去。
至少要打印或重新抛出:
try {
saveContacts();
} catch (IOException e) {
System.out.println("保存联系人失败:" + e.getMessage());
}
更好的做法是在上层统一处理,或者包装成业务异常:
try {
saveContacts();
} catch (IOException e) {
throw new ContactStorageException("保存联系人失败", e);
}
这里保留了原始异常 e 作为 cause。以后排查时能看到真正底层原因。
十二、自定义业务异常
有时 Java 内置异常不够表达业务。
比如联系人存储失败,可以定义:
public class ContactStorageException extends RuntimeException {
public ContactStorageException(String message) {
super(message);
}
public ContactStorageException(String message, Throwable cause) {
super(message, cause);
}
}
使用:
public void save() {
try {
// 写文件
} catch (IOException e) {
throw new ContactStorageException("保存联系人文件失败", e);
}
}
为什么继承 RuntimeException?
业务异常是否做成受检异常,没有绝对答案。很多现代项目会把业务异常做成运行时异常,然后在统一入口捕获处理。
如果你希望调用者必须处理某类外部失败,也可以继承 Exception。
本教程现阶段建议:
- 参数和业务状态问题,用运行时异常。
- 文件 IO 这类底层 API 遇到的 IOException,先按 Java 要求处理。
- 需要转成业务语义时,包装成自定义运行时异常。
十三、异常包装要保留 cause
不推荐:
catch (IOException e) {
throw new ContactStorageException("保存失败");
}
这样原始 IOException 丢了。
推荐:
catch (IOException e) {
throw new ContactStorageException("保存失败", e);
}
异常链能告诉你:
业务上:保存失败
底层原因:权限不足 / 文件不存在 / 磁盘错误
排查问题时,cause 很重要。
十四、在命令行项目里统一处理异常
联系人项目里,菜单可以这样处理:
try {
manager.addContact(contact);
System.out.println("新增成功");
} catch (IllegalArgumentException | IllegalStateException e) {
System.out.println("操作失败:" + e.getMessage());
}
如果每个菜单动作都写一遍 catch,会有点重复。
可以在主循环外层统一包住:
while (true) {
try {
// 读取命令并执行
} catch (RuntimeException e) {
System.out.println("操作失败:" + e.getMessage());
}
}
这适合小型命令行程序。
真实项目里通常会有更系统的异常处理层,比如 Web 项目里的全局异常处理器。
十五、异常信息要给人看得懂
不好的异常消息:
throw new IllegalArgumentException("error");
更好的:
throw new IllegalArgumentException("手机号不能为空");
再好一点:
throw new IllegalArgumentException("手机号只能包含数字、空格或横线:" + phone);
异常消息要回答:
哪里错了?
为什么错?
最好能带上关键值。
但不要把敏感信息写进异常,比如密码、身份证完整号码、密钥。
十六、异常不是替代所有 if
不要用异常控制普通流程。
不推荐:
try {
Contact contact = manager.findByPhone(phone);
System.out.println(contact.getName());
} catch (NullPointerException e) {
System.out.println("联系人不存在");
}
应该写:
Contact contact = manager.findByPhone(phone);
if (contact == null) {
System.out.println("联系人不存在");
} else {
System.out.println(contact.getName());
}
“没找到联系人”在查询场景里可能是正常结果,不一定要用异常。
但删除联系人时,要求删除一个不存在的手机号,可以视为操作失败:
public void removeByPhone(String phone) {
Contact removed = contactsByPhone.remove(normalizedPhone);
if (removed == null) {
throw new IllegalArgumentException("联系人不存在:" + normalizedPhone);
}
}
是否用异常,要看业务语义。
十七、常见异常
1. NullPointerException
空引用调用方法:
String text = null;
text.length();
解决:明确哪些值允许为空,哪些不允许;构造方法和参数入口尽早校验。
2. IndexOutOfBoundsException
下标越界:
List<String> names = new ArrayList<>();
names.get(0);
解决:访问前检查 size()。
3. NumberFormatException
字符串转数字失败:
Integer.parseInt("abc");
解决:捕获后提示用户输入数字。
4. IllegalArgumentException
参数不合法:
new Contact("", "小林", "", null);
5. IllegalStateException
对象状态不允许:
book.borrowOneCopy(); // 没库存
6. IOException
文件或 IO 失败:
Files.readString(Path.of("missing.txt"));
下一章会大量见到。
十八、练习
练习 1:解析年龄
写方法:
public static int parseAge(String text)
要求:
- null 或空字符串抛 IllegalArgumentException
- 不是数字时抛 IllegalArgumentException
- 年龄小于 0 或大于 150 抛 IllegalArgumentException
提示:内部可以捕获 NumberFormatException。
练习 2:联系人查找
给 ContactManager 增加:
public Contact requireByPhone(String phone)
找不到时抛:
IllegalArgumentException("联系人不存在:" + phone)
练习 3:自定义异常
定义:
public class ContactNotFoundException extends RuntimeException
让 requireByPhone 找不到时抛这个异常。
练习 4:不要吞异常
写一段读取文件的代码,先故意 catch 后什么都不做,观察问题。
然后改成打印清楚错误消息。
练习 5:异常包装
定义:
public class ContactStorageException extends RuntimeException
要求提供两个构造方法:
ContactStorageException(String message)
ContactStorageException(String message, Throwable cause)
思考为什么第二个构造方法重要。
十九、本章小结
异常处理的目标不是让错误消失,而是让错误被清楚表达、正确传递、合理处理。
你现在应该理解:
- 异常是表示运行中错误的对象。
- try/catch 用来捕获并处理异常。
- finally 用来执行收尾逻辑。
- throw 是抛出异常对象。
- throws 是声明方法可能抛出异常。
- 运行时异常通常表示参数、状态或程序逻辑问题。
- 受检异常通常表示外部资源失败,调用方必须处理或继续声明。
- 不要吞异常。
- 包装异常时要保留 cause。
- 自定义异常能表达业务语义。
- 异常不是普通 if/else 的替代品。
下一章讲文件和 IO。你会看到为什么 IOException 是受检异常,也会学习 Path、Files、字符流、字节流和 try-with-resources。



