05异常-断言-日志

5485 字
27 分钟
05异常-断言-日志

第 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();
}

对照着记三点:

throwsthrow
位置方法 / 构造器的签名方法体里的语句
后面跟异常类型异常对象(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 执行之后,控制权往哪儿走:

  1. new 出一个异常对象,顺带记下当前的调用栈。
  2. 当前方法立刻结束,后面的语句一行都不执行。
  3. 异常沿调用栈往上找,遇到第一个类型对得上的 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; // 条件不成立就抛 AssertionError
assert balance >= 0 : "余额为负: " + balance; // 带上消息

断言失败抛的是 AssertionError——注意它在 Error 那一支,不是 Exception,本来就不该被 catch。

最关键的一点:断言默认是关闭的。不加开关,代码里所有 assert 语句在运行时都会被直接跳过,等于白写。

7.4.2 启用和禁用断言#

开关加在 java 启动命令上,不是 javac:

Terminal window
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 更严重的日志才收,更细的直接丢掉。但光调它往往还是看不到——一条日志要打到屏幕上,得过两道关:

  1. logger 先筛:这条日志级别够不够,不够直接丢掉,不往下传。
  2. 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 = FINE
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
# 文件处理器:输出路径(%h 是用户主目录)
java.util.logging.FileHandler.pattern = %h/app.log
java.util.logging.FileHandler.level = INFO

改这里不用动代码。指定另一份配置:

Terminal window
java -Djava.util.logging.config.file=my.properties Demo

7.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 判断
NumberFormatExceptionInteger.parseInt("abc")输入没校验,直接拿去转
IllegalArgumentException方法收到不该收的参数调用方传错,检查没做在入口
ArithmeticException整数除以 0忘了判除数为零
IllegalStateException对象状态不允许当前操作调用顺序错了,如用完没重置
UnsupportedOperationException调了该实现不支持的方法拿到的是不可修改的集合
FileNotFoundException文件路径不存在路径拼错、没权限、工作目录不对
IOException各种 I/O 失败文件、网络、权限——受检异常的总类

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
05异常-断言-日志
https://hecloud.top/posts/tech/java-se/05异常-断言-日志/
作者
贺小云
发布于
2026-10-26
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
贺小云
热爱技术与折腾的博客 Dancing with uncertainty. 与不确定性共舞
公告
出于成本考虑,贺云已全面转型Serverless架构
分类
标签
碎碎念
站点统计
文章
91
分类
5
标签
44
总字数
103,192
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.16.5
文章许可
CC BY-NC-SA 4.0