异常(exception)

基本的异常

异常情形(exception condition)是指阻止当前方法或作用域继续执行的问题。普通问题是指在当前上下文中有足够的信心,能够以某种方式解决这个问题。而异常情形是指,因为在当前上下文中我们没有必要的信息来处理这个问题,所以无法继续处理。我们所能做的就是跳出当前上下文,并将问题委托给更上层的上下文。

异常参数:所有标准异常类都有两个构造器:一个无参构造器,一个接受 String 参数,用于在异常中放置相关信息。

throw new NullPointerException("t = null");

在使用 new 创建了异常对象后,此对象的引用传给 throw。尽管异常对象的类型通常与方法设计的返回类型不同,但从效果上看,它就像是从方法“返回”的,即可以简单地把异常机制看作一种不同的返回机制。另外还能用抛出异常的方式从当前作用域退出。在这两种情况下,将会返回一个异常对象,然后退出方法或作用域。

异常返回的“地点”与普通方法调用返回的“地点”完全不同。(异常将在一个恰当的异常处理程序中得到解决,它的位置可能离异常被抛出的地方很远,也可能会跨越方法调用栈的许多层级。)

此外,能够抛出任意类型的Throwable对象,它是异常类型的根类。通常,对于不同类型的错误,要抛出相应的异常。错误信息可以保存在异常对象内部或者用异常类的名称来暗示。上一层环境通过这些信息来决定如何处理异常。(通常,唯一的信息只有异常的类型名,而在异常对象内部没有任何有意义的信息。)

捕获异常

监控区域(guarded region)是一段可能产生异常的代码,并且后面跟着处理这些异常的代码。

try语句块:如果不希望在方法内部抛出异常后方法就此结束,可以在方法内设置try块来捕获异常。通过将所有动作都放在try块里,然后只需在一个地方就可以捕获所有异常。

异常处理程序:抛出的异常必须在该处得到处理,而且针对每个不同类型的捕获的异常,要准备相应的处理程序。异常处理程序紧跟在try块之后,用关键字catch来表示。

try {
	// 可能会产生异常的代码
} catch(Type1 id1) {
	// 处理Type1类型的异常
} catch(Type2 id2) {
	// 处理Type2类型的异常
}

每个catch子句就像一个小方法,接受且只接受一个特定类型的参数。可以在处理程序内部使用标识符。有时可能用不到标识符,因为异常的类型已经给了足够的信息来对异常进行处理,但标识符不能省略。而且只有匹配的catch子句才能得到执行,与switch语句不同,switch 语句需要在每一个case后面跟一个break,以避免执行后续的case子句。

注意在try块的内部,许多不同的方法调用可能会产生类型相同的异常,而只需要提供一个针对此类型的异常处理程序。

在异常处理理论中有两种基本模型。Java支持终止模型。在这种模型中,将假设错误非常严重,以至于程序无法返回到异常发生的地方继续执行。另一种称为恢复模型,意思是异常处理程序的工作是修正错误,然后重新尝试调用出问题的方法,并认为第二次能成功。如果想要用Java实现类似恢复的行为,那么在遇见错误时就不能抛出异常,而是调用方法来修正该错误。或者,把try块放在while循环里,这样就不断地进入try块,直到得到满意的结果。

创建自己的异常

可以自己定义异常类来表示程序中可能会遇到的特定问题。要自己定义异常类,必须从已有的异常类继承,最好是选择意思相近的异常类继承(不过这样的异常并不容易找)。建立新的异常类型最简单的方法就是让编译器为你产生无参构造器:

编译器创建了无参构造器,它将自动调用基类的无参构造器。对异常来说,最重要的部分就是类名,像SimpleException(String)这样的构造器不实用。

可以通过System.err将错误输出发送到标准错误流,由于System.out可能被重定向,如果将输出发送到System.err,就不会和System.out一起被重定向,用户就更有可能注意到它。

在该异常处理程序中,调用了在Throwable类声明(Exception即从此类继承)的printStackTrace()方法。它将打印“从方法调用处直到异常抛出处”的方法调用序列。这里,信息被发送到了System.out,并自动地被捕获和显示在输出中。但是,如果调用默认版本:e.printStackTrace(),信息就会被输出到标准错误流。

异常与记录日志:可以使用java.util.logging工具将输出记录到日志中。

静态的Logger.getLogger()方法创建了一个String参数相关联的Logger对象(通常与错误相关的包名和类名),这个Logger对象会将其输出发送到System.err。向Logger写入的最简单方式就是直接调用与日志记录消息的级别相关联的方法,这里使用的是severe()。为了产生日志记录消息,我们欲获取异常抛出处的栈轨迹,但是printStackTrace()不会默认地产生字符串。为了获取字符串,我们需要使用重载的printStackTrace()方法,它接受一个java.io.PrintWriter对象作为参数。如果我们将一个java.io.StringWriter对象传递给这个PrintWriter的构造器,那么通过调用toString()方法,就可以将输出抽取为一个 String。

这里使用的方法是把所有的日志基础设施都构建在异常本身之中,因此它可以自动工作而无需干预。但是更常见的情况是捕捉别人的异常,并将其记录到日志中,所以必须在异常处理程序中生成日志信息。

还可以加入额外的构造器和成员来进一步自定义异常。

新的异常添加了字段x以及设定x值的构造器和读取数据的方法。此外,还覆盖了Throwable.getMessage()方法,以产生更详细的信息。对于异常类来说,getMessage()方法有点类似于toString()方法。

异常只是一种对象,所以我们可以继续装饰得到更多功能,但是使用程序包的客户端程序员可能仅仅只是查看一下抛出的异常类型,然后就不管了(Java库中异常大部分也是这么使用的),这些额外添加的功能根本用不上。

异常说明

异常说明(exception specification)就是Java用语法礼貌地告知客户端程序员这个方法会抛出的异常,它是方法声明的组成部分,在参数列表之后。

使用关键字throws,后面跟所有可能被抛出的异常的列表,即void f() throws TooBig, TooSmall, DivaZero { //...,如果不加异常说明就表示该方法不会抛出异常(除了从RuntimeException继承的异常,它们可以在没有异常说明的情况下被抛出)。

代码必须与异常说明保持一致。如果方法里的代码产生了异常却没有进行处理,编译器会发现这个问题并提醒你:要么处理这个异常,要么就在异常说明中表明此方法将产生异常。通过这种自顶向下强制执行的异常说明机制,Java在编译时就可以保证一定水平的异常正确性。

不过还是有个能“作弊”的地方:可以声明方法将抛出异常,实际上却不抛出。编译器相信了这个声明,并强制此方法的用户像真的抛出异常那样使用这个方法。这样做的好处是,为异常先占个位子,以后就可以抛出这种异常而不用修改已有的代码。在定义抽象基类和接口时这种能力很重要,这样派生类或接口实现就能够抛出这些预先声明的异常。

这种在编译时被强制检查的异常称为检查型异常(checked exception)。

捕捉任何异常

通过捕获异常类型的基类Exception,就可以写一个异常处理程序来捕获所有类型的异常(事实上还有其他的基类,但Exception是所有编程行为相关的基类)。

因为Exception是与编程有关的所有异常类的基类,所以不会含太多具体的信息,不过可以调用来自其基类Throwable的方法。

以下代码用来获取详细信息,或者用本地语言表示的详细信息:

以下代码返回对Throwable的简单描述,如果有详细信息,也会包含在内。

以下代码打印Throwable和Throwable的调用栈轨迹。调用栈显示了“把你带到异常抛出地点”的方法调用序列。其中第一个版本输出到标准错误,后两个版本允许选择要输出的流:

以下代码在Throwable对象的内部记录栈帧的当前状态。当应用会重新抛出错误或异常时很有用。

此外,还可以从Throwable的基类Object获得一些方法。就异常而言,可能用得上的是getClass(),它返回表示这个对象的类的Class对象。然后可以使用getName()方法查询这个Class对象包含包信息的名称,或者使用只产生类名称的getSimpleName()方法。

多重捕获: 可以直接catch基类型来使用同一方式捕获一组具有相同基类的异常。若这些异常没有共同的基类型,在Java 7之前,必须为每一个类型编写一个catch。而通过Java 7的多重捕获机制,可以使用"或"将不同类型的异常组合起来,只需要一行catch语句:

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

重新抛出异常:有时要重新抛出刚捕获的异常,由于已经有了指向当前异常的引用,所以可以直接抛出。

重抛异常会把异常抛给上一级环境中的异常处理程序,同一个try块的后续catch子句将被忽略。而且异常对象的所有信息都得以保持。

如果只是把当前异常对象重新抛出,那么printStackTrace()方法显示的将是原来异常抛出点的调用栈信息,而并非重新抛出点的信息。要想更新这个信息,可以调用filInStackTrace()方法,这将返回一个Throwable对象,它是通过把当前调用栈信息填入原来那个异常对象而建立的。

有可能在捕获异常之后抛出另一种异常。这么做的话,得到的效果类似于使用filInStackTrace(),有关原来异常发生点的信息会丢失,剩下的是与新的抛出点有关的信息。

精准的重新抛出异常:Java 7之前,如果遇到异常,则只能重新抛出该类型的异常。导致在Java 7中修复的代码不精确。

因为catch捕获了一个BaseException,所以编译器会强制我们声明catcher() throws BaseException,尽管它实际上要抛出的是更具体的DerivedException。从Java 7开始就没有这个问题了。

有时我们会捕捉一个异常并抛出另一个,但仍保留原始异常的信息,这称为“异常链”。现在所有的Throwable子类都可以选择在构造器中接受一个cause对象Throwable(Stirng msg, Throwable cause))。这个cause就用来表示原始异常,这样通过把原始异常传递给新的异常,使得即使在当前位置创建并抛出了新的异常,也能通过这个异常链追踪到异常最初发生的位置。

在Throwable的子类中,只有三种基本的异常类型提供了带cause参数的构造器,它们是Error(JVM使用它来报告系统错误)、Exception和RuntimeException。要链接任何其他的异常类型,应使用initCause()而不是构造器。

每个DynamicFields对象都含有一个数组,其元素是“成对的对象”。第一个对象表示字段标识符(一个字符串),第二个表示字段值,值的类型可以是除基本类型外的任意类型。当创建对象的时候,要合理估计一下需要多少字段。当调用setField()方法的时候,它将试图通过标识修改已有字段值,否则就建一个新的字段,并把值放入。如果空间不够了,将建立一个更长的数组,并把原来数组的元素复制进去。如果你试图为字段设置一个空值,将抛出一个DynamicFieldsException异常,它是通过使用initCause()方法把NullPointerException对象插入而建立的。

至于返回值,setField()将用getField()方法把此位置的旧值取出,这个操作可能会抛出NoSuchFieldException异常。如果客户端程序员调用了getField()方法,那么他就有责任处理这个可能抛出的NoSuchFieldException异常,但如果异常是从setField()方法里抛出的,这种情况将被视为编程错误,所以就使用接受cause参数的构造器把NoSuchFieldException异常转换为RuntimeException异常。

可以注意到,toString()方法使用了StringBuilder()来创建其结果。而且只要编写设计循环的toString()方法时,通常都会使用它。

Java标准异常

Throwable这个Java类被用来表示任何可以作为异常被抛出的类。有两个常用的继承自Throwable的类型:Error和Exception。前者表示编译时错误和系统错误(只有个别特殊情况需要关心),后者是一个基本类型,可以从任何标准的Java库方法、自己的方法以及运行时事故中抛出。

异常的基本的概念是用名称代表发生的问题。异常并非全是在java.lang包里定义的;有些异常是用来支持其他像util、net和io这样的程序包,这些异常可以通过它们的完整名称或者从它们的父类中看出端倪。比如,所有的输入/输出异常都是从java.io.IOException 继承而来的。

特例:RuntimeException

属于运行时异常的类型有很多,它们会自动被java虚拟机抛出,所以不必在异常说明中把它们列出来。它们也被称为“非检查型异常”。

RuntimeException代表的是编程错误,它包括以下错误:

  1. 无法预料的错误。比如从你控制范围之外传递进来的null引用。

  2. 作为程序员,应该在代码中进行检查的错误。(比如对于ArrayIndexOutOfBoundsException,就得注意一下数组的大小了)在一个地方发生的异常,常常会在另一个地方导致错误。它们常常代表程序中的逻辑错误,应该在代码层面避免,而不是靠异常处理来恢复。

新特性:更好的NullPointerException报告机制

JDK 15之后,NullPointerException可以提供更多的信息,有了更好的报告机制。

使用finally执行清理

有一些代码片段,可能会希望无论try块中的异常是否抛出,它们都能得到执行。这通常适用于内存回收之外的情况(因为回收由垃圾回收器完成),为了达到这个效果,可以在异常处理程序后面加上finally子句。

finally子句总会执行的演示:

这里无论是否抛出异常,finally子句都被执行了。而且还可以看到Java中的异常不允许我们回退到异常被抛出的地方。这里还演示了:如果把try块放到一个循环中,同时设置一个让程序继续执行之前必须满足的条件,还可以添加一个静态的计数器,或其他某种装置,让循环在放弃之前尝试几种不同的方法。这样可以提高程序的健壮性。

finally用来做什么

对于没有垃圾回收和析构函数自动调用机制的语言来说,finally非常重要。它能使程序员保证:无论try块里发生了什么,内存总能得到释放。在Java中,当要把除内存之外的资源回复到它们的初始状态时,就要用到finally子句。这种需要清理的资源包括:已经打开的文件或网络连接,在屏幕上画的图形,甚至是外部世界的某个开关。

在return中使用finally

因为finally子句总是会执行,所以可以从一个方法内的多个点返回,仍然能保证重要的清理工作会执行。

缺陷:异常丢失

用某种特殊的finally子句的情况下,会发生异常丢失:

在输出中没有看到VeryImportantException,它被finally子句中的HoHumException取代。

异常限制

在重写一个方法时,只能抛出该方法的基类版本中说明的异常。这个限制很有用,因为这意味着,若当基类使用的代码应用到其派生类对象(派生是面向对象编程中的一个基本概念)的时候,一样能够工作,异常也不例外。

总结:在继承和重写的过程中,“异常说明”可以缩小,但是不能扩大

构造器

太多数情况下,如果异常发生了,所有东西都能被正确清理。但涉及构造器时,就会出现问题。构造器会把对象设置成安全的初始状态,但还会有别的动作,比如打开一个文件,这样的动作只有在对象使用完毕并且用户调用了特殊的清理方法之后才能得以清理。如果在构造器内抛出了异常,这些清理行为也许就不能正常工作了。

这时finally也不能解决问题。因为finally会每次都执行清理代码。如果构造器在其执行过程中半途而废,也许该对象的某些部分还没有被成功创建,而这些部分在finaly子句中却是要被清理的。

在InputFile的构造器中,先使用文件名创建一个FileReader,FileReader对象本身用处并不大,但可以用它来建立BufferedReader对象。

在这里只有当FileReader构造器失败才会抛出FileNotFoundException异常,如果还有其他方法能抛出FileNotFoundException,就有点麻烦了。这时,通常必须把这些方法分别放到各自的try块里。执行完操作后,重新抛出异常,这么做是因为不希望当构造器调用失败,仍然让调用方以为“对象被成功创建,并且是有效的”。

在该例子中,由于finally会在每次完成构造器之后都执行一遍,因此它实在不该是调用close()关闭文件的地方。我们希望文件在InputFlle对象的整个生命周期内都处于打开状态。

由于getLine()调用了能抛出异常的readline(),且这个异常已经在方法内得到处理,因此getLine()不会抛出任何异常。在设计异常时有一个问题:应该把异常全部放在这一层处理;还是先处理一部分,然后再向上层抛出相同的(或新的)异常;又或者是不做任何处理直接向上层抛出。如果用法恰当的话,直接向上层抛出的确能简化编程。在这里,getLine()方法将异常转换为RuntimeException,表示一个编程错误。

对于可能在构造过程中抛出异常而且需要清理的类,最安全的用法是使用嵌套的try块,如本例main()中。这种通用的清理惯用法在构造器不抛出任何异常时也应该运用,其基本规则是:在创建需要清理的对象之后,立即进入一个try-finally语句块

注意:如果dispose()可以抛出异常,那么就可能需要额外的try语句块。基本上,应该仔细考虑所有的可能性,并确保正确处理每一种情况。

Try-with-Resources用法

正确使用java.io.FileInputStream的例子:

Java 7之后引入try-with-resources语法,可以简化上述代码:

try括号中的内容为资源说明头,现在in在这个try块的其余部分都是可用的。更重要的是,无论你如何退出try块(正常或异常),都会执行前一个finally子句的等价操作,但不用编写那些杂乱而棘手的代码。

工作原理:在try-with-resources定义子句中创建的对象(在括号内)必须实现java.lang.AutoCloseable接口,这个接口有一个方法:close()。当在Java 7中引入AutoCloseable时,许多接口和类被修改以实现它,在AutoCloseable的Java文档中就可以看到所有实现该接口的类列表,其中包含Stream类:

由此可以看出,资源说明头可以包含多个定义,用分号隔开(最后的分号可选)。在这个头部定义的每个对象都会在 try 语句块运行结束之后调用 close() 方法。

try-with-resources 里面的 try 语句块可以不包含 catch 或 finally 语句而独立存在。在这里,IOException 被 main() 方法抛出,所以这里并不需要在 try 后面跟着一个 catch 语句块。

Java 5 中的 Closeable 已经被修改,修改之后的接口继承了 AutoCloseable 接口。所以所有实现了 Closeable 接口的对象,都支持了 try-with-resources 特性。

揭示细节

创建自己的 AutoCloseable 类,进一步了解 try-with-resources 的基本机制:

由此可以看出,退出 try 块会调用两个对象的 close() 方法,并以与创建顺序相反的顺序关闭它们

资源说明头中定义的必须为AutoCloseable对象,若不是则会出现编译错误。

当其中一个构造函数抛出异常时:

由此可看到,中间的对象抛出异常,这时强制使用 catch 子句来捕获构造函数异常。即资源说明头中的内容实际上被 try 块包围。

而且,由于 SecondExcept 在创建期间抛出异常,不会为 SecondExcept 调用 close(),因为如果构造函数失败,则无法假设你可以安全地对该对象执行任何操作,包括关闭它。由于 SecondExcept 的异常,Second 对象实例 s2 不会被创建,因此也不会有清除事件发生。

当close()方法抛出异常时:

这里并没有强制必须使用 catch 子句,也可以通过main() throws CloseException的方式报告异常。但 catch 子句是放置错误处理代码的典型位置。

注意:应尽量使用 try-with-resources,可以使功能实现更简单,使得生成的代码更清晰,更易于理解。

新特性:try-with-resources中的实际的最终变量

最初的 try-with-resources 要求将所有被管理的变量都定义在资源说明头中,但是JDK 9增加了在 try 之前定义这些变量的能力,只要它们被显式地声明为最终变量,或者是实际上的最终变量。

因为r1和r2的定义没有像old()中那样放到try块中,需要通过说明throw IOException。可以看到,在引用变量被try-with-resources释放后仍然可以引用它们,但是会触发异常。

异常匹配

抛出异常的时候,异常处理系统会按照代码的书写顺序找出“最近”的处理程序。找到匹配的处理程序之后,它就认为异常将得到处理,然后就不再继续查找。

查找的时候并不要求抛出的异常同处理程序所声明的异常完全匹配。派生类的对象也可以匹配其基类的处理程序

其他可选方式

异常处理的一个重要原则是“只有在你知道如何处理的情况下才捕获异常"。实际上,异常处理的一个重要目标就是把错误处理的代码同错误发生的地点相分离。通过允许一个处理程序去处理多个出错点,异常处理还使得错误处理代码的数量趋于减少。

“被检查的异常”使这个问题变得有些复杂,因为它们强制你在可能还没准备好处理错误的时候被迫加上 catch 子句,这就导致了吞食则有害(harmful if swallowed)的问题:

虽然这样做能通过编译,但除非记得复查并改正代码,否则异常将会丢失。

将“检查型异常”转换为“非检查型异常”

一个简单的方案是使用链式异常。通过将一个检查型异常传递给RuntimeException构造器,我们可以将其包在一个RuntimeException中:

这种技巧提供了一种选择,我们可以忽略异常,并使其沿着调用栈向上“冒泡”。而不需要编写try-catch子句和异常说明。然而,我们仍然可以使用getCause()来捕捉和处理特定的异常。

WrapCheckedException.throwRuntimeException() 的代码可以生成不同类型的异常。这些异常被捕获并包装进了 RuntimeException 对象,所以它们成了这些运行时异常的"cause"了。

另一种解决方案是创建自己的 RuntimeException 的子类。在这种方式中,不必捕获它,但是希望得到它的其他代码都可以捕获它。

异常使用指南

应该在下列情况下使用异常:

  1. 尽可能使用 try-with-resource。

  2. 在恰当的级别处理问题。(在知道该如何处理的情况下才捕获异常)

  3. 解决问题并且重新调用产生异常的方法。

  4. 进行少许修补,然后绕过异常发生的地方继续执行。

  5. 用别的数据进行计算,以代替方法预计会返回的值。

  6. 把当前运行环境下能做的事情尽量做完,然后把相同的异常重抛到更高层。

  7. 把当前运行环境下能做的事情尽量做完,然后把不同的异常抛到更高层。

  8. 终止程序。

  9. 进行简化。(如果你的异常模式使问题变得太复杂,那用起来会非常痛苦也很烦人)

  10. 让类库和程序更安全。(这既是在为调试做短期投资,也是在为程序的健壮性做长期投资)

最后更新于