【Java】异常
前言
Java中使用异常机制去处理程序错误,减少了错误处理代码的复杂度。使得我们不必在程序每个可能出现错误的地方都进行检查并添加错误处理代码,从而显得程序主要结构混乱。异常机制会捕获错误,并且在异常处理程序
中处理错误,使得程序代码和错误处理代码分离,使得代码结构更清晰明了。下面将介绍Java中的异常分类、如何创建异常处理程序以及自定义异常等。
异常分类
(悄咪咪说句,这手写太丑了,我当时肯定过度熬夜没有睡醒😂 )
Java中的所有异常都继承自Throwable
。
Throwable
:被用来表示任何可以作为异常被抛出的类。有两个重要的子类Exception和Eerror。二者都是Java异常处理的重要子类,并且二者也包含许多重要的子类。
Error
:该类层次结构描述了Java运行时系统内部错误和资源耗尽错误,总之是与Java虚拟机有关的运行错误或是应用程序无法处理的。这些错误是不可查的,因为它们在应用程序的控制和处理能力之外,而且绝大多数是程序运行时不允许出现的状况。对于设计合理的应用程序来说,即使确实发生了错误,本质上也不应该试图去处理它所引起的异常状况。在 Java中,错误通过Error的子类描述。一般不需要我们关心。
Exception
: 是程序本身可以处理的异常,是需要我们关心的异常类。
Java中的异常又可分为可检查异(checked exceptions)和不可检查异常(unchecked exceptions)。
- 可检查异常:编译器要求必须处理的异常。在Exception类及其子类中,除了RuntimeException类及其子类外,其余都是可检查异常。这种异常编译器会强制要求处理它,需要使用
try-catch
语句去捕获处理或者使用throw
子句抛出该异常让其他地方去处理它,否则编译器会不允许该代码编译通过。 - 不可检查异常:编译器不强制要求处理的异常。该异常分来包括:运行时异常(RuntimeException)及其子类和错误(Error)及其子类。这种异常即使不使用try-catch子句捕获和throw子句抛出,编译器也会使得编译通过。而一旦发生这种异常,程序将立即终止。
Exception异常只有RuntimeException及其子类是不可查的异常,其余异常都是可查异常。于是,Excepption异常分类就可以分为运行时异常和非运行时异常(编译时异常)。
运行时异常如NullPointerException(空指针异常)、ArrayIndexOutOfBoundException(数组下标越界异常)编译器不强制要求处理 。这些异常一般是由于程序逻辑错误引起,程序应该从逻辑角度避免这种异常的发生。非运行时异常则需要强制要求处理,否则编译不予通过。
下面再附上一张比较完整的Java异常类图:
异常处理
Java程序中的异常处理机制为:抛出异常+捕获异常。
抛出异常
异常情形(exceptionanl condiition)是指阻止当前方法或作用域继续执行的问题。需要与普通问题区分开:普通问题指的是在当前环境下能够得到足够的信息,总能够处理这个问题;异常情形就是,程序不能执行下去了,在当前环境下无法获必要的信息来解决问题。需要做的就是从当前环境中跳出,并把问题交给上一级环境让它去处理。这就是抛出异常所发生的事情。
例如:除法问题。若是事先没有对除数为0进行判断,那么遇见除数为0时,就是一个异常情况。当然的环境不知道如何处理这个情况,于是就将异常抛出。
Java中抛出异常的实现方式:
- 使用new在堆上创建异常对象。
- 当前的执行路径被终止,并从当期环境中使用
throw
关键字弹出异常对象的引用 - 异常处理机制接管程序,并开始寻找一个恰当的地方来继续执行程序。恰当的地方就是指异常处理程序。
- 异常处理程序将程序从错误状态中恢复,以使程序能要么换一种方式运行,那么继续原来的程序执行。
举一个例子:一个引用t为null时,创建一个异常从当前环境中抛出,把错误传播到更大的环境中去处理。这个例子只为举例说明,实际不这样用。
if(t == null){
throw new NullPointerException();
}
异常允许我们强制程序停止运行,并告诉我们出现了什么问题,或者(理想状况下)强制程序处理问题,并返回到稳定状态。
异常参数:
与使用Java中其他对象一样,我们使用new在对上创建异常对象,这也伴随着存储空间的分配和构造器的调用。所有标准异常都有两个构造器:一个默认构造器和一个以字符串作为参数的构造器。带参构造器可以将一些信息放入构造器中。
throw new NullPionterException(" t = null");
将这个字符串信息提取出来的方式有许多种,稍后介绍。
对于不同类型的错误,要抛出相应的异常。错误信息可以保存在异常对象的内部或者使用异常类的名称来暗示。上一层环境通过这些异常信息来决定如何处理这些异常。通常,异常对象中仅有的信息就是异常类型,除此之外不包含任何有意义的内容。
捕获异常try-catch
要理解异常是如何被捕获的,需要先理解监控区域(guarded region)
的概念。它是一段可能产生异常的代码,并且后面跟着处理这些异常的代码。
try块
如果在方法内部抛出了异常(或者该方法内部调用的其他方法抛出了异常),那么这个方法将在抛出异常的过程中结束,方法中后续代码将不能运行。如果不希望刚发就此结束,可以在方法内部设置一个特殊的块来捕获异常。因为在这个块里“尝试”各种(可能产生异常的)方法调用,所以称为try块。它是跟在try关键字后的普通代码块:
try{
//可能会抛出异常的代码
}
对于不支持异常处理的程序语言,想要仔细检查错误,就需要在每个方法调用的前后设置错误检查代码。但是有了异常处理机制,就可以把所有动作都可以放在tty块中,然后只需要在一个地方就捕获所有异常。这也是前面说的,异常处理机制将完成任务的代码和错误检查的代码分离,使得程序结构清晰易于阅读。
异常处理程序
抛出的异常必须在某个地方得到处理,这个地方就是我们前面说的异常处理程序。针对每个要捕获的异常,需要准备相应的处理程序。异常程序紧跟在try块之后,以关键字catch
表示。
try{
//可能会抛出异常的代码
}catch(ExceptionType1 id1){
//处理类型为ExceptionType1异常的代码
}catch(ExceptionType2 id2){
//处理类型为ExceptionType2异常的代码
}
catch子句就是异常处理程序,看起来像是接收指定的异常类型参数的方法。异常处理程序必须紧跟在try块之后。当异常被抛出时,异常处理机制将负责搜寻参数与抛出异常类相匹配的第一个处理程序。然后进入catch子句执行,此时就认为异常得到了处理。一旦catch子句结束,则处理程序的查找过程结束。
在try块内部,许多不同的方法调用可能会产生相同类型的异常,而你只需要提供一个针对此类型的异常处理程序。
异常处理的两种模型:终止模型和恢复模型
异常处理周期理论上有两种模型:终止模型和恢复模型。
Java支持终止模型(Java、C++等大多数语言支持的模型)。在这种模型中,将假设错误非常关键,以至于程序无法返回到异常经常发生的地方继续执行。一旦异常被抛出,就表明错误已经无法挽回,也不可以继续回来执行。
恢复模型则指异常处理程序的工作是修正错误,然后重新尝试调用出问题的方法,并认为第二次可以成功。对于恢复模型,通常希望异常被处理之后能继续执行程序。
虽然恢复模型很吸引人,但是不是很实用。其中主要的原因可能是它所导致的耦合:恢复性的处理程序需要了解异常抛出的地点,这势必要包含依赖于抛出位置的非通用性代码。这增加了代码编写和维护的困难。
如果想要使Java实现类似恢复的行为,那么在遇见错误的时候就不能抛出异常,而是调用方法来修正该错误。或者,把try-catch块放在while循环里,这样就可以不断地进入try块,直到得到满意的结果然后退出。例如:
boolean exit = false;
while(!exit){
try{
//编写可能会出异常的代码或者调用可能会出异常的方法
f();
//...
boolean = true;
}catch(ExceptionType e){//捕获相应类型异常进行处理
//异常处理
}
}
创建自定义异常
Java提供提供的异常体系可能不会完全包含我们遇见的错误,所以允许我们可以自定义异常。自己要自定义异常必须已知的异常继承,最好是选择意思相近的异常类继承。建立新的异常最简单的方法就是让编译器为你产生默认的构造器,可以较少写的代码量。
//自定义异常继承自Exception
class SimpleException extends Exception{}
public class InheritingException {
//thorws关键字说明该方法会产生SimpleException异常
public void f() throws SimpleException{
System.out.println("从f()中抛出SimpleException异常");
throw new SimpleException(); //使用thorw关键字抛出SimpleException异常
}
public static void main(String[] args) {
InheritingException ie = new InheritingException();
try {
//调用可能会抛出异常的方法
ie.f();
}catch(SimpleException e) {
System.out.println("捕获了SimpleException异常");
// System.err.println("捕获了SimpleException异常");
}
}
}
/*
output:
从f()中抛出SimpleException异常
捕获了SimpleException异常
*/
编译器创建了默认构造器,它将自动调用基类的默认构造器。可以将错误信息发送到标准错误流,这样更能引起用户注意。
也可以为异常类定义一个接受字符串参数的构造器:
class MyException extends Exception{
public MyException() {}
//增加含参构造器
public MyException(String msg) {
super(msg);
}
}
public class FullConstructors {
public static void f() throws MyException{
System.out.println("从f()中抛出异常");
throw new MyException();
}
public static void g() throws MyException{
System.out.println("从g()中抛出异常");
throw new MyException("从g()中产生");
}
public static void main(String[] args) {
try {
f();
}catch(MyException e) {
e.printStackTrace(System.out);
}
try {
g();
}catch(MyException e) {
e.printStackTrace(System.out);
}
}
}
/*
output:
从f()中抛出异常
blogTest.MyException
at blogTest.FullConstructors.f(FullConstructors.java:17)
at blogTest.FullConstructors.main(FullConstructors.java:27)
从g()中抛出异常
blogTest.MyException: 从g()中产生
at blogTest.FullConstructors.g(FullConstructors.java:22)
at blogTest.FullConstructors.main(FullConstructors.java:33)
*/
两个构造器定义了创建MyException类对象的创建方式。对于第二个构造器,使用super关键字明确调用了其基类构造器,它接受一个字符串作为参数。
在异常处理程序中,调用了在Throwable类中声明的printStackTrace()方法,它将打印“从方法调用处直到异常抛出处”的方法调用序列。这里,信息将被发送到System.out中,并自动地被捕获和显示在输出中。若是调用printStackTrace()原始版本
e.printStackTrace();
则信息将被输出到标准错误流。
异常说明
Java提供了相应语法,是你可以告知客户端程序员某个方法可能会抛出什么异常,然后客户端程序员就可以对可能出现的异常进行处理。这就是异常说明
,属于方法声明的一部分,紧跟在形式参数列表之后。就如上代码中:
//thorws关键字说明该方法会产生SimpleException异常
public void f() throws SimpleException{
System.out.println("从f()中抛出SimpleException异常");
throw new SimpleException();
}
代码必须和异常说明一致。 若是方法中产生了异常(抛出异常),那么编译器就会发现并提醒你:要么在这个方法中处理这个异常,要么在异常说明里表明此方法会产生该异常。
我们需要注意:虽然产生异常如果不处理就必须要进行说明,但是我们却可以说明异常但实际不抛出。编译器相信了这个声明,并强制此方法的用户像真的抛出这种异常那样使用这个方法。这样做的好处在于:为异常先占一个位置,以后就可以抛出这种异常而不用修改已有的代码。在定义抽象类基类和接口时这种能力特别重要,这样派生类或接口实现就能够抛出这些预先声明的异常。
Exception异常及一些异常类的方法
捕获所有异常
若要只写一个异常捕获所有类型的异常,那就通过捕获异常类型的基类Exception实现。(实际上有其他基类,但是Exception是同编程活动相关的基类。)
catch(Exception e){
// 异常处理代码
}
上面这段代码将捕获所有异常,做好将其放到处理程序列表末尾,以防止它抢在其他处理程序之间先将异常捕获。因为基类异常可以捕获子类异常。
从API文档可以看出异常基类Exception没有含有太多的具体信息,从文档也可以它的基类Throwable含了不少方法。
所以我们可以利用从Throwable基类继承来到方法:String getMessage()
、String getLocalizedMessage()
来获取详细信息或用本地语言表示的详细信息。
String toString()
返回对Throwable的简单描述。
void printStackTrace()
、void printStackTrace(PrintStream s)
、 void printStackTrace(PrintWriter s)
打印Throwable和Throwable的调用轨迹。调用栈显示了“把你带到异常抛出地点”的方法调用序列。第一个版本输出到标准错误,后两个版本允许选择要输出的流。
Throwable fillInStackTrace()
用于在Throwable对象的内部记录栈帧的当前状态。这在程序重新抛出错误或异常时很有用。
栈轨迹
printStackTrace()方法所提供的信息可以通过StackTraceElement[] getStackTrace()
方法来直接访问。这个方法返回一个由栈轨迹中的元素所构成的数组,其中的每一个元素都表示栈中的一帧。元素0是栈顶元素,并且是调用序列的最后一个方法调用(即这个Throwable被创建和抛出之处)。数组最后一个元素即栈底元素是调用序列中的第一个方法调用。举一小例:
public class WhoCalled {
public static void f() {
try {
throw new Exception();
}catch(Exception e) {
for(StackTraceElement ste : e.getStackTrace()) {
System.out.println(ste.getMethodName()); //打印方法名
}
}
}
public static void g() {f();}
public static void main(String[] args) {
f();
System.out.println("-------------");
g();
}
}
/*
output:
f
main
-------------
f
g
main
*/
重新抛出异常
重新抛出异常会把异常给上一级环境中的异常处理程序,同一个try块后面的catch子句将被忽略。被抛出的异常的信息要保持,这样上一级的异常处理程序才可以得到该异常的所有信息。若只是将异常简单重新抛出那么printStackTrace()显示的将仍然是原来异常抛出点的调用栈信息,不是当前抛出点的信息。若是想要更新信息,可以调用fillInStackTrace()方法,这个方法将返回一个Throwable对象,它是通过把当前调用栈信息填入原来那个异常对象而建立的。
从样例输出可以看出单纯抛出异常,调用栈信息确实没有改变。使用fillInStack()方法,异常的发生地就改变了。有可能在捕获异常之后抛出另外一种异常,那么有关原来异常发生点的信息就会丢失,剩下的全是与与新抛出点有关的信息。前一个异常对象因为是在堆上面创建的,所以垃圾回收器会自动将它们清理掉,不必担心。
异常链
若是想在捕获一个异常之后抛出新的异常,并且希望把原来的异常信息保存下来,这就是异常链
。如何实现呢?
Throwable的子类构造器中可以接受一个cause(因由)对象作为参数。这个cause就用来表示原始异常,于是这样就可以把原始异常传递给新的异常,使用新的异常就可以跟踪到原始异常。但是,在Throwable的子类中只有三种基本异常类提供了带cause参数的构造器,它们是Error、Exception以及RuntimeException。若是把其他类型异常链接起来,就应该使用initCasue()方法而不是构造器。
使用finally进行清理
对于一些代码,我们希望无论try块当中的异常是否抛出,这些代码都可以得到执行。这通常适用于内存回收之外的情况(内存回收是Java虚拟机完成),比如当异常抛出时关闭打开的文件资源。为了达到这个效果,可以在异常处理程序后面加上finally子句。
try-catch-finally
所以完整的异常处理程序看起来像是这样:
try{
//可能会抛出异常的代码
}catch(ExceptionType1 id1){
//处理类型为ExceptionType1异常的代码
}catch(ExceptionType2 id2){
//处理类型为ExceptionType2异常的代码
}finally{
//无论异常是否发生都可以被执行
}
public class FianllyTest {
public static void main(String[] args) {
try {
throw new Exception();
}catch(Exception e) {
System.out.println("捕获Exception异常");
}finally {
System.out.println("当异常发生时,finally子句1");
}
try {
int i = 0;
}catch(Exception e) {
System.out.println("捕获Exception异常");
}finally {
System.out.println("当异常没有发生时,finally子句2");
}
}
}
/*
output:
捕获Exception异常
当异常发生时,finally子句1
当异常没有发生时,finally子句2
*/
从输出可看出无论异常是否被抛出,finally子句总是能够被执行。
finally的用处
对于没有垃圾回收机制和析构函数自动调用机制的程序语言来说,finally非常重要。它能使程序员保证:无论try块里面发生什么,内存总能得到释放。但是Java拥有垃圾回收机制,内存释放不再是问题。
那么,Java在什么情况下使用finally呢?当要把除内存之外的资源恢复到它们的初始状态时,就需要用到finally子句。这种需要清理的资源包括:已经打开的文件或者网络连接,在屏幕上显示到图形,甚至是外部世界的某个开关等。
在return中使用finally
因为finally子句总会被执行,所以在一个方法中可以从多个点返回,并且可以保证重要的清理工作仍旧会执行。
注意:异常丢失
异常作为程序出错的标志,决不应该被忽略,但是还是不免会被忽视掉。比如使用特殊的方式使用finally子句,就会导致异常丢失:
因为try块后面可以直接跟finally块。所以可以导致在VeryImportantException异常还没有处理的情况下就抛出另外一个异常,导致这个异常丢失。如果要避免这种情况就需要在每个异常抛出后都需要紧跟异常处理程序。
还有一种更简单的异常丢失方法是从finally子句中直接返回:
public class ExceptionSilencer{
public static void main(String args[]){
try{
throw new RuntimeException();
}finally{
return;
}
}
}
这个程序即使抛出了异常,也不会有任何输出。所以要注意这些特殊的finally的使用方法~
finally子句不会被执行的情况
-
在finally子句中产生了异常
-
在前面的代码中用了System.exit()退出程序
-
程序所在的线程死亡
-
关闭CPU
小结
介绍了Java中异常类的分类,按异常发生类型分和按可检查和不可检查分。其次介绍了Java中如何抛出一个异常以及如何捕获一个异常,即try-catch语句块的使用。如何自定义一个异常,我们需要注意Java中的异常类已经涵盖了大部分会出现的异常,除非特殊情况,一般不需要去自定义异常。throws异常声明的使用。然后介绍异常类中的常用方法,几乎都是继承自Throwable类。最后介绍了finally子句,要注意使用finally,避免出现异常丢失~
参考:
[1] Eckel B. Java编程思想(第四版)[M]. 北京: 机械工业出版社, 2007
[2] BenchResources.Net. Exception Hierarchy in Java[EB/OL]. /2019-02-23. https://www.benchresources.net/exception-hierarchy-in-java/.
[3] hguisu. java(3)-深入理解java异常处理机制[EB/OL]. /2019-02-23. https://blog.csdn.net/hguisu/article/details/6155636.