Java核心技术1-异常

  |  

摘要: 本文是《Java核心技术 10th》中关于异常、断言、日志的要点总结

【对算法,数学,计算机感兴趣的同学,欢迎关注我哈,阅读更多原创文章】
我的网站:潮汐朝夕的生活实验室
我的公众号:算法题刷刷
我的知乎:潮汐朝夕
我的github:FennelDumplings
我的leetcode:FennelDumplings


本文是《Java核心技术1》第10版 Chap7 中关于异常的要点总结。

当用户运行程序期间,可能出现程序错误、外部环境影响造成用户数据丢失等情况。此时至少应该做到以下几点:

  • 向用户通告错误
  • 保存所有的工作结果
  • 允许用户以妥善形式退出程序

本文主要涉及以下三个方面:

(1) 异常

对于异常情况,Java 用异常处理的【错误捕获机制】来处理。Java 中的异常处理与 C++ 中的异常处理很类似。

(2) 断言

测试期间需要大量的检测已验证程序的正确性,这些代码如果用复制粘贴的方式维护,就比较累了,而断言是一种相对优雅的方式。

(3) 日志

当程序出现错误时,并不总是能与用户或终端进行交互。此时需要记下问题,以备后续分析。Java 中有标准日志框架可以做这件事。

在文章 Java核心技术1-断言与日志 中我们介绍了断言、日志和调试,本文我们介绍异常。


处理错误

异常处理的任务就是将控制权从错误产生的地方转移给能够处理这种情况的错误处理器。为了能够在程序中处理异常情况,必须研究程序中可能出现的错误和问题。

(1) 用户输入错误,例如用户请求连接一个 URL 但是语法不正确。
(2) 设备错误,例如打印机打印过充中没纸了。
(3) 物理限制,例如存储空间已经用完。
(4) 代码错误,例如返回错误答案。

异常分类

异常对象都是派生于 Throwable 类的一个实例。

Java 中的异常层次结构示意图如下:

Error 类层次结构描述了 Java 运行时系统的内部错误和资源耗尽错误。应用程序不应该抛出这种类型的对象。如果出现了这样的内部错误,除了通告给用户,并尽力使程序安全地终止之外,再也无能为力了。

需要关注的是 Exception 层次结构。这个层次结构又分解为两个分支:一个分支派生于 RuntimeException;另一个分支包含其他异常。划分两个分支的规则是:由程序错误导致的异常属于 RuntimeException;而程序本身没有问题,但由于像 I/O 错误这类问题导致的异常属于其他异常

派生于 RuntimeException 的异常举例:

  • 错误的类型转换。
  • 数组访问越界。
  • 访问 null 指针。

不是派生于 RuntimeException 的异常举例:

  • 试图在文件尾部后面读取数据。
  • 试图打开一个不存在的文件。
  • 试图根据给定的字符串查找 Class 对象,而这个字符串表示的类并不存在。

Java语言规范将派生于 Error 类或 RuntimeException 类的所有异常称为非受查(unchecked)异常,所有其他的异常称为受查(checked)异常

C++ 和 Java 中关于异常的区别和联系

C++有两个基本的异常类,一个是runtime_error;另一个是logic_error。

  • logic_error 类相当于 Java 中的 RuntimeException,它表示程序中的逻辑错误;
  • runtime_error 类是所有由于不可预测的原因所引发的异常的基类。它相当于 Java 中的非 RuntimeException 异常。

声明受查异常 throws

一个方法不仅需要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误。

方法应该在其首部声明所有可能抛出的异常。这样可以从首部反映出这个方法可能抛出哪类受查异常。例如,下面是标准类库中提供的 FileInputStream 类的一个构造器的声明

1
public FileInputStream(String name) throws FileNotFoundException

如果这个方法真的抛出了 FileNotFoundException 这个异常对象,运行时系统就会开始搜索异常处理器,以便知道如何处理 FileNotFoundException 对象。

不必将所有可能抛出的异常都进行声明。我们关注的是什么时候需要在方法中用 throws 子句声明异常,什么异常必须使用 throws 子句声明。

遇到下面 4 种情况时应该抛出异常

(1) 调用一个抛出受查异常的方法,例如 FileInputStream 构造器
(2) 程序运行过程中发现错误,并且利用 throw 语句抛出一个受查异常
(3) 程序出现错误,例如 a[-1] = 0 会抛出 ArrayIndexOutOfBoundsException 这个非受查异常
(4) Java 虚拟机和运行时库出现的内部错误

  • 前两种情况,必须告诉调用这个方法的程序员有可能抛出异常,也就是在首部进行声明。

对于那些可能被他人使用的 Java 方法,应该根据异常规范(exception specification),在方法的首部声明这个方法可能抛出的异常。

1
2
3
4
5
class MyAnimation {
public Image loadImage(String s) throws IOException {
...
}
}

如果一个方法有可能抛出多个受查异常类型,那么就必须在方法的首部列出所有的异常类

1
2
3
4
5
class MyAnimation {
public Image loadImage(String s) throws FileNotFoundException, EOFException {
...
}
}
  • 对于 Java 的内部错误,也就是从 Error 继承的错误,以及从 RuntimeException 继承的非受查异常,我们没有任何控制能力,不应该声明。

如果方法没有声明所有可能发生的受查异常,编译器就会发出一个错误消息。

子类覆盖超类方法是的异常声明问题

如果在子类中覆盖了超类的一个方法,子类方法中声明的受查异常不能比超类方法中声明的异常更通用(也就是说,子类方法中可以抛出更特定的异常,或者根本不抛出任何异常)。

如果超类方法没有抛出任何受查异常,子类也不能抛出任何受查异常。

声明异常类的子类的问题

如果类中的一个方法声明将会抛出一个异常,而这个异常是某个特定类的实例时,则这个方法就有可能抛出一个这个类的异常,或者这个类的任意一个子类的异常。

例如,FileInputStream 构造器声明将有可能抛出一个 IOException 异常,然而并不知道具体是哪种 IOException 异常。它既可能是 IOException 异常,也可能是其子类的异常,例如,FileNotFoundException。

C++ 的 throw 与 Java 的 throws 的区别和联系

在 C++ 中,throw 说明符在运行时执行,而不是在编译时执行。也就是说,C++ 编译器将不处理任何异常规范。但是,如果函数抛出的异常没有出现在 throw 列表中,就会调用 unexpected 函数,这个函数的默认处理方式是终止程序的执行。

在 C++ 中,如果没有给出 throw 说明,函数可能会抛出任何异常。而在 Java 中,没有 throws 说明符的方法将不能抛出任何受查异常。

抛出异常

1
2
3
4
5
6
7
8
9
10
11
12
String readData(Scanner in) throws EOFException {
...
while(...) {
if(!in.hasNext()) { // 遇到 EOF
if(n < len) {
throw new EOFException("...");
}
}
...
}
...
}

对于一个已经存在的异常类,将其抛出非常容易。在这种情况下:

(1) 找到一个合适的异常类。
(2) 创建这个类的一个对象。
(3) 将对象抛出。

一旦方法抛出了异常,这个方法就不可能返回到调用者。

C++ 与 Java 在抛出异常时的区别

抛出异常的过程基本相同。区别在于在 Java 中,只能抛出 Throwable 子类的对象,而在 C++ 中,却可以抛出任何类型的值。

创建异常类

定义一个派生于 Exception 的类,或者派生于 Exception 子类的类。

1
2
3
4
5
6
class FileFormatException extends IOException {
public FileFormatException() {}
public FileFormatException(String gripe) {
super(gripe);
}
}

此后就可以抛出自定义的异常类型了:

1
2
3
4
5
6
7
8
9
10
String readData(BufferedReader in) throws FileFormatException {
...
while(...) {
if (ch == -1) { // 出现 EOF
if(n < len {
throw new FileFormatException();
}
}
}
}

捕获异常

前面我们了解到如何抛出异常。有的代码还需要捕获异常。

如果某个异常发生的时候没有在任何地方进行捕获,那程序就会终止执行,并在控制台上打印出异常信息,其中包括异常的类型和堆栈的内容。

要想捕获一个异常,必须设置 try/catch 语句块,如下:

1
2
3
4
5
try {
...
} catch (ExceptionType e) {
...
}

如果在 try 语句块中的任何代码抛出了一个在 catch 子句中说明的异常类,那么

step1: 程序将跳过try语句块的其余代码。
step2: 程序将执行catch子句中的处理器代码。

例如 read 方法:

1
2
3
4
5
6
7
8
9
10
11
public void read(String filename) {
try {
InputStream in = new FileInputStream(filename);
int b;
while((b = in.read() != -1)) {
...
}
} catch (IOException e) {
e.printStackTrace();
}
}

上面的代码中进入 catch 后会生成一个栈轨迹。

此外还有一种选择,就是什么也不做,将异常传递给调用者。如果 read 方法出现了错误,就让 read 方法的调用者去操心!如果采用这种处理方式,就必须声明这个方法可能会抛出一个 IOException。

1
2
3
4
5
6
7
public void read(String filename) throws IOException {
InputStream in = new FileInputStream(filename);
int b;
while((b = in.read() != -1)) {
...
}
}

编译器严格地执行 throws 说明符。如果调用了一个抛出受查异常的方法,就必须对它进行处理,或者继续传递。

捕获异常的总结

  • 应该捕获那些知道如何处理的异常,而将那些不知道怎样处理的异常继续进行传递。
  • 如果想传递一个异常,就必须在方法的首部添加一个throws说明符,以便告知调用者这个方法可能会抛出异常。
  • 如果编写一个覆盖超类的方法,而这个方法又没有抛出异常,那么这个方法就必须捕获方法代码中出现的每一个受查异常。不允许在子类的 throws 说明符中出现超过超类方法所列出的异常类范围。

捕获多个异常

1
2
3
4
5
6
7
8
9
try {
...
} catch (FileNotFoundException e) {
e.getMessage() // 获得对象的更多信息
} catch (UnknownHostException e) {
e.getClass().getName() // 获得对象的实际类型
} catch (IOException e) {
...
}

捕获多个异常不仅会让你的代码看起来更简单,还会更高效。生成的字节码只包含一个对应公共 catch 子句的代码块。

异常链

在 catch 子句中可以抛出一个异常,目的是改变异常的类型。例如:

1
2
3
4
5
try {
...
} catch (SQLException e) {
throw new ServletException("database error: " + e.gatMessage());
}

还有一种方法,将原始异常设置为新异常的原因:

1
2
3
4
5
6
7
try {
...
} catch (SQLException e) {
Throwable se = new ServletException("database error");
se.initCause(e);
throw se;
}

捕获到异常时,可以用以下语句得到原始异常。

1
Throwable e = se.getCause();

这种包装技术的好处是可以让用户抛出子系统中的高级异常,而不丢失原始异常的细节。

如果在一个方法中发生了一个受查异常,而不允许抛出它,那么包装技术就十分有用。我们可以捕获这个受查异常,并将它包装成一个运行时异常。

有时你可能只想记录一个异常,再将它重新抛出,而不做任何改变:

1
2
3
4
5
6
try {
...
} catch (Exception e) {
logger.log(level, message, e);
throw e;
}

finally 子句

当代码抛出异常,会终止方法中剩余代码的处理,并退出这个方法。如果该方法获得了一些只有自己知道的本地资源,则需要在推出前回收。

一种方案是捕获并重新抛出所有异常,但这样在正常代码中和异常代码中都需要清除所分配的资源。

更好的方案是用 finally 子句。

例如数据库程序中,在发生异常时需要关闭与数据库的链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
InputStream in = new FileInputStream(...);
try {
1...
// 抛出异常
2...
} catch (IOException e) {
3...
// 抛出异常
t...
} fanally {
5...
in.close();
}
6...

以上代码有几种情况:

(1) 没有抛出异常:先执行 try 中的代码,再执行 finally 中的代码,然后执行 try 块之后的代码。执行顺序为 1256

(2) 抛出一个在 catch 中捕获的异常:执行 try 中的代码直至异常,再执行 catch 中的代码,执行 finally 中的代码。如果 catch 中没有抛出异常,则执行顺序为 1345;如果 catch 抛出了异常,执行顺序为 135。

(3) 抛出了一个不是由 catch 捕获的异常。则执行顺序为 15。

耦合 try/catch 和 try/finally 的技巧

1
2
3
4
5
6
7
8
9
10
InputStream in = ...;
try {
try {
...
} finally {
in.close();
}
} catch (IOException e) {
...
}
  • 内层 try 只有一个职责:确保关闭输入流
  • 外层 try 只有一个职责:确保报告出现的错误

这种设计除了职责清楚之外还有一个功能: 会报告 finalley 中出现的错误。

finally 中包含 return 的情况

如果用 return 从 try 块中退出,则在方法返回前,finally 中的内容会被执行,如果 finally 中也有一个 return,这个返回值将覆盖原始返回值。

清理资源的方法抛出异常的情况

例如下面代码中,try 块中遇到异常,执行 finally,而 close 方法本身抛出异常,此时原始异常将丢失,转而抛出 close 方法的异常。

如果依然要抛出原异常,则要写复杂的嵌套。或者用带资源的 try

1
2
3
4
5
6
InputStream in = ...;
try {
...
} finally {
in.close()
}

带资源的 try 语句

有一个 AutoClaseable 接口,其中有一个方法:

1
void close() throws Exception

此外还有一个 Closeable 接口,这是 AutoClaseable 的子接口,也有一个 close 方法:

1
void close() throws IOException

对于以下代码,如果资源属于一个实现了 AutoClaseable 接口的类,则可以方便处理。

1
2
3
4
5
try {
...
} finally {
...
}

带资源的 try 语句如下

1
2
3
try (Resource res = ...) {
...
}

try 块退出时,会自动调用 res.close()。

例如:读取一个文件中的所有单词,下面的代码中正常退出或存在异常,都会调用 in.close(),就好像用了 finally 一样。

1
2
3
4
5
try (Scanner in = new Scanner(new FileInputStream("/usr/share/dict/words")), "UTF-8") {
while(in.hasNext()) {
System.out.println(in.next());
}
}

也可以指定多个资源:

1
2
3
4
5
6
try (Scanner in = new Scanner(new FileInputStream("/usr/share/dict/words"), "UTF-8");
PrintWriter out = new PrintWriter("out.txt")) {
while(in.hasNext()) {
out.println(in.next().toUpperCase());
}
}

不论这个块如何退出,in 和 out 都会关闭。如果用常规方式手动编程,就需要两个嵌套的 try/finally 语句。

如果 close 发生异常,原来的异常会重新抛出,而 close 方法抛出的异常会“被抑制”。这些异常将自动捕获,并由 addSuppressed 方法增加到原来的异常。如果对这些异常感兴趣,可以调用 getSuppressed 方法,它会得到从 close 方法抛出并被抑制的异常列表。

分析堆栈轨迹元素

堆栈轨迹是一个方法调用过程的列表。

可以调用 Throwable 类的 printStackTrace 方法访问堆栈轨迹的文本描述信息。

1
2
3
4
Throwable t = new Throwable();
StringWriter out = new StringWriter();
t.printStackTrace(new PrintWriter(out));
String description = out.toString();

还有 getStackTrace 方法,得到 StackTraceElement 对象的一个数组。

1
2
3
4
5
Throwable t = new Throwable();
StackTraceElement[] frames = t.getStackTrace();
for(StackTraceElement frame: frames) {
// 分析 frame
}

StackTraceElement 对象还有能够获得文件名和当前执行的代码行号的方法,还有能够获得类名和方法名的方法。

Thread.getAllStackTrace 方法,产生所有线程的堆栈轨迹

1
2
3
4
5
Map<Thread, StackTraceElement[]> map = Thread.getAllStackTrace();
for(Thread t: map.keySet()) {
StackTraceElement[] frames = map.get(t);
// 分析 frame
}

例子:打印阶乘递归函数的堆栈情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.Scanner;

public class StackTraceTest {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.print("Enter n: ");
int n = in.nextInt();
factorial(n);
}

public static int factorial(int n) {
System.out.println("factorial(" + n + ")");
Throwable t = new Throwable();
StackTraceElement[] frames = t.getStackTrace();
for(StackTraceElement f: frames) {
System.out.println(f);
}
int r;
if(n <= 1) {
r = 1;
} else {
r = n * factorial(n - 1);
}
System.out.println("return: " + r);
return r;
}
}

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
factorial(3)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:15)
stackTrace.StackTraceTest.main(StackTraceTest.java:10)
factorial(2)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:15)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:24)
stackTrace.StackTraceTest.main(StackTraceTest.java:10)
factorial(1)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:15)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:24)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:24)
stackTrace.StackTraceTest.main(StackTraceTest.java:10)
return: 1
return: 2
return: 6

异常机制的技巧

1.异常处理不能代替简单的测试,例如 if(!q.empty())

  1. 不要过分细化异常。
  2. 利用异常层次结构,也就是寻找更加适当的子类或者自定义异常类。
    4.早抛出:在检测错误时,“苛刻”要比放任更好。例如栈空的时候调用 st.pop,抛出 EmptyStackException 比返回 null 好。
  3. 晚捕获:传递异常可能比捕获异常更好。

Share