05异常-断言-日志
- 100概述
- 201Java背景和基本结构
- 302对象与类
- 403继承与反射
- 504接口-lambda-内部类
- 605异常-断言-日志本文

第 7 章 异常、断言和日志
7.1 处理错误
方法出错只有三种结局:就地解决、把问题往上交、直接崩掉。Java 的做法是抛出一个异常对象——它带着「出了什么事」和「在哪里出的」,沿调用栈往上一层一层找处理者。
7.1.1 异常分类
所有异常都继承自 Throwable,往下先分两支:
Throwable├── Error 虚拟机级故障,程序不该捕获│ ├── OutOfMemoryError│ └── StackOverflowError└── Exception ├── RuntimeException 非受检 │ ├── NullPointerException │ ├── ArrayIndexOutOfBoundsException │ ├── ClassCastException │ └── IllegalArgumentException └── IOException 受检 ├── FileNotFoundException └── ...Error 是虚拟机自己撑不住了——内存耗尽、栈溢出,你接住也没法恢复,所以不该 catch。
Exception 才是要给程序处理的。它又分两类,判断规则一句话:
RuntimeException 及其子类不受检查,其余 Exception 子类受检查;Error 也不受检查。
- 受检:编译器会检查你处理没处理。处理只有两条路——就地
try-catch掉,或者在自己方法上throws交出去;两条都不走就编译不过,报错原话是must be caught or declared to be thrown。这类异常来自外部世界:文件不在、网络断了、磁盘满了。 - 非受检:编译器问都不问,你可以完全不理,代码照样编译,真出了就运行时崩掉。这类是代码自己写错了——空指针、下标越界、类型转错,该靠改代码消灭,而不是靠
catch。
vs C:C 没有异常机制,出错靠返回值 +
errno,调用者完全可以装作没看见,错误就这么烂在底下;Java 的受检异常是编译期强制,不处理连class都生成不出来。
7.1.2 声明检查型异常
受检异常不想就地处理,就得写进方法签名,用 throws:
public void load(String path) throws IOException { Scanner in = new Scanner(Path.of(path)); // 方法体里可能抛 IOException,自己不管,交给调用者}==调用这个方法==的人只有两条路:就地 try 掉,或者自己也 throws 上去。
try { load("data.txt");} catch (IOException e) { System.out.println("读取失败:" + e.getMessage());}throws 和 throw 差一个字母,完全是两回事。
throws 写在方法头,后面跟的是类型,多个用逗号隔开(throws IOException, SQLException),自己不会产生任何对象。
它只说一件事:调用我的人可能收到这些异常。位置也有限制——只有方法和构造器的声明后面能写,类、接口、字段、初始化块一律不行。
throw 写在方法体,后面跟的必须是异常对象,执行到这一行就真的把异常扔出去:
public void save(String text) throws IOException { if (text.length() > 1000) { throw new IOException("内容超长:最多 1000 字"); // 受检异常,所以方法头必须有 throws } PrintWriter out = new PrintWriter("note.txt"); out.println(text); out.close();}对照着记三点:
throws | throw | |
|---|---|---|
| 位置 | 方法 / 构造器的签名 | 方法体里的语句 |
| 后面跟 | 异常类型 | 异常对象(new 出来的) |
| 作用 | 提前告知 | 当场抛出 |
两者还有一条联动规则:throw 抛的是受检异常时,所在方法必须 throws 同一个类型(或它的父类型),否则编译不过——方法体里哪怕只有这一句 throw,方法头的 throws IOException 也省不掉。抛 RuntimeException 及其子类(以及 Error)时没有这条约束——方法头写不写 throws 都行,编译器根本不检查。
还有一点:throws 声明的是「可能」,不是「一定」。方法体里一条 throw 都没有也完全正常——异常可能是它调用的其他方法带出来的,只要可能冒出来,就得交代。反过来,只要方法体内部抛了受检异常又没有当场 catch,就必须有throws。
两个细节:
- 覆盖父类方法时,子类不能声明更宽的受检异常——父类说只抛
IOException,子类不能改成throws Exception,否则多态调用时就接不住了。 - 别为了省事到处写
throws Exception。那等于把「出什么错」这个信息丢掉了,调用者只能一把抓。
7.1.3 如何抛出异常
public void withdraw(double amount) throws InsufficientFundsException { if (amount > balance) { throw new InsufficientFundsException("余额不足,还差 " + (amount - balance)); } balance -= amount;}throw 执行之后,控制权往哪儿走:
new出一个异常对象,顺带记下当前的调用栈。- 当前方法立刻结束,后面的语句一行都不执行。
- 异常沿调用栈往上找,遇到第一个类型对得上的
catch就交出去;没人接,线程带着栈轨迹死掉。
同一个块里 throw 之后紧跟的语句,编译器直接判为「不可达」(unreachable statement),编译不过。
7.1.4 创建异常类
自定义异常就是一个普通的类,选一个父类、给两个构造器:
public class InsufficientFundsException extends Exception { public InsufficientFundsException() { }
public InsufficientFundsException(String message) { super(message); }}受检与否跟着父类走,继承谁就跟谁一类:
extends Exception→ 受检,调用者被迫处理。extends RuntimeException→ 非受检,调用者不用管。extends IOException这类已有的受检异常 → 还是受检。
选哪个看意图:调用者有办法补救,就选受检(让调用者去提示用户换个文件);调用者只能改自己代码,就选非受检(参数非法、状态不对)。
需要携带数据就自己加字段和构造器,比如余额不足的异常里存一个 double shortage,catch 到的人就能直接显示差多少钱。
类名以 Exception 结尾是惯例,JDK 里全这样。
7.2 捕获异常
7.2.1 捕获异常概述
public class Demo { public static void main(String[] args) { int[] scores = new int[3]; try { scores[5] = 100; System.out.println("这行不会执行"); } catch (ArrayIndexOutOfBoundsException e) { System.out.println("下标越界:" + e.getMessage()); } System.out.println("程序继续运行"); }}输出:
下标越界:Index 5 out of bounds for length 3程序继续运行三件事同时发生:try 块里出错点之后的语句不再执行;控制权交给类型匹配的 catch;catch 块结束后,程序从 try-catch 之后继续跑,就像没出过事。
e 是异常对象,最常用的两个方法是 getMessage()(拿消息)和 printStackTrace()(打调用链)。getMessage() 可能返回 null——有些异常构造时就没带消息,别直接拿它拼字符串。
7.2.2 捕获多个异常
一个 try 后面可以跟多个 catch,顺序必须从子类到父类:
try { Scanner in = new Scanner(Path.of("data.txt")); // ...} catch (FileNotFoundException e) { // 子类,放前面 System.out.println("文件不存在");} catch (IOException e) { // 父类,放后面 System.out.println("读文件出错:" + e.getMessage());}写反了编译器直接报错 exception ... has already been caught——因为父类的 catch 会把子类的异常全接走,后面的分支永远轮不到。
如果几种异常的处理方式完全一样,可以合并成一条:
try { LocalDate day = LocalDate.parse("2026-13-01"); Scanner in = new Scanner(Path.of("data.txt"));} catch (IOException | DateTimeParseException e) { System.out.println("数据读不出来:" + e.getMessage());}合并后 e 只剩一个类型:Java 顺着两条继承链往上找,取第一个重合的祖先——IOException 和 DateTimeParseException 都继承自 Exception,所以 e 就是 Exception。它只能调用 Exception 里有的方法(getMessage()、printStackTrace()),子类型特有的方法一概调不了,需要分别处理就拆回两个 catch。合并的 e 还是隐式 final 的,不能再赋值。
两个类型之间有继承关系时不允许合并,比如 IOException | FileNotFoundException 直接编译不过。
catch (Exception e) 会把所有异常一网打尽,包括非受检的——偶尔用来在 main 里兜底可以,日常处理别这么写,等于放弃了按类型分类处置。
7.2.3 再次抛出异常与异常链
两种「接住了又放走」的情况。
第一种:原样再抛。 先做点清理(记日志、回滚),但处理不了,继续往上交:
try { load("data.txt");} catch (IOException e) { System.out.println("读取失败,交给上层处理"); throw e; // 注意:方法是受检异常就得自己 throws}第二种:包装成另一种异常。 底层抛的 IOException 对上层来说没意义——上层只关心「订单加载失败」。换异常时必须把原异常带进去:
public class OrderException extends RuntimeException { public OrderException(String message, Throwable cause) { super(message, cause); }}
public void loadOrder() { try { Scanner in = new Scanner(Path.of("order.txt")); // ... } catch (IOException e) { throw new OrderException("订单文件读取失败", e); }}打印出来长这样:
Exception in thread "main" OrderException: 订单文件读取失败 at com.example.hr.Demo.loadOrder(Demo.java:12) ...Caused by: java.io.FileNotFoundException: order.txt (No such file or directory) at java.base/java.io.FileInputStream.open0(Native Method) ...Caused by 这一段就是原始现场。不把 e 传进去,这段就没了——换成另一个异常等于把根因抹掉,日后查问题只能靠猜。这就是异常链。
老式异常类没有带 cause 的构造器,用 initCause(e) 补上,效果一样。
7.2.4 finally子句
finally 里的代码一定会执行,不管 try 是正常跑完、抛了异常、还是半路 return:
Scanner in = null;try { in = new Scanner(Path.of("data.txt")); while (in.hasNextLine()) { System.out.println(in.nextLine()); }} catch (IOException e) { e.printStackTrace();} finally { if (in != null) in.close(); // 不管走哪条路都关掉}只有两种情况它不执行:JVM 被杀掉、或者中途调了 System.exit。
别在 finally 里 return。 finally 的 return 会顶掉还没处理完的异常,把它整个吞掉——程序看起来正常返回了,错误却凭空消失。
上面这段代码其实还留着两个坑:new Scanner 那行就抛异常时 in 还是 null,得判空;close() 自己也可能抛异常,那就会把原来的异常盖住。下一节专门解决这些。
vs C:C 没有
finally,清理代码只能每个出口抄一遍,或者用goto cleanup收拢到一处——后者能写对,但语言不给你任何保障。
7.2.5 try-with-resources语句
上一节留下的两个坑,Java 7 给了一个专门的写法堵掉:把要关的东西写进 try 的括号里。
try (Scanner in = new Scanner(Path.of("data.txt"))) { while (in.hasNextLine()) { System.out.println(in.nextLine()); }} catch (IOException e) { e.printStackTrace();}括号里声明的对象,离开 try 块时 close() 会自动被调用——正常跑完要调,抛异常往外飞之前也要调。不用判空,不用手写 finally。
放进去的要求只有一个:这个类自己有 close() 方法。Scanner、PrintWriter、各种文件流都有;这类「用完必须关」的对象习惯上叫资源。
Java 用一个接口来标记「我有 close()」,叫 java.lang.AutoCloseable,全身上下一个方法:
public interface AutoCloseable { void close() throws Exception;}类只要 implements AutoCloseable、把 close() 写出来,编译器就允许它进 try 括号——自己写的类也一样。
多个资源写在同一个括号里,用分号隔开,关闭顺序和声明顺序相反(后开的先关):
try (Scanner in = new Scanner(Path.of("in.txt")); PrintWriter out = new PrintWriter("out.txt")) { while (in.hasNextLine()) { out.println(in.nextLine().toUpperCase()); }} catch (IOException e) { e.printStackTrace();} // 先关 out,再关 in关闭时如果真的也抛了异常,它不会顶掉原来的异常,而是作为「被抑制的异常」挂在原异常后面,栈轨迹里显示为 Suppressed:。这一点比手写 finally 安全。
日常写文件 I/O 一律用这个,finally 手动关资源的写法基本可以淘汰了。
vs C:这就是 C++ 的 RAII——析构函数在离开作用域时自动跑。C 里没有析构,只能手动
fclose,漏一条路径就泄漏一个句柄。
7.2.6 分析栈轨迹元素
栈轨迹是异常里最有价值的东西,长这样:
Exception in thread "main" java.lang.ArithmeticException: / by zero at com.example.hr.Demo.divide(Demo.java:9) at com.example.hr.Demo.main(Demo.java:4)读法固定:
- 第一行:异常类型 + 消息。
/ by zero已经说明了死因。 - 每个
at是一层调用帧:类.方法(文件名:行号)。 - 最上面那一行是抛出点,越往下越是调用者,最底下是程序的入口。
所以定位只需要看第一个 at,直接跳到那个文件的第 9 行。
程序里也能拿到这些信息:e.getStackTrace() 返回 StackTraceElement[],每一项有 getClassName()、getMethodName()、getLineNumber()。需要把错误上报到服务器时才会用到,日常读控制台输出就够。
vs C:C 崩溃时只能靠 core dump 加
gdb的backtrace,还得先打开 core 文件开关;Java 的栈轨迹是异常对象自带的,一出错就自动打全。
7.3 使用异常的技巧
-
别用异常做正常流程。 能在事前用
if判断的,不要靠catch兜——异常的开销和可读性都不划算。// 反例try {return arr[index].length();} catch (NullPointerException e) {return 0;} -
别吞异常。 空的
catch {}是最坏的写法,错误就此消失。至少打一行日志。 -
早抛晚捕。 发现问题的层次立刻
throw,能决定怎么处理的层次再catch,中间层只管往上放。 -
包一层再抛。 底层异常翻译成调用者看得懂的业务异常,同时用异常链保留根因。
-
别一把抓。 除了
main兜底,不要随手catch (Exception e),那样所有错误都变成同一种。 -
受检异常别滥用。 现代项目里业务异常大多直接继承
RuntimeException,受检异常留给真正可恢复的 I/O 问题。
7.4 使用断言
7.4.1 断言的概念
assert balance >= 0; // 条件不成立就抛 AssertionErrorassert balance >= 0 : "余额为负: " + balance; // 带上消息断言失败抛的是 AssertionError——注意它在 Error 那一支,不是 Exception,本来就不该被 catch。
最关键的一点:断言默认是关闭的。不加开关,代码里所有 assert 语句在运行时都会被直接跳过,等于白写。
7.4.2 启用和禁用断言
开关加在 java 启动命令上,不是 javac:
java Demo # 断言关闭(默认)java -ea Demo # 断言全开java -ea:com.example... Demo # 只给这个包及其子包开IDEA 里要写在运行配置的 VM options 里,直接点运行按钮是不带 -ea 的——这也是「我本地断言明明没报错」的常见原因。
生产环境一般不开:断言是给开发和测试用的。
7.4.3 使用断言完成参数检查
这里有个容易记反的地方:参数检查用异常,不用断言。
// ✅ public 方法检查参数:抛异常public void setSalary(double salary) { if (salary < 0) { throw new IllegalArgumentException("薪水不能为负:" + salary); } this.salary = salary;}
// ✅ 断言:检查程序内部假设private int sum(int[] data) { assert data.length > 0 : "空数组不该进到这里"; int total = 0; for (int v : data) total += v; return total;}原因有两条:断言可能被 -da 关掉,关掉后非法参数就一路畅通;而且 AssertionError 是 Error,把它抛给调用者本身就不合适。参数非法时要抛 IllegalArgumentException,那是 Exception 那一支。
判空这类最常见的检查,JDK 已经写好了:Objects.requireNonNull(name, "name 不能为 null"),不满足就抛 NullPointerException。
7.4.4 使用断言提供假设文档
断言的第二个作用是把「我默认它成立」写下来,而且能被真的检查一遍:
// 约定:list 已按 id 升序排列assert isSorted(list) : "list 未排序";这既是给后来的读者看的注释,也是测试期的一根探针——假设一旦不成立,立刻炸出来,而不是等到几天后数据全乱了才发现。
7.5 日志
System.out 调试有个致命问题:上线之后要么全留(刷屏),要么全删(出事了没记录)。日志就是带级别的打印,可以按需开关、按需改去向。Java 自带的是 java.util.logging(常简称 JUL)。
7.5.1 基本日志
最省事的写法:
import java.util.logging.Logger;
public class Demo { public static void main(String[] args) { Logger logger = Logger.getGlobal(); logger.info("程序启动"); logger.warning("磁盘空间不足"); logger.severe("数据库连不上"); }}输出:
10月 27, 2026 3:20:15 下午 Demo main信息: 程序启动10月 27, 2026 3:20:15 下午 Demo main警告: 磁盘空间不足10月 27, 2026 3:20:15 下午 Demo main严重: 数据库连不上第一行是时间、类名、方法名,第二行是级别加消息。级别名会按系统语言显示成「信息 / 警告 / 严重」(对应代码里的 INFO / WARNING / SEVERE)。
每个级别都有同名的小写方法:severe() → SEVERE、warning() → WARNING,依次类推,还有 config()、fine()、finer()、finest()。想一次打印任意级别也可以 logger.log(Level.WARNING, "消息")。
顺带一个细节:日志默认打到 System.err,不是 System.out。
7.5.2 高级日志
实际用的时候,每个类建一个自己的 logger,名字用类名:
private static final Logger logger = Logger.getLogger("com.example.hr.Demo");
logger.info("张三入职,月薪 " + salary);logger.warning("余额低,剩余 " + balance);logger 名字用点号分层级,com.example.hr.Demo 的父 logger 是 com.example.hr,再往上是 com.example。
这里的「继承」跟类的继承无关,说的是级别怎么找:某个 logger 自己没设级别,就沿着名字往上一层一层找,用最近一个设过的级别。
Logger hr = Logger.getLogger("com.example.hr");hr.setLevel(Level.FINE); // 设门槛:这一支都收 FINE 及以上
Logger demo = Logger.getLogger("com.example.hr.Demo");demo.fine("调试信息"); // 写一条 FINE 级别的日志,门槛沿用父级注意:demo.fine(...) 里的 fine 不是设级别,而是「写一条 FINE 级别的日志」——括号里放的是消息。设门槛只有一个方法:setLevel。它做的是往 demo 这个 logger 里写一条 FINE 级别的记录,括号里是消息内容。它不改任何配置。
所以按包统一调级别只要设一次父级,包里所有类都跟着变。
七个级别从高到低:
SEVERE → WARNING → INFO → CONFIG → FINE → FINER → FINEST
默认只输出 INFO 及以上,所以写了 logger.fine(...) 也看不到。
logger.setLevel(Level.FINE) 是给这个 logger 设最低门槛:比 FINE 更严重的日志才收,更细的直接丢掉。但光调它往往还是看不到——一条日志要打到屏幕上,得过两道关:
- logger 先筛:这条日志级别够不够,不够直接丢掉,不往下传。
- handler 再筛:logger 放行后交给 handler(默认是控制台那个),handler 又按自己的级别筛一遍。
两道关默认都卡在 INFO。只把 logger 那关调低,handler 那关还锁着,FINE 照样出不来。要两处一起调——logging.properties 里正好就是两行(7.5.3)。
7.5.3 修改日志管理器配置
全局配置在一个 properties 文件里,JDK 9 以后在 $JAVA_HOME/conf/logging.properties:
# 所有 logger 的默认级别.level = FINE
# 控制台处理器:级别和格式java.util.logging.ConsoleHandler.level = FINEjava.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
# 文件处理器:输出路径(%h 是用户主目录)java.util.logging.FileHandler.pattern = %h/app.logjava.util.logging.FileHandler.level = INFO改这里不用动代码。指定另一份配置:
java -Djava.util.logging.config.file=my.properties Demo7.5.4 本地化
日志文本也能按语言切换:用一个资源包(ResourceBundle)存消息模板,类型是 Localizable 的记录会去查表。实际项目极少这么做,一般直接写英文消息,界面文案另走一套。认得即可。
7.5.5 处理器
Handler 决定日志去哪儿:
Logger logger = Logger.getLogger("com.example.hr.Demo");logger.setUseParentHandlers(false); // 不再往父 logger 传,避免重复输出logger.addHandler(new ConsoleHandler()); // 控制台logger.addHandler(new FileHandler("app.log")); // 文件一个 logger 可以挂多个 handler——一份日志同时进控制台和文件,就是加两行的事。
两个常见坑:FileHandler 的构造器抛受检的 IOException,得处理;给 logger 加了自己的 handler 后,默认还会把记录传给父 logger 的 handler,同一个日志就会打印两遍,所以通常先 setUseParentHandlers(false)。
7.5.6 过滤器
Filter 是比级别更细的一道闸,逐条判断要不要放行:
logger.setFilter(record -> record.getMessage() != null && !record.getMessage().contains("密码"));接口方法就一个 boolean isLoggable(LogRecord record),用 lambda 实现最方便。级别是粗筛,过滤器是细筛。
7.5.7 格式化器
Formatter 决定日志长什么样。JDK 自带两个:SimpleFormatter(纯文本,默认就是这个)和 XMLFormatter(XML,便于机器解析)。
要自定义就继承 Formatter,实现 format(LogRecord record) 返回一个字符串,然后 handler.setFormatter(new MyFormatter())。日常项目里通常换成 Logback 之类,这一层认得即可。
7.5.8 日志技巧
- 一个类一个 logger,
private static final,名字用全类名:Logger.getLogger(Demo.class.getName())(.class是 5.9 的Class对象,认得即可)。 - 记录异常要把异常对象传进去:
logger.log(Level.SEVERE, "保存失败", e);。写成logger.severe(e.getMessage())就只剩一行消息,栈轨迹全丢了。 - 级别别乱用:正常流程
INFO,能恢复的异常情况WARNING,出错了SEVERE,调试细节FINE。 - 别忘了日志是给上线后的人看的——消息里写清「哪条数据、什么操作、什么值」,别只写「失败了」。
7.6 调试技巧
- 断点:比
System.out强的地方是能停住看现场——变量值、调用栈、任意表达式求值,都不用改代码。 - 条件断点:只在
i == 999时才停,不用手动按 999 次继续。 - 单步分三种:
step into走进被调方法,step over把整个方法当一步跨过,step out从当前方法跳回调用处。分清这三个,调试就够用了。 - 崩溃先读栈轨迹(7.2.6)。十次里有九次答案就在第一行,别看都不看就去猜。
- 二分定位:注释掉一半代码看还崩不崩,比逐行读快得多。
- 最小复现:把问题缩到一个能单独运行的小文件。缩的过程本身经常就把 bug 找出来了。
附录
常见异常速查
| 异常 | 什么时候抛 | 通常的根因 |
|---|---|---|
NullPointerException | 对 null 调方法、取字段、算长度 | 对象没初始化,或方法返回了 null 没判 |
ArrayIndexOutOfBoundsException | 下标 < 0 或 >= length | 循环边界写错,差一 |
ClassCastException | 强转的类型对不上 | 转之前没做 instanceof 判断 |
NumberFormatException | Integer.parseInt("abc") | 输入没校验,直接拿去转 |
IllegalArgumentException | 方法收到不该收的参数 | 调用方传错,检查没做在入口 |
ArithmeticException | 整数除以 0 | 忘了判除数为零 |
IllegalStateException | 对象状态不允许当前操作 | 调用顺序错了,如用完没重置 |
UnsupportedOperationException | 调了该实现不支持的方法 | 拿到的是不可修改的集合 |
FileNotFoundException | 文件路径不存在 | 路径拼错、没权限、工作目录不对 |
IOException | 各种 I/O 失败 | 文件、网络、权限——受检异常的总类 |
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





