最近面试了几家公司,某大厂的面试官有问到我对于Java中异常机制的了解,吧啦吧啦的和面试官说了一下自己的理解,从受查异常到非受查异常,从Throwable到Error、Exception,等等...,面试完,自己回头想了一下,感觉可能是面试的时候有点紧张?似乎讲的也不是很好,可能从自己懂到讲给别人听,让别人也能理解自己的理解,是需要自己真正消化掉这一部分知识。正好最近也有再回头看一下《Java核心技术》这本书,就在本博文里面梳理一下自己对于Java异常机制这部分内容的拙见吧。
1 异常的分类
在Java中,所有的异常都是派生于Throwable类的,在标准Java库中,已经为我们提供了很多异常类,例如,RuntimeException(运行时异常)、IllegalArgumentException(非法参数异常)、IndexOutOfBoundsException(索引越界异常)等,我们还可以通过继承这些类来实现自己的自定义异常类。从类结构上来说,Java异常层次结构如下图:
所有的异常都继承自Throwable,标准库中直接继承Throwable的类中主要分为Error类和Exception类。
其中,Error 类层次结构描述了 Java 运行时系统的内部错误和资源耗尽错误。 应用程序不应该抛出这种类型的对象。 如果出现了这样的内部错误, 除了通告给用户,并尽力使程序安全地终止之外, 再也无能为力了。这种情况很少出现。
——引自《Java核心技术》
也就是说,Error类主要用于描述一些我们无法从代码层面来规避的错误,就好像你永远也无法避免有人在你程序运行的时候突然把你的电脑关机了,hhh。因此我们对此也无能为力。
因此我们在程序中更多的是关注于Exception以及其子类。Exception类又可以进一步的细分为两个分支:RuntimeException和非RuntimeException。划分这两个分支的规则也很简单,由于程序代码导致的错误异常属于RuntimeException;而程序本身没有问题,但由于像I/O错误这类问题导致的异常就属于非RuntimeException。
其中常见的RuntimeException有以下几种:
- 错误的类型转换。
- 数组访问越界。
- 访问 null 指针。
常见的非RuntimeException如下:
- 试图在文件尾部后面读取数据。
- 试图打开一个不存在的文件。
- 试图根据给定的字符串查找 Class 对象, 而这个字符串表示的类并不存在。
《Java核心技术》一书中在介绍RuntimeException时我觉得有一句话很能概括它的特点,
“如果出现 RuntimeException 异常, 那么就一定是你的问题” ——引自《Java核心技术》
也就是说,RuntimeException是那些用于描述代码存在漏洞的异常,例如,如果抛出了索引越界异常ArraylndexOutOfBoundsException,是否意味着开发者遗漏掉了在代码中检查数组下标的逻辑?如果抛出了空指针异常NullPointerException,是否意味着开发者应该在使用变量前先检查一下是不是为null?
以上是Java中异常从类结构的角度来进行的分类。
从一个异常是否需要被捕获的角度,异常又可以分为受查(checked)异常和非受查(unchecked)异常,其中,将继承于Error类或RuntimeException类的所有异常称为非受查异常,其他的异常称为受查异常。
what?这又是啥?
先解释一下,这是从Java编译器的角度来区分异常,编译器会要求你的代码为所有的受查异常提供异常处理机制,而非受查异常则不需要。
结合前面的内容,其实也不难看出,为什么不需要捕获或者处理非受查异常(也就是Error和RuntimeException及其子类们)?因为对于Error类而言,你就算捕获了它们,你还能做什么呢?Nothing,就好像有人在你程序运行的时候突然把你的电脑关机了,hhh,又来?
同理对于RuntimeException而言,我们更应该在代码中添加相应的逻辑来避免它们,而不是捕获它们,还记得我们怎么处理引越界异常ArraylndexOutOfBoundsException和空指针异常NullPointerException的吗?RuntimeException有助于提高我们代码的鲁棒性。
由此可见,非受查异常主要包括那些我们完全没办法处理的异常(Error)和完全能够通过代码来避免的异常(RuntimeException)。
剩下的,就是那些需要我们来捕获和处理的异常了,例如,如果试图去读取一个不存在的文件,我们抛出了FileNotFoundException,程序在捕获了这个异常后,依然拥有能够正常运行下去的可能性,这就是我们异常捕获机制存在的意义啊,让我们的代码,尽可能的按照设定好的逻辑,正常的走下去。
2 抛出异常
异常的抛出比较简单,当我们代码执行到某个步骤时,我们无法再合理的执行下去,这时我们就需要抛出一个异常,例如,当我们需要发送电子邮件时,如果用户传进来一个非法的邮件地址,我们就需要抛出一个异常,代码结构大致如下,
public void sendEmail(String emailAddress, Mail mail) {
if (!isEmailAddress(emailAddress)) {
throw new IllegalArgumentException();
}
// 执行发送email
...
}
如果觉得 IllegalArgumentException()不够贴切,我们还可以自定义Exception来抛出,如下:
public class IllegalEmailAddressException extends Exception {
public IllegalEmailAddressException() {
super();
}
public IllegalEmailAddressException(String message) {
super(message);
}
}
我们自定义了一个IllegalEmailAddressException用于说明传入的Email地址不合法。
public void sendEmail(String emailAddress, Mail email) throws IllegalEmailAddressException {
if (!isEmail(emailAddress)) {
throw new IllegalEmailAddressException();
}
email.send(emailAddress);
}
可以看到,如果我们在代码中抛出的是RuntimeException或者其子类(例如样例代码中的IllegalArgumentException)等非受查异常,我们无需在我们的方法申明中通过throws申明我们这个方法还有可能返回异常,也就说明调用此方法者无需捕获此异常。
但是如果抛出的是受查异常,且我们在本方法中未捕获此异常,我们还需要在此方法申明中通过throws来申明此异常,将这个异常继续向外抛出,交由方法的调用者捕获(或者继续向上抛出)。
3 捕获异常
3.1 try/catch语句块
抛出异常很简单,但是我们抛出一个异常,往往是希望调用者能够注意到这段代码是可能存在异常的,并希望调用者能够主动的去处理它们。毕竟,如果异常没有被捕获,那么当前线程就会终止。
捕获异常便需要用到Java中的try/catch语句块,最基本的捕获结构如下:
public void sendEmail(String emailAddress, Mail email) {
try {
// code that may throw an exception
email.send(emailAddress);
} catch (IllegalEmailAddressException e) {
System.out.println("Illegal email address");
}
// if (!isEmail(emailAddress)) {
// throw new IllegalEmailAddressException();
// }
// email.send(emailAddress);
}
我们将可能抛出异常的代码段放在try语句块中执行,并通过catch语句块来捕获try语句块中可能抛出的异常对象。
1. 如果在 try语句块中的任何代码抛出了一个在 catch 子句中说明的异常类, 那么
- 程序将跳过 try语句块的其余代码。
- 程序将执行 catch 子句中的处理器代码。
2. 如果在 try 语句块中的代码没有拋出任何异常,那么程序将跳过 catch 子句。
3. 如果方法中的任何代码拋出了一个在 catch 子句中没有声明的异常类型,那么这个方法就会立刻退出(希望调用者为这种类型的异常设了catch 子句)。
由此可见,我们对于一个异常,一般有2种可选的处理方式——捕获它或者继续向上抛出它。一般来说,应该捕获那些知道如何处理的异常,而将那些不知道怎样处理的异常继续进行传递,交给方法的具体调用者来进行处理。
但是这个规则又有一个例外,如果我们在子类中覆盖(@Override)了父类的一个方法,但是在父类的方法中,并未申明会抛出异常,那么在子类覆盖的方法中,我们也不能够抛出任何异常,既需要对所有的受查异常在方法体内进行捕获。简而言之,我们不能在子类覆盖的方法的throws说明符中抛出超过父类方法所列出的异常范围类型。
这个例外显得很啰嗦,其实也很好理解,看个代码例子吧,
import java.io.IOException;
public class SuperClass {
public void methodWithOutException() {
}
public void methodWithIOException() throws IOException {
}
public void methodWithException() throws Exception {
}
}
class SubClass extends SuperClass {
// 父类方法没有声明异常,则子类覆盖该父类方法也不能声明异常
// 报错
@Override
public void methodWithOutException() throws Exception {
super.methodWithOutException();
}
// 父类方法声明了异常,则子类覆盖该父类方法不能声明超出父类异常范围外的异常
// 即只能声明父类方法声明的异常或其子类异常
// 报错
@Override
public void methodWithIOException() throws Exception {
super.methodWithIOException();
}
// 父类方法声明了异常,且子类覆盖父类方法时所申明异常为父类方法声明的异常的子类
// 合理
@Override
public void methodWithException() throws IOException {
}
}
可以看到,我们在子类中覆盖父类方法时,不允许抛出超出父类申明范围异常的异常,即,要么捕获父类方法中所声明的异常(不在子类方法中抛出异常),要么抛出父类声明的异常类的子类型。这是由于Java的多态,我们可以通过父类型的引用来指向一个子类型的实例对象,如下,
class Main {
public static void main(String[] args) {
// 父类型引用指向子类型对象实例
SuperClass obj = new SubClass();
// 如果子类方法中抛出了异常,我们就不能通过父类的引用捕获它
obj.methodWithOutException();
// 同理,我们也只能捕获父类方法声明的异常
try {
obj.methodWithException();
} catch (Exception e) {
e.printStackTrace();
}
try {
obj.methodWithIOException();
} catch (IOException e) {
e.printStackTrace();
}
}
}
所以,子类如果覆盖了父类的方法,则子类方法的申明中只能抛出比父类申明的异常范围小的异常(不抛出异常或抛出父类方法申明中异常的子类型)。
并且,我们还可以在try/catch语句块中同时捕获多个类型的异常,并且为每种异常提供不一样的处理方式,如下,
try {
obj.methodWithIOException();
obj.methodWithException();
} catch (IOException e) {
// 处理IOException异常
} catch (Exception e) {
// 处理Exception异常
}
在Java1.7之后,对捕获多种异常进行了改善,我们可以在同一个catch语句中捕获多个异常类型,如下,
try {
// code that might throw exceptions
} catch (FileNotFoundException | UnknownHostException e) {
// emergency action for missing files and unknown hosts
} catch (IOException e) {
// emergency action for all other I/O problems
}
同时,在catch语句块中,异常变量隐含为final变量,即我们不可以在catch语句中为e赋不同的值。
3.2 finally语句块
finally语句块似乎是面试中一般问的比较多的点,比如finally语句块执行的时机?try语句块中有return语句,finally语句块还会执行吗?finally语句中有return语句又会发生什么?catch语句中抛出了异常,finally还会执行吗?等等...
这里我觉得值得好好说一说。
finally语句块是用于保证那些必须要被执行到的语句一定能够得到执行机会的语句块,例如,关闭数据库连接,关闭网络连接等,如果这些语句放在try语句块中执行,很可能由于try语句块中抛出了异常,导致此部分的代码得不到执行,这些系统资源便一直得不到释放。有人会说,那我们在catch语句中再关闭一次就好了呀,第一,这样会造成这部分代码重复很多次,如果有多个catch语句块,那么每个里面都需要一份一样的代码。第二,如果抛出了未在catch子句中捕获的异常呢?还是会造成资源的浪费。所以我们才需要finally语句块来完成这部分的任务。
我们看一个具有标准结构的try/catch/finally结构,如下:
public class ExceptionDemo {
public static void main(String[] args) {
String s = new ExceptionDemo().tryDoSomething();
System.out.println(s);
}
public String tryDoSomething() {
try {
System.out.println("doing something...");
return "try...";
} catch (Exception e) {
System.out.println("catch exception");
return "catch...";
} finally {
System.out.println("finish doing something");
return "finally...";
}
}
}
Java如何保证finally语句块一定能够执行呢?我们来看看编译后的class文件吧,毕竟它包含了Jvm真正执行的语句。
"javap -v ExceptionDemo.class"
Last modified 2022-4-10; size 1044 bytes
MD5 checksum 895d565fbce08e8fca39637d0c508192
Compiled from "ExceptionDemo.java"
public class ExceptionDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #14.#37 // java/lang/Object."<init>":()V
#2 = Class #38 // ExceptionDemo
#3 = Methodref #2.#37 // ExceptionDemo."<init>":()V
#4 = Methodref #2.#39 // ExceptionDemo.tryDoSomething:()Ljava/lang/String;
#5 = Fieldref #40.#41 // java/lang/System.out:Ljava/io/PrintStream;
#6 = Methodref #42.#43 // java/io/PrintStream.println:(Ljava/lang/String;)V
#7 = String #44 // doing something...
#8 = String #45 // try...
#9 = String #46 // finish doing something
#10 = String #47 // finally...
#11 = Class #48 // java/lang/Exception
#12 = String #49 // catch exception
#13 = String #50 // catch...
#14 = Class #51 // java/lang/Object
#15 = Utf8 <init>
#16 = Utf8 ()V
#17 = Utf8 Code
#18 = Utf8 LineNumberTable
#19 = Utf8 LocalVariableTable
#20 = Utf8 this
#21 = Utf8 LExceptionDemo;
#22 = Utf8 main
#23 = Utf8 ([Ljava/lang/String;)V
#24 = Utf8 args
#25 = Utf8 [Ljava/lang/String;
#26 = Utf8 s
#27 = Utf8 Ljava/lang/String;
#28 = Utf8 tryDoSomething
#29 = Utf8 ()Ljava/lang/String;
#30 = Utf8 e
#31 = Utf8 Ljava/lang/Exception;
#32 = Utf8 StackMapTable
#33 = Class #48 // java/lang/Exception
#34 = Class #52 // java/lang/Throwable
#35 = Utf8 SourceFile
#36 = Utf8 ExceptionDemo.java
#37 = NameAndType #15:#16 // "<init>":()V
#38 = Utf8 ExceptionDemo
#39 = NameAndType #28:#29 // tryDoSomething:()Ljava/lang/String;
#40 = Class #53 // java/lang/System
#41 = NameAndType #54:#55 // out:Ljava/io/PrintStream;
#42 = Class #56 // java/io/PrintStream
#43 = NameAndType #57:#58 // println:(Ljava/lang/String;)V
#44 = Utf8 doing something...
#45 = Utf8 try...
#46 = Utf8 finish doing something
#47 = Utf8 finally...
#48 = Utf8 java/lang/Exception
#49 = Utf8 catch exception
#50 = Utf8 catch...
#51 = Utf8 java/lang/Object
#52 = Utf8 java/lang/Throwable
#53 = Utf8 java/lang/System
#54 = Utf8 out
#55 = Utf8 Ljava/io/PrintStream;
#56 = Utf8 java/io/PrintStream
#57 = Utf8 println
#58 = Utf8 (Ljava/lang/String;)V
{
public ExceptionDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LExceptionDemo;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class ExceptionDemo
3: dup
4: invokespecial #3 // Method "<init>":()V
7: invokevirtual #4 // Method tryDoSomething:()Ljava/lang/String;
10: astore_1
11: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
14: aload_1
15: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
18: return
LineNumberTable:
line 3: 0
line 4: 11
line 5: 18
LocalVariableTable:
Start Length Slot Name Signature
0 19 0 args [Ljava/lang/String;
11 8 1 s Ljava/lang/String;
public java.lang.String tryDoSomething();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
// try语句开始
0: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #7 // String doing something...
5: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// load "try..."字符串准备作为返回值
8: ldc #8 // String try...
// finally语句块中的代码逻辑
10: astore_1
11: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
14: ldc #9 // String finish doing something
16: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// load "finally..."字符串准备作为返回值
19: ldc #10 // String finally...
// try语句块返回,try语句块结束
21: areturn
// catch语句块,catch语句块开始
22: astore_1
23: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
26: ldc #12 // String catch exception
28: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// load "catch..."字符串准备作为返回值
31: ldc #13 // String catch...
33: astore_2
// finally语句块中的代码逻辑
34: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
37: ldc #9 // String finish doing something
39: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// load "finally..."字符串准备作为返回值
42: ldc #10 // String finally...
// catch语句块返回,catch语句块结束
44: areturn
// 其他类型的异常,直接走finally语句块
45: astore_3
46: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
49: ldc #9 // String finish doing something
51: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
54: ldc #10 // String finally...
56: areturn
Exception table:
from to target type
0 11 22 Class java/lang/Exception
0 11 45 any
22 34 45 any
LineNumberTable:
line 8: 0
line 9: 8
line 14: 11
line 15: 19
line 10: 22
line 11: 23
line 12: 31
line 14: 34
line 15: 42
line 14: 45
line 15: 54
LocalVariableTable:
Start Length Slot Name Signature
23 22 1 e Ljava/lang/Exception;
0 57 0 this LExceptionDemo;
StackMapTable: number_of_entries = 2
frame_type = 86 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 86 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
}
SourceFile: "ExceptionDemo.java"
从反编译到的class文件中,我们可以看到,finally语句的实质其实和在try/catch语句块中添加逻辑并无区别,编译器自动的将finally语句块中的内容追加到我们的try/catch语句块中(具体位置是在try/catch语句块中的return语句之前),并且抛出我们未捕获的异常时,会直接执行finally语句中的代码。
Ok,再回头看看本节开始时的那些问题,
finally语句块执行的时机?刚说过,呐,上面红字就是。
try语句块中有return语句,finally语句块还会执行吗?会执行!
finally语句中有return语句又会发生什么?finally语句块中的返回语句具有最高优先级,会覆盖掉try/catch语句块中的返回值。
catch语句中抛出了异常,finally还会执行吗?会执行!
在Java1.7中,还提供了带资源的try语句,对于那些实现了AutoCloseable接口的资源对象,我们可以在try语句的开始就申明它们,如下,
try (Scanner in = new Scanner(new FileInputStream("./words.txt")), "UTF-8") {
while (in.hasNextO)
System.out.pri ntl n(i n.next()) ;
} // will autoclose in after
其中那些实现了AutoCloseable接口的资源对象,会在之后自动的调用close方法,实现资源的释放。其实本质上和finally语句应该并无太大差异,只是在可能返回的地方都调用了该对象的close方法罢了。感兴趣可以自己反编译一下相关代码查看一下class文件中的具体实现。
至此,关于Java中的异常机制,分析得也七七八八了,其实Java中的异常也并非完美,只是提供了一种方式,能保证开发者尽可能的保证程序能够顺利执行下去,当try语句中出现了异常,我们的程序依然有可能执行完剩下的逻辑。