Java异常(Exception)详解

  1. 1. 异常处理程序
  2. 2. 创建异常与日志记录
    1. 2.1 自定义异常
    2. 2.2 日志记录
  3. 3. 异常说明
  4. 4. 捕捉任何异常
    1. 4.1 Exception类的方法
    2. 4.2 多重捕捉
    3. 4.3 栈轨迹
    4. 4.4 重新抛出异常
  5. 5. 使用finally块执行清理
    1. 5.1 finally的作用
    2. 5.2 不同异常处理层及方法返回时的finally执行机制
  6. 6. try-with-resources语句
    1. 6.1 底层细节
    2. 6.2 构造器抛出异常
    3. 6.3 try块抛出异常
    4. 6.4 close()方法抛出异常

本文介绍了 Java 的异常处理机制,Java 使用异常(Exception)提供了一个一致的错误报告模型,从而使组件可以将问题可靠地传达给客户代码。

改进错误恢复机制是增加代码稳健性的最强有力的方法之一。捕捉错误的理想时机是在编译时,也就是在你试图运行程序之前。然而,并不是所有的错误都能在编译时发现。其他问题必须在运行时通过某种正规手段来处理,这种手段应该支持这个错误的源头将适当的信息传递给知道如何正确处理该难题的某个接收者。

1. 异常处理程序

如果我们正处于一个方法之中,并抛出了异常(或者在该方法中调用的另一个方法抛出了异常),该方法将在抛出异常的过程中退出,如果不希望退出,可以在其中设置一个特殊的块来捕捉这个异常。因为要在这里“尝试”各种方法调用,所以它称为 try 块。

被抛出的异常总是要在某个地方结束,这个地方就是异常处理程序,我们可以为每种异常类型编写一个。异常处理程序紧跟在 try 块之后,用关键字 catch 来表示:

1
2
3
4
5
6
7
try {
// 可能会产生异常的代码
} catch (ExceptionType1 e1) {
// 处理ExceptionType1类型的异常
} catch (ExceptionType2 e2) {
// 处理ExceptionType2类型的异常
}

每个 catch 子句(异常处理程序)就像一个小方法,接受且只接受一个特定类型的参数。标识符(e1e2 等)可以在处理程序中使用,就像方法参数一样。有时候我们从不使用这个标识符,因为异常的类型已经为处理该异常提供了足够多的信息,但是这个标识符必须放在这里。

异常处理程序必须紧跟在 try 块的后面,如果一个异常被抛出,异常处理机制会去查找参数与异常类型相匹配的第一个处理程序,然后进入 catch 子句,我们就认为这个异常被处理了。一旦 catch 子句完成,对异常处理程序的搜索就会停止,只有匹配的 catch 子句才会执行,它不像 switch 语句那样每个 case 之后都需要一个 break,以避免执行其余的 case

2. 创建异常与日志记录

2.1 自定义异常

要创建自己的异常类,可以继承现有的异常类,异常类型的基类是 Exception,我们可以直接使用无参构造器,也可以创建一个接受 String 参数的构造器:

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
28
29
30
31
32
33
34
35
package exception;

class MyException extends Exception {
MyException() {}
MyException(String msg) { super(msg); }
}

public class TestMyException {
static void f() throws MyException {
throw new MyException();
}

static void g() throws MyException {
throw new MyException("Exception from g()");
}

public static void main(String[] args) {
try {
f();
} catch (MyException e) {
System.out.println(e);
}

try {
g();
} catch (MyException e) {
System.out.println(e);
}

/*
* exception.MyException
* exception.MyException: Exception from g()
*/
}
}

2.2 日志记录

我们可以使用 java.util.logging 工具将输出记录到日志中,常见的情况是捕捉别人的异常,并将其记录到日志中,所以我们必须在异常处理程序中生成日志信息:

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
28
29
package exception;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.logging.Logger;

public class LoggingExceptions {
private static Logger logger = Logger.getLogger("LoggingExceptions");

static void logException(Exception e) {
StringWriter trace = new StringWriter();
e.printStackTrace(new PrintWriter(trace));
logger.severe(trace.toString());
}

public static void main(String[] args) {
try {
throw new NullPointerException("NullPointerException thrown by main()");
} catch (NullPointerException e) {
logException(e);
}

/* 标准错误流输出
* 十月 31, 2023 1:01:46 下午 exception.LoggingExceptions logException
* 严重: java.lang.NullPointerException: NullPointerException thrown by main()
* at exception.LoggingExceptions.main(LoggingExceptions.java:18)
*/
}
}

3. 异常说明

Java 鼓励人们将其方法中可能会抛出的异常告知调用该方法的客户程序员。这就是异常说明(exception specification)。它是方法声明的组成部分,出现在参数列表之后。

异常规范使用了一个额外的关键字 throws,后面跟着所有可能被抛出的异常的列表,所以我们的方法定义看起来就像下面这样:

1
void f() throws Exception1, Exception2 { // ... }

如果没有 throws 参数,意味着这个方法不会抛出异常(除了从 RuntimeException 继承而来的异常,这样的异常可以从任何地方抛出而不需要异常说明,后面会介绍)。

异常说明必须和实际情况匹配,如果方法中的代码引发了异常,但是这个方法并没有处理,编译器就会检测到并提醒我们:要么处理这个异常,要么用异常说明指出这个异常可能会从该方法中抛出。

这种在编译时被检查并强制实施的异常叫作检查型异常(checked exception)。

4. 捕捉任何异常

通过捕捉异常类型的基类 Exception,可以创建一个能捕捉任何类型异常的处理程序。

1
2
3
catch (Exception e) {
// TODO
}

这会捕捉任何异常,所以如果使用它的话,请把它放在处理程序列表的最后,以避免它抢在其他任何异常处理程序之前捕获了异常。

4.1 Exception类的方法

Exception 类是所有对程序员很重要的异常类的基类,所以通过它我们不会得到关于异常的很多具体信息,但是我们可以调用来自其基类 Throwable 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package exception;

public class ExceptionMethods {
public static void main(String[] args) {
try {
throw new Exception("My Exception");
} catch (Exception e) {
System.out.println("e.getMessage(): " + e.getMessage());
System.out.println("e.getLocalizedMessage(): " + e.getLocalizedMessage());
System.out.println("e.toString(): " + e);
System.out.println("e.printStackTrace(): ");
e.printStackTrace(System.out); // 默认会打印到标准错误流
}

/*
* e.getMessage(): My Exception
* e.getLocalizedMessage(): My Exception
* e.toString(): java.lang.Exception: My Exception
* e.printStackTrace():
* java.lang.Exception: My Exception
* at exception.ExceptionMethods.main(ExceptionMethods.java:6)
*/
}
}

可以发现每个方法都比前一个方法提供了更多信息,实际上每个方法都是前一个方法的超集。

4.2 多重捕捉

如果我们想以同样的方式处理一组异常,并且它们有一个共同的基类,那么直接捕捉这个基类即可。但是如果它们没有共同的基类,在 Java 7 之前,必须为每一个异常写一个 catch 子句。

利用 Java 7 提供的多重捕捉(multi-catch)处理程序,我们可以在一个 catch 子句中用 | 操作符把不同类型的异常连接起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package exception;

public class MultiCatch {
public static void main(String[] args) {
try {
throw new NullPointerException();
} catch (ArithmeticException | NullPointerException e) {
System.out.println("Catch ArithmeticException or NullPointerException");
System.out.println(e);
}

/*
* Catch ArithmeticException or NullPointerException
* java.lang.NullPointerException
*/
}
}

4.3 栈轨迹

printStackTrace() 提供的信息也可以使用 getStackTrace() 直接访问,这个方法会返回一个由栈轨迹元素组成的数组,每个元素表示一个栈帧。第一个元素是栈顶,即序列中的最后一个方法调用(这个 Throwable 被创建和抛出的位置):

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
28
29
30
31
32
33
34
35
36
37
38
package exception;

public class StackTrace {
static void f() throws Exception {
throw new Exception();
}

static void g() throws Exception {
f();
}

public static void main(String[] args) {
try {
f();
} catch (Exception e) {
for (StackTraceElement s: e.getStackTrace())
System.out.println(s.getMethodName());
}

System.out.println("--------------------");

try {
g();
} catch (Exception e) {
for (StackTraceElement s: e.getStackTrace())
System.out.println(s.getMethodName());
}

/*
* f
* main
* --------------------
* f
* g
* main
*/
}
}

4.4 重新抛出异常

有时我们要重新抛出刚捕获的异常,特别是当使用 Exception 来捕捉任何异常的时候我们已经有指向当前异常的引用,所以可以重新抛出它:

1
2
3
catch (Exception e) {
throw e;
}

如果重新抛出当前的异常,在 printStackTrace() 中打印的关于异常的信息,仍将是原来的异常抛出点的信息,而不是重新抛出异常的地方的信息,要加入新的栈轨迹信息可以调用 fillInStackTrace(),它会返回一个 Throwable 对象,这个对象是它通过将当前栈的信息塞到原来的异常对象中而创建的:

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
28
29
30
31
32
33
34
35
36
37
38
39
package exception;

public class Rethrowing {
public static void f() throws Exception {
System.out.println("Originating Exception in f()");
throw new Exception("Thrown from f()");
}

public static void g() throws Exception {
try {
f();
} catch (Exception e) {
System.out.println("Inside g(), e.printStackTrace():");
e.printStackTrace(System.out);
throw (Exception)e.fillInStackTrace();
}
}

public static void main(String[] args) {
try {
g();
} catch (Exception e) {
System.out.println("Inside main(), e.printStackTrace():");
e.printStackTrace(System.out);
}

/*
* Inside g(), e.printStackTrace():
* java.lang.Exception: Thrown from f()
* at exception.Rethrowing.f(Rethrowing.java:6)
* at exception.Rethrowing.g(Rethrowing.java:11)
* at exception.Rethrowing.main(Rethrowing.java:21)
* Inside main(), e.printStackTrace():
* java.lang.Exception: Thrown from f()
* at exception.Rethrowing.g(Rethrowing.java:15)
* at exception.Rethrowing.main(Rethrowing.java:21)
*/
}
}

可以看到在 main() 中输出异常的栈轨迹时没有 f() 的信息。fillInStackTrace() 被调用的那一行,成为这个异常的新起点。

重新抛出一个与所捕获的异常不同的异常也是可以的,这样做会得到与使用 fillInStackTrace() 类似的效果,关于这个异常的原始调用点的信息会丢失,剩下的是与新的 throw 有关的信息。

5. 使用finally块执行清理

往往会出现这样的情况:不管 try 块中是否抛出异常,都有一段代码必须执行,这通常是内存恢复之外的操作,因为内存恢复操作由垃圾收集器处理。

我们可以在所有异常处理程序的末尾使用一个 finally 子句,所以异常处理的全貌就是这样的:

1
2
3
4
5
6
7
8
9
try {
// 被守护区域,可能会抛出异常
} catch (Exception1 e1) {
// 处理Exception1类型的异常
} catch (Exception2 e2) {
// 处理Exception2类型的异常
} finally {
// 无论哪种情况都会执行的活动
}

我们来看一下下面这个例子:

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
package exception;

public class FinallyWorks {
static int count = 0;
public static void main(String[] args) {
while (true) {
try {
if (count++ == 0)
throw new Exception();
System.out.println("No Exception");
} catch (Exception e) {
System.out.println("Caught Exception");
} finally {
System.out.println("Inside finally");
if (count == 2) break;
}
}

/*
* Caught Exception
* Inside finally
* No Exception
* Inside finally
*/
}
}

从输出可以看出,无论是否抛出异常,finally 子句都执行了。还可以看出另一事实:Java 中的异常不允许我们回退到异常被抛出的地方。

5.1 finally的作用

在没有垃圾收集并且不会自动调用析构函数的语言中,finally 非常重要,这是因为不管在 try 块中发生了什么,它都使得程序员可以确保内存的释放。但是 Java 提供了垃圾收集器,且无需调用析构函数,那么 finally 在 Java 中什么时候需要用到?

要清理内存之外的某些东西时,finally 子句是必要的,例如打开的文件或网络连接,画在屏幕上的东西等。

5.2 不同异常处理层及方法返回时的finally执行机制

即使抛出的异常没有被当前的这组 catch 子句捕获,在异常处理机制向更高一层中继续搜索异常处理程序之前,finally 也会执行:

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
package exception;

public class AlwaysFinally {
public static void main(String[] args) {
try {
System.out.println("Inside outer try");
try {
System.out.println("Inside inner try");
throw new Exception();
} finally {
System.out.println("Inside inner finally");
}
} catch (Exception e) {
System.out.println("Caught Exception in outer catch");
} finally {
System.out.println("Inside outer finally");
}

/*
* Inside outer try
* Inside inner try
* Inside inner finally
* Caught Exception in outer catch
* Inside outer finally
*/
}
}

因为 finally 子句总会执行,所以在一个方法中,我们可以从多个点返回,并且仍然能够确保重要的清理工作得到执行:

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
28
29
30
31
32
33
package exception;

public class MultiReturns {
static void f(int i) {
try {
System.out.println("Point 1");
if (i == 1) return;
System.out.println("Point 2");
if (i == 2) return;
System.out.println("End");
return;
} finally {
System.out.println("Do something cleanup");
}
}

public static void main(String[] args) {
for (int i = 1; i <= 3; i++)
f(i);

/*
* Point 1
* Do something cleanup
* Point 1
* Point 2
* Do something cleanup
* Point 1
* Point 2
* End
* Do something cleanup
*/
}
}

输出表明,方法从哪里返回并不重要,finally 子句中的内容总会运行。

6. try-with-resources语句

有时有些对象会出现如下的情况:

  • 需要清理;
  • 需要在特定时刻清理,例如当走出某个作用域的时候(通过正常方式或通过异常)。

一个常见的例子是 java.io.FileInputStream,传统方式下我们需要编写棘手的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
InputStream in = null;

try {
in = new FileInputStream(new File("Something.txt"));
int content = in.read();
// 处理内容
} catch (IOException e) {
// 处理异常
} finally {
if (in != null) {
try {
in.close();
} catch(IOException e) {
// 处理close()异常
}
}
}

Java 7 引入了 try-with-resources 语法,可以很好地简化上述代码:

1
2
3
4
5
6
try (InputStream in = new FileInputStream(new File("Something.txt"))) {
int content = in.read();
// 处理内容
} catch (IOException e) {
// 处理异常
}

try 后面可以跟一个括号定义,我们在这里创建了 FileInputstream 对象。括号中的内容叫作资源说明头(resource specification header)。现在对象 in 在这个 try 块的其余部分都是可用的。更重要的是,不管如何退出 try 块(无论是正常方式还是通过异常),都会执行与上一个示例中的 finally 子句等同的操作,无需编写复杂棘手的代码了。

它是如何工作的呢?在 try-with-resources 定义子句中(也就是括号内)创建的对象必须实现 java.lang.AutoCloseable 接口,该接口只有一个方法:close()

资源说明头可以包含多个定义,用分号隔开,在这个头部定义的每个对象都将在 try 块的末尾调用其 close()

6.1 底层细节

为了研究 try-with-resources 的底层机制,可以创建自己的实现了 AutoCloseable 接口的类:

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
28
29
30
31
32
33
34
35
package exception;

class Reporter implements AutoCloseable {
String name = getClass().getSimpleName();

Reporter() {
System.out.println("Creating " + name);
}

@Override
public void close() {
System.out.println("Closing " + name);
}
}

class FirstReporter extends Reporter {}

class SecondReporter extends Reporter {}

public class AutoCloseableDetails {
public static void main(String[] args) {
try (FirstReporter fr = new FirstReporter();
SecondReporter sr = new SecondReporter()) {
System.out.println("Inside try");
}

/*
* Creating FirstReporter
* Creating SecondReporter
* Inside try
* Closing SecondReporter
* Closing FirstReporter
*/
}
}

在退出 try 块时会调用两个对象的 close() 方法,而且会以与创建顺序相反的顺序关闭他们。这个顺序很重要,因为在这种配置情况下,SecondReporter 对象有可能会依赖 FirstReporter 对象。

6.2 构造器抛出异常

如果在资源说明头创建某个对象时其构造器抛出异常,结果会怎样?

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package exception;

class Reporter implements AutoCloseable {
String name = getClass().getSimpleName();

Reporter() {
System.out.println("Creating " + name);
}

@Override
public void close() {
System.out.println("Closing " + name);
}
}

class FirstReporter extends Reporter {}

class SecondReporter extends Reporter {}

class ExcepReporter extends Reporter {
ExcepReporter() throws Exception {
super();
throw new Exception();
}
}

public class AutoCloseableDetails {
public static void main(String[] args) {
try (FirstReporter fr = new FirstReporter();
ExcepReporter er = new ExcepReporter();
SecondReporter sr = new SecondReporter()) {
System.out.println("Inside try");
} catch (Exception e) {
System.out.println("Caught Exception");
}

/*
* Creating FirstReporter
* Creating ExcepReporter
* Closing FirstReporter
* Caught Exception
*/
}
}

我们在资源说明头定义了三个对象,中间的对象抛出了一个异常,正因为如此,编译器强制我们提供一个 catch 子句来捕捉构造器的异常,这意味着资源说明头实际上是被这个 try 块包围的。

不出所料,FirstReporter 顺利创建,而 ExcepReporter 在创建过程中抛出了一个异常。清注意 ExcepReporterclose() 方法没有被调用,这是因为如果构造器失败了,我们不能假定可以在这个对象上安全地执行任何操作,包括关闭它在内。因为 ExcepReporter 抛出了异常,所以 SecondReporter 对象从未被创建,也不会被清理。

6.3 try块抛出异常

如果构造器都不会抛出异常,但是在 try 块中可能抛出异常,编译器又会强制我们提供一个 catch 子句:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package exception;

class Reporter implements AutoCloseable {
String name = getClass().getSimpleName();

Reporter() {
System.out.println("Creating " + name);
}

@Override
public void close() {
System.out.println("Closing " + name);
}
}

class FirstReporter extends Reporter {}

class SecondReporter extends Reporter {}

class ExcepReporter extends Reporter {
ExcepReporter() throws Exception {
super();
throw new Exception();
}
}

public class AutoCloseableDetails {
public static void main(String[] args) {
try (FirstReporter fr = new FirstReporter()) {
System.out.println("Inside try");
SecondReporter sr = new SecondReporter();
ExcepReporter er = new ExcepReporter();
System.out.println("End of try");
} catch (Exception e) {
System.out.println("Caught Exception");
}

/*
* Creating FirstReporter
* Inside try
* Creating SecondReporter
* Creating ExcepReporter
* Closing FirstReporter
* Caught Exception
*/
}
}

注意,SecondReporter 对象永远不会得到清理,这是因为它不是在资源说明头中创建的,所以它的清理得不到保证。这一点很重要,因为 Java 在这里没有以警告或错误的形式给出提示,所以像这样的错误很容易被漏掉。

6.4 close()方法抛出异常

最后,让我们看看在 close() 方法中抛出异常的情况:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package exception;

class Reporter implements AutoCloseable {
String name = getClass().getSimpleName();

Reporter() {
System.out.println("Creating " + name);
}

@Override
public void close() throws Exception {
System.out.println("Closing " + name);
}
}

class FirstReporter extends Reporter {}

class SecondReporter extends Reporter {}

class ExcepReporter extends Reporter {
@Override
public void close() throws Exception {
super.close();
throw new Exception();
}
}

public class AutoCloseableDetails {
public static void main(String[] args) {
try (FirstReporter fr = new FirstReporter();
ExcepReporter er = new ExcepReporter();
SecondReporter sr = new SecondReporter()) {
System.out.println("Inside try");
} catch (Exception e) {
System.out.println("Caught Exception");
}

/*
* Creating FirstReporter
* Creating ExcepReporter
* Creating SecondReporter
* Inside try
* Closing SecondReporter
* Closing ExcepReporter
* Closing FirstReporter
* Caught Exception
*/
}
}

请注意,因为这三个对象都被创建出来了,所以它们又都以相反的顺序被关闭了,即使 ExcepReporter.close() 抛出了异常。当我们考虑到这一点时,这就是我们希望发生的事情,但是如果必须自己编程实现所有的逻辑,代码可能会出现漏洞,从而导致出错。因此我们应该尽可能地使用 try-with-resources,这个特性还能使生成的代码更干净且更容易理解。