java 异常

八月的第四周,来学习 Java 的异常处理机制。

异常就是不正常,程序不能正常执行,中途遇到了的问题就是异常。这次来写异常分哪些情况,以及怎么处理异常。


首先需要厘清的是,几乎所有介绍异常的总结贴,以及面试时对异常的提问,基本上说的都不光是异常,而是整个异常体系。

异常体系(简略)

看一下简图就很明白了:父类 Throwable 有两个子类,一个是 Error (错误),另一个才是 Exception (异常)。也就是说,异常体系说的不光是异常,还把他爸和他哥一起介绍了,一家包圆了。

光从词语的表达上就能感受到,Error 和 Exception 的严重程度是不同的,Error (错误)是非常严重的事情,而 Exception (异常)是可以商量的事情。

  • Error :错误,指的是程序中很严重的问题,严重到 Java 虚拟机都无法处理,只能停止程序改bug。
  • Exception :异常,指的是程序中的轻度问题,程序运行时遇到了之后,处理一下还可以继续执行下去(当然了,一定要处理,不能随便放任不管)。

上面那张图只是一张表示 Error 和 Exception 之间关系的图,实际上的异常体系还是比较庞大的,我找到了一张稍微全一些的体系图(图片来源)。

异常体系

现在你要注意到,上图中的所有东西,比如 Error ,比如 Exception ,比如 Exception 里的 NullPointerException ,不论是错误也好异常也罢,以上的所有东西,统统都是类。Java 作为一门面向对象的语言,已经连错误、异常这些东西,都封装起来看作是一种类了。上图中的所有东西都是类,都有继承关系,都有方法,都是当做一个个的封装起来的对象来处理的。

Error

Error (错误)不是这周学习的重点,事实上其内容需要好几年 Java 实战的积累才能逐渐掌握,因为里面涉及到的具体情况都是偏硬件、偏底层的东西,比如 OutOfMemoryError (内存溢出错误类)。

我们目前只需要知道,Java 虚拟机并不会处理 Error 类,也就是说编译器不会检查 Error 类,我们在做程序设计的时候,也不应该去尝试捕获 Error 类。

Exception

Exception (异常)是这周关注的重点。

Exception 有许许多多的子类,其中一类 RuntimeException运行时异常,这个子类和其他子类有所不同,怎么不同的一会再说。总之 Exception 可以分成两类,一类叫做运行时异常,另一类叫做非运行时异常

怎么不同的呢?

  • RuntimeException 运行时异常,就是程序真的运行起来了,才能发现的异常。
  • 非运行时异常,意思是说,程序即使不运行,编译器都知道将会有异常发生。也就是说,你不处理这个异常,你就别想让程序运行起来。

这两类异常的主要区别在于,一种程序可以先运行起来(遇到了再报错),一种程序上来就无法运行(不用遇到了,直接报错)。

再换句话说,一种可以通过编译器的检查,一种无法通过编译器的检查。

如果从编译器检查的角度来看这两类异常,那么运行时异常( RuntimeException ),因为可以逃避编译器的检查,因此可以被称为非检查性异常;而其他异常(也就是非运行时异常)逃不过编译器的检查,因此可以被称为检查性异常。

所以异常可以换一种分类方式:检查性异常非检查性异常。这一种分类方式是更被普遍提到的,虽然它所指的其实就是运行时和非运行时,只是看待的角度不同。(现在建议你再去看一看上面的图,再理解一下分类)

  1. 非检查性异常/运行时异常

    属于这一类的异常中,较为常见的有:

    • NullPointerException 空指针异常
    • ArithmeticException 算术异常(比如 5 除以 0 )
    • ClassCastException 类型转换异常
    • IndexOutOfBoundsException 数组越界异常
    • ArrayStoreException 数据存储异常(数组存储时数据类型不一致)
  2. 检查性异常/非运行时异常

    属于这一类异常中,较为常见的有:

    • IOException 输入输出异常
    • FileNotFoundException 文件不存在异常(继承自 IOException
    • SQLException SQL 语句异常
    • InterruptedException 中断异常

现在来学习,如果遇到了异常( Exception ),怎么处理异常。

准确的说应该是,我怀疑某个地方要出现异常,我怎么提前处理这个地方,让它在程序运行之后真的遇到了异常时,仍然能在处理之后继续运行程序。

要注意的是,检查性异常是一定要进行处理的,不处理程序都无法运行(因为编译器直接报错),而非检查性异常和没有异常的地方(比如合理怀疑某段代码有问题),也是可以进行异常处理的。异常处理并不是只针对于异常的,而是在一切场景之下,只要我怀疑存在异常,都可以去做异常处理(大不了没碰上呗)。

Java 有两种异常处理方案,分别是就地解决( try / catch / finally 语句)和异地正法( throws )。

就地解决

现在我遇到一段可能存在异常的代码,打算当场解决掉它。

下面这几行代码就是就地解决异常的处理方式。

1
2
3
4
5
6
7
8
9
10
try {
// 可能存在异常
...
} catch (Exception e) {
// 如果遇到了异常,那就执行这段代码
...
} finally {
// 不论有没有遇到异常,都执行这段代码
...
}

有三个关键字:

  • try 大括号里放【可能存在异常的代码】,表示“这段代码可能有异常,我们来试试看”。
  • catch 小括号里放【异常的类型】,大括号里放【处理异常的代码】,表示“我发现了XX异常,我要这么去处理它”。
  • finally 大括号里放【不论如何都会执行的代码】,表示“我管你遇不遇得到异常,全都给我执行这段代码”。

还是举一个具体的例子吧。

1
2
3
4
5
6
7
8
9
10
11
try {
int number = 1/0;
} catch (ArithmeticException e) {
System.out.println("发现了算术异常");
} finally {
System.out.println("不论如何,都给我执行这段代码");
}

// 最终会在控制台上打印两句话:
// 发现了算术异常
// 不论如何,都给我执行这段代码
  1. 程序进入 try{} 语句块中,执行了 int number = 1/0; 这行代码,因为 0 不能作为被除数,所以出现了异常。
  2. 通过 catch(){} 语句块捕捉到了 ArithmeticException (算术异常),你应该注意到了,小括号里放的就是这一异常的类型,如果放的是别的异常的话,那么会捕捉不到的。大括号里表示,捕捉到这一异常之后,要执行的操作,在这里是在控制台打印了一句话:“发现了算术异常”。
  3. finally{} 语句块是程序无论如何都会执行的部分,即使是没有异常也会执行。在这里发生了算数异常,打印了一句话:“不论如何,都给我执行这段代码”。其实如果没有发生异常的话,也会把这句话打印出来的。

异地正法

有时我们并不想在异常出现的地方就去处理它,那么可以把异常抛出去

抛出去(throw)又是一个翻译过来的词,在英文环境中这个词很生动,但是在汉语环境中就并不如此。我们经常听到抛异常这三个字,意思就是把异常给“抛”出去,不立即处理,而是在其他地方去处理。

比如现在有方法 A方法 A 调用了方法 B方法 B 的内部出现了异常,现在就有两种解决方案:

  1. 方法 B 自己处理异常,也就是刚才说的“就地正法”。
  2. 方法 B 不去解决,而是“抛”给方法 A 去解决。

为什么不当场解决,而是抛出去让其他地方去解决,原因可能有很多,比如另一个地方拿到异常对象能做更多事情,比如程序员懒想拖到其他地方再说,等等。

抛异常的动作很简单,只需要在方法名的后面(注意,不是在异常发生的原地,而是在异常发生所在的方法,那个方法的名字的位置处),加上 throws XXXException 就可以了。

举个栗子吧:

1
2
3
4
private int test() throws ArithmeticException {
int number = 1 / 0;
return number;
}

这里定义了一个只有两行代码的方法(方法名叫 test),第一行是让 number = 1/0 (很明显出现了算术异常),第二行返回 number 。我并没有在异常出现的原地去处理,而是在方法名的后面,加上了 throws ArithmeticException ,表示抛出去了一个算术异常。之后哪里调用了这个方法,就必须去处理这个异常(或者继续抛出去)。

你注意,这里说的抛异常,是我们遇到了异常之后,把遇到的这个异常抛出去,而且是在方法名旁边去抛的,意思是说“每当你调用这个方法的时候,都要注意,这个方法里面有个异常要处理”。但是还有一种抛异常的方法,就是可以在随便一个地方,手动地抛出去一个异常。

1
throw new ArithmeticException();

比如这个样子,我手动地把一个新实例化出来的算术异常抛出去了。

在方法名旁边写的那个,是 throws ,而手动在随便一个地方抛出去的,是 throw


剩下的部分太过零碎,我就分条写,写到哪里算哪里好了。

1.finally 语句块

在捕获异常当场解决的地方,我们使用了 try / catch / finally 语句,其中 finally{} 语句块是“无论如何都会执行的代码”。

这里的无论如何,真的就是无论如何,它保证了程序一定会执行这部分代码,不管发不发生异常都会执行,而且不管 try{} 语句块中写了什么,也都会执行。

注意:即使是 try{} 语句块中写了返回语句 return xxx; ,你感觉程序走到这里就停止了,其实并不是,这样也都会去执行 finally{} 语句块。

1
2
3
4
5
6
7
8
9
10
11
12
try {
int number = 0;
return number;
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("finally 语句块执行了");
}

// 最终会在控制台上打印两行:
// finally 语句块执行了
// 0

通过反编译可以发现,Java 在处理 finally{} 语句块时,是把它复制成两份,分别在 try{} 语句块和 catch{} 语句块后面去执行一次的。

而且如果 finally{} 语句块中写了返回语句 return xxx; ,将会执行这个返回语句,程序到此结束。

2.异常链

维基百科对于异常链的解释真是太令人舒服了,一眼看懂:

异常链是一种面向对象编程技术,指将捕获的异常包装进一个新的异常中并重新抛出的异常处理方式。原异常被保存为新异常的一个属性(比如cause)。这个想法是指一个方法应该抛出定义在相同的抽象层次上的异常,但不会丢弃更低层次的信息。

还是举一个例子吧:

main 方法调用 test1 方法,test1 方法调用 test2 方法。

其中 test2 方法中抛一个异常, test1 方法会接到 test2 方法抛出的异常,包裹起来再抛一个新的异常。

这样 main 方法中就会出现一个异常,这个异常里面还有一个异常(也就是接到一个包裹着异常的异常)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Test {

public static void main(String[] args) {
// 调用 test1 方法
int num = test1();
}

// 调用 test2 方法,抛一个新的异常
private static int test1(){
int num = 0;
try {
num = test2();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("这里是test1的异常",e);
}
return num;
}

// test2 方法中抛一个异常
private static int test2() throws Exception {
throw new Exception("这里是test2的异常");
}
}

控制台的打印信息如下:

1
2
3
4
5
6
7
8
9
10
11
java.lang.Exception: 这里是test2的异常
at testCode.Test.test2(Test.java:21)
at testCode.Test.test1(Test.java:12)
at testCode.Test.main(Test.java:6)
Exception in thread "main" java.lang.RuntimeException: 这里是test1的异常
at testCode.Test.test1(Test.java:15)
at testCode.Test.main(Test.java:6)
Caused by: java.lang.Exception: 这里是test2的异常
at testCode.Test.test2(Test.java:21)
at testCode.Test.test1(Test.java:12)
... 1 more

这里就看到了异常链。

3.一个catch子句捕获多个异常

JDK1.7 改进了 catch 子句的语法,允许在其中指定多种异常,每个异常类型之间使用“|”来分隔。

1
2
3
4
5
6
7
8
9
public class ExceptionHandler {
public void handle(){
try {
//..............
} catch (ExceptionA | ExceptionB ab) {
} catch (ExceptionC c) {
}
}
}

4.异常抑制

JDK1.7 中为 Throwable类 增加 addSuppressed方法getSuppressed方法

当一个异常被抛出的时候,可能有其他异常因为该异常而被抑制住,从而无法正常抛出。这时可以通过 addSuppressed方法 把这些被抑制的方法记录下来,被抑制的异常会出现在抛出的异常的堆栈信息中,可以通过 getSuppressed方法 获取到被抑制的异常。

个人觉得没啥用,懒得写了。

5. try - with - resources

同样还是 JDK1.7 增加的新特性。

以前使用 try / catch / finally 语句块处理资源的时候,如果要关闭资源,需要在 finally{} 语句块中关闭,使用 try - with - resources 方式可以在 try() 的小括号里声明,Java 会自动关闭资源。

1
2
3
4
BufferedReader br = new BufferedReader(inputString);
try (BufferedReader br1 = br) {
return br1.readLine();
}

JDK1.9 更进一步, try() 的小括号里不需要声明了,声明过程在外面也可以。

1
2
3
4
BufferedReader br = new BufferedReader(inputString);
try (br) {
return br.readLine();
}

try 后面小括号内的东西即为 resources ,resources 必须是实现 java.lang.AutoCloseable 或者 java.io.Closeable 接口的类(需要手动 close() 释放资源的)。

6.IDEA 的快捷操作

要是使用的Java开发工具是 IDEA 的话,那么如果想对某一句代码进行异常处理,可以在这句代码写完之后,敲上“ .try “这四个字符,再敲回车,就可以自动为这行代码加上 try / catch 语句了。

例如这行代码:

1
int number = 1/0;.try

写完之后敲击回车,自动变成如下代码:

1
2
3
4
5
try {
int number = 1/0;
} catch (Exception e) {
e.printStackTrace();
}