第七章 异常、断言和日志
7.1 处理错误
假设在一个Java程序运行期间出现了一个错误。这个错误可能由于文件包含错误信息,或者网络连接出现问题造成的,也有可能是因为使用了无效的数组下标,或者试图使用一个没有被赋值的对象引用而造成的。用户期望在出现错误时,程序能够采取合理的行为。如果由于出现错误而使得某些操作没有完成,程序应该:
- 返回到一种安全状态,并能够让用户执行其他的命令;或者
- 允许用户保存所有工作的结果,并以妥善的方式终止程序。
为了能够处理程序中的异常情况,必须考虑到程序中可能会出现的错误和问题。那么需要考虑哪些问题呢?
- 用户输入错误
- 设备错误
- 物理限制
- 代码错误
正如第五章中所提到的那样,在Java中,如果某个方法不能采用正常的途经完成它的任务,可以通过另外一个路径退出方法。在这种情况下,方法并不返回任何值,而是抛出(throw)一个封装了错误信息的对象。需要注意的是,这个方法将会立刻退出,并不返回正常值。此外,也不会从调用这个方法的代码继续执行,取而代之的是,异常处理机制开始搜索能够处理这种异常状况的异常处理器(exception handler)。
异常有自己的语法和特定的继承层次结构。下面首先介绍语法,然后给出有效地使用这种语言特性的技巧。
7.1.1 异常分类
在Java程序设计语言中,异常对象都是派生于Throwable类的一个类实例。如果Java中内置的异常类不能满足需求,用户还可以创建自己的异常类。
需要注意的是,所有的异常都是Throwable继承而来,但在下一层立即分解为两个分支:Error和Exception。
Error类层次结构描述了Java运行时系统的内部错误和资源耗尽错误。你的应用程序不应该抛出这种类型的对象。如果出现了这样的内部错误,除了通知用户,并尽力妥善地终止程序之外,你几乎无能为力。这种情况很少出现。
在设计Java程序时,要重点关注Exception层次结构。这个层次结构又分解为两个分支:一个分支派生于RuntimeException;另一个分支包含其他异常。一般规则是:由编程错误导致的异常属于RuntimeException;如果程序本身没有问题,但由于像I/O错误这类问题导致的异常属于其他异常。
派生于RuntimeException的异常包括以下问题:
- 错误的强制类转换
- 数组访问越界
- 访问null指针。
不是派生于RuntiomeException的异常包括:
- 试图超越文件末尾继续读取数据
- 试图打开一个不存在的文件
- 试图根据给定的字符串查找Class对象,而这个字符串表示的类并不存在。
如果出现了RuntimeException异常,那么就一定是你的问题,这个规则很有道理。应该通过检测数组下标是否越界来避免ArrayIndexOutBoundsExceptiont异常;应该在使用变量之前通过检测它是否为null来杜绝NullPointerException异常的发生。
如何处理不存在的文件呢?难道不能先检查文件是否存在再打开它呢?嗯,这个文件有可能在你检查它是否存在之后就立即被删除了。因此,“是否存在”取决于环境,而不只是取决于你的代码。
Java语言规范将派生于Error类或RuntimeException类的所有异常称为非检查型(unchecked)异常,所有其他的异常称为检查型(checked)异常。这是很有用的术语,在后面还会用到。编译器将检查你是否为所有的检查型异常提供了异常处理器。
7.1.2 声明检查型异常
如果遇到了无法处理的情况,Java方法可以抛出一个异常。这个道理很简单:方法不仅需要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误。例如,一段读取文件的代码知道有可能读取的文件不存在,或者文件内容为空,因此,试图处理文件信息的代码就需要通知编译器可能抛出IOException类的异常。
要在方法的首部指出这个方法可能抛出一个异常,所以要修改方法首部,以反映这个方法可能抛出的检查异常。例如,下面是标准类库中的FileInputStream类的一个构造器的声明。
1 | public FileInputStream(String name) throws FileNotFoundException |
这个声明表示这个构造器将根据给定的String参数产生一个FileInputStream对象,但也有可能抛出一个FileNotFoundException异常。如果发生了这种情况,构造器将不会初始化一个新的FileInputStream对象,而是抛出一个FileNotFoundException类对象。如果这个方法真的抛出了这样一个异常对象,运行时系统就会开始搜索直到如何处理FileNotFoundException对象的异常处理器。
在自己编写方法时,不必声明这个方法可能抛出的所有异常。至于什么时候需要在方法中throws子句声明异常,以及要用throws子句声明哪些异常,需要记住在遇到下面四种情况会抛出异常:
- 调用了一个抛出检查型异常的方法,例如FileInputStream构造器。
- 检测到一个错误,并且利用throw语句抛出一个检查型异常。
- 程序出现错误,例如,a[-1] = 0会抛出一个非检查型异常。
- Java虚拟机或运行时库内出现内部错误。
如果出现了前两种情况,则必须告诉调用这个方法的程序员有可能抛出异常。为什么?因为任何一个抛出异常的方法都有可能是一个死亡陷阱。如果没有处理器捕获这个异常,当前执行的线程就会终止。
有些Java方法包含在对外提供的类中,对于这些方法,应该通过方法首部的异常规范(exception specification)声明这个方法可能抛出异常。
1 | class MyAnimation |
如果一个方法有可能抛出多个检查型异常类型,那么就必须在方法的首部列出所有的异常类。每个异常类之间用逗号隔开。
1 | class MyAnimation |
但是,不需要声明Java的内存错误,即从Error继承的异常。任何程序代码都有可能抛出那些异常,而我们对此完全无法控制。同样,也不应该声明从RuntimeException继承的那些非检查型异常
1 | class MyAnimation |
这些运行时错误完全在我们的控制之中。如果特别担心数组下标错误,就应该多花些时间修正这些错误,而不只是声明这些错误有可能发生。
总之,一个方法必须声明所有可能抛出的检查型异常,而非检查型异常要么在你的控制之外(Error),要么是由从一开始就应该避免的情况所导致的(RuntimeException)。如果你的方法没有声明所有可能发生的检查异常,编译器就会发出一个错误消息。
当然,从前面的示例中可以知道:不只是声明异常,你还可以捕获异常。这样就不会从这个方法抛出这个异常,所以也没有必要使用throws。
如果类中的一个方法声明它会抛出一个异常,而这个异常是某个特定类的实例,那么这个方法抛出的异常可能属于这个类,也可能属于这个类的任意一个子类。例如,FileInputStream构造器声明有可能抛出一个IOExcetion异常,在这种情况下,你并不知道具体是哪种IOException异常。它既可能是IOException异常,也可能是某个子类的对象,例如,FileNotFoundException。
7.1.3 如何抛出异常
现在假设在程序代码中发生了糟糕的事情。一个名为readData的方法正在读取一个文件,文件首部包含以下信息,承诺文件长度1024个字符:
1 | Content - leangth: 1024 |
然而,读到733个字符之后文件就结束了。你可能认为这是一种不正常的情况,希望抛出一个异常。
首先要决定应该抛出什么类型的异常。可能某种IOException是个不错的选择。仔细地阅读Java API文档之后会发现:EOFException异常的描述是:“指示输入过程中意外遇到了EOF”。完美,这正是我们要抛出的异常。下面是抛出这个异常的语句:
1 | throw new EOFException(); |
EOFException类还有一个带一个字符串参数的构造器。你可以很好地利用这个构造器,更细致地描述异常。
1 | String gripe = "Content - length:" + len + ",Received:" + n; |
在前面已经看到,如果一个已有的异常类能够满足你的要求,抛出这个异常非常容易。在这种情况下:
- 找到一个合适的异常类
- 创建这个类的一个对象
- 将对象抛出
一旦方法抛出异常,这个方法就不会返回到调用者。也就是说,不必操心建立一个默认的返回值或错误码。
7.1.4 创建异常类
你的代码可能会遇到任何标准异常类都无法描述清楚的问题。在这种情况下,创建自己的异常类就是一件顺理成章的事情了。我们需要做的只是一个派生于Exception的类,或者派生于Exception的某个子类,如IOException。习惯做法是,自定义的这个类应该包含两个构造器,一个是默认的构造器,另一个是包含详细描述信息的构造器(超类Throwable的toString方法会返回一个字符串,其中包含这个详细信息,这在调式中非常有用。)
1 | public FileFormatException extends IOException |
1 | String readData(BufferedReader in) throws FileFormatException |
7.2 捕获异常
7.2.1 捕获异常
如果发生了某个异常,但没有在任何地方捕获这个异常,程序就会终止,并在控制台上打印一个消息,其中包括这个异常的类型和一个堆栈轨迹。
想要捕获一个异常,需要设置try/catch语句块。最简单的try语句块如下所示:
1 | try |
如果try语句块中的任何代码抛出了catch子句中指定的一个异常类,那么:
- 程序将跳过try语句块的其余代码。
- 程序将执行catch子句中的处理器代码。
如果try语句块中的代码没有抛出任何异常,那么程序将跳过catch子句。
如果方法中的任何代码抛出了catch子句没有声明的一个异常类型,那么这个方法就会立即退出(希望它的调用者为这种类型的异常提供catch子句)。
为了展示捕获异常的处理过程,下面给出了一个很典型的读取数据的代码:
1 | public void read(String filename) |
需要注意的是,try子句中的大多数代码都很容易理解:读取并处理字节,直到遇到文件结束符为止。正如在JavaAPI中看到的那样,read方法有可能抛出一个IOException异常。在这种情况下,将跳出整个while循环,进入catch子句,并生成一个堆栈轨迹。对于一个“玩具类”的简单程序来说,这样处理异常看上去很有道理。还有其他选择么?
通常,最好的选择是什么也不做,而是将异常传递给调用者。如果read方法出现了错误,就让read方法调用者去操心这个问题!如果采用这种处理方法,就必须声明这个方法可能会抛出一个IOException。
1 | public void read(String filename) throws IOException |
请记住,编译器将严格地执行throws说明符。如果调用了一个抛出检查型异常的方法,就必须处理这个异常,或者继续传递这个异常。哪种方法更好呢?一般经验是,要捕获那些你知道如何处理的异常,而继续传播那些你不知道怎么处理的异常。如果想传播一个异常,就必须在方法的首部添加一个throws说明符,提醒调用者这个方法可能会抛出异常。查看API文档,可以看到每个方法可能会抛出哪些异常,然后决定是由自己处理,还是添加到throws列表中。对于后一种选择,不用感到难堪。将异常交给胜任的处理器进行处理要比压制这个异常更好。
同时记住,这个规则也有一个例外。前面曾经提到过:如果编写一个方法覆盖超类的方法,而这个超类的方法没有抛出异常(如JComponent中的paintComponent),你就必须捕获你的方法代码中出现的每一检查型异常。不允许在子类的throws说明符中出现超类方法未列出的异常类。
7.2.2 捕获多个异常
在一个try语句块中可以捕获多个异常类型,并对不同类型的异常做出不同的处理。要为每个异常类型使用一个单独的catch子句,如下列:
1 | try |
异常对象可能包含有关异常性质的信息。想要获得这个对象的更多信息,可以尝试使用e.getMessage();得到详细的错误消息(如果有的话),或者使用e.getClass().getName()得到异常对象的实际类型。
在Java7中,通过一个catch子句中可以捕获多个异常类型。例如,假设对应缺少文件和未知主机异常的动作是一样的,就可以合并catch子句:
1 | try |
只有当捕获的异常类型彼此之间不存在子类关系时才需要这个特性。
7.2.3 再次抛出异常与异常链
可以在catch子句中抛出一个异常。通常,希望改变异常的类型时会这样做。如果开发了一个供其他程序员使用的子系统,可以使用一个指示子系统故障的异常类型,这很有道理。ServletException就是这样一个异常类型的例子。执行一个servlet的代码可能不想知道发生错误的细节原因,但希望明确地直到Servlet是否有问题。
可以如下捕获异常并将它再次抛出:
1 | try |
在这里,构造ServletException时提供了异常的消息文本。
不过,可以有一种更好的处理方法,可以把原始异常设置为新异常的“原因”:
1 | try |
捕获到这个异常时,可以使用下面这条语句获取原始异常:
1 | Throwable original = caughtException.getCause(); |
强烈建议使用这种包装技术。这样可以在子系统中抛出高层异常,而不会丢失原始异常。
有时候你可能只想记录一个异常,再将它重新抛出,而不做任何改变:
1 | try |
7.2.4 finally 子句
代码抛出一个异常时,就会停止处理这个方法中 剩余的代码,并退出这个方法。如果这个方法已经获得了只有它自己知道一些本地资源,而且这些资源必须清理,这就会有问题。一种解决方案是捕获所有异常,完成资源的清理,再重新抛出异常。但是,这种解决方案比较繁琐,这是因为需要在两个点地方清理资源分配。一个在正常的代码中;另一个在异常代码中。finally子句可以解决这个问题。
不管是否有异常被捕获,finally子句中的代码都会执行。在下面的示例中,所有情况下程序都将关闭输入流。
1 | FileInputStream in = new FileInputStream(...); |
这个程序可能在以下3中情况执行finally子句:
- 代码没有抛出异常。这种情况下,程序首先执行try语句块中的全部代码,然后执行行finally子句中的代码。随后,继续执行finally子句之后的第一条语句。也就是说,执行顺序为1,2,5,6。
- 代码抛出一个异常,并在一个catch子句中捕获。在上面的示例中就是IOException异常。在这种情况下,程序将执行try语句块中的所有代码,直到抛出异常为止。此时,将跳过try语句块中的剩余代码,转去执行与该异常匹配的catch子句中的代码,最后执行finally子句中的代码。如果catch子句没有抛出异常,程序将执行finally子句之后的第一条语句。在这种情况下,执行顺序是1,3,4,5,6。如果catch子句抛出了一个异常,异常将被抛回到这个方法的调用者。执行顺序则只是1,3,5。
- 代码抛出了一个异常,但没有任何catch子句捕获这个异常。在这种情况下,程序将执行try语句块中的所有语句,直到抛出异常为止。此时,将跳过try语句块的剩余代码,然后执行finally子句中语句,并将异常抛回给这个方法的调用者。在这里,执行的顺序只是1,5。
try语句可以只有finally子句,而没有catch子句。例如,下面这条try语句:
1 | InputStream in = ...; |
无论在try语句块中是否遇到异常,finally子句中的in.close()语句都会执行。当然,如果真的遇到一个异常,这个异常将会被重新抛出,并且必须由另一个catch子句捕获。
1 | InputStream in = ...; |
内层的try语句块只有一个职责,就是确保关闭输入流。外层的try语句块也只有一个职责,就是确保报告出现的错误。这种解决方法不仅清楚,而且功能更强大:将会报告finally子句中出现的错误。
7.2.5 try-with-Resources 语句
在Java7中,对于以下代码模式:
1 | open a resource |
假设资源属于一个实现了AutoCloseable接口的类。Java7为这种代码模式提供了一个很有用的快捷方式。AutoCloseable接口有一个方法:
1 | void close() throws Exception |
try-with-resources语句的最简形式为:
1 | try(Resource res =...) |
try块退出时,会自动调用res.close()。下面给出一个典型的例子,这里要读取一个文件中的所有单词:
1 | try(Scanner in = new Scanner(new FileInputStream("/usr/share/dict/words"),StandardCharsets.UTF_8)) |
这个块正常退出时,或者存在一个异常时,都会调用in.close()方法,就好像使用了finally一样。还可以指定多个资源。例如:
1 | try(Scanner in = new Scanner(new FileInputStream("/usr/share/dict/words",StandardCharsets.UTF_8); |
不论这个块如何退出,in和out都会关闭。如果你用常规方式手动编程,就需要两个嵌套的try/finally语句。
在Java9中,可以在try首部中提供之前声明的事实最终变量:
1 | public static void printAll(String[] lines, PrintWriter out) |
如果try块抛出一个异常,而且close方法也抛出一个异常,这就带来一个难题。try-with-resources语句可以很好地处理这种情况。原来的异常会重新抛出,而close方法抛出的异常会“被抑制”。这些异常将自动捕获,并由addSuppressed方法增加到原来的异常。如果对这些异常感兴趣,可以调用getSuppressed方法,它会生成从close方法抛出并被抑制的异常数组。
你肯定不想采用这种常规方式编程。只要需要关闭资源,就要尽可能使用try-with-resources语句。
7.2.6 分析堆栈轨迹元素
堆栈轨迹(stack trace)是程序执行过程中某个特定点上所有挂起的方法调用的一个列表。你肯定已经看到过这种堆栈轨迹列表,当Java程序因为一个未捕获的异常而终止时,就会显示堆栈轨迹。
可以调用Throwable类的printStackTrace方法访问堆栈轨迹的文本描述信息。
1 | Throwable t = new Throwable(); |
一种更灵活的方法是使用StackWalker类,它会生成一个StackWalker.StackFrame实例流,其中每个实例分别描述一个栈帧(stack frame)。可以利用以下调用迭代处理这些栈帧:
1 | StackWalker walker = StackWalker.getInstance(); |
如果想要以懒方式处理Stream
1 | walker.walk(stream -> process stream) |
7.3 使用异常的技巧
- 1.异常处理不能代替简单的测试:作为一个示例,在这里编写了一段代码,试着将一个空栈弹出10000000次。首先,在弹栈之前先查看栈是否为空。
1 | if(!s.empty()) s.pop() |
接下来,我们强制要求不论栈是否为空都执行弹出操作,然后捕获EmptyStackException异常来指示我们不能这样做。
1 | try |
在我们的测试机器上,调用isEmpty的版本运行时间为646毫秒。捕获EmptyStackException的版本运行时间为21739毫秒。可以看出,与完成简单的测试相比,捕获异常所花费的时间大大超过了前者,因此使用异常的基本规则是:只在异常情况下使用异常。
- 2.不要过分地细化异常:很多程序员习惯将每一条语句都分装在一个独立的try语句块中
1 | PrintStream out; |
这种编程方式将导致代码量的急剧膨胀。首先来看你希望这段代码完成的任务。在这里,我们希望从栈中弹出100个数,将它们存入一个文件中。如果出现了问题,我们什么也做不了。如果栈是空的,他不会变成非空状态;如果文件包含错误,这个错误也不会魔法般地消失。因此,有必要将整个任务包在一个try语句块中,这样,当任何一个操作出现问题时,就可以取消整个任务。
1 | try |
这段代码看起来清晰多了。这样也满足了异常处理的一个承诺:将正常处理与错误处理分开。
- 3.充分利用异常层次结构:不要只抛出RuntimeException异常。应该寻找一个合适的子类或创建自己的异常类。不要只捕获Throwable异常,否则,这会使你的代码更难读、更难维护。考虑检查型异常与非检查型异常的区别。检查型异常本来就很庞大,不要为逻辑错误抛出这些异常。如果能够将一种异常转换成另一种更加合适的异常,那么不要犹豫。例如,在解析某个文件中的一个整数时,可以捕获NumberFormatException异常,然后将它转换为IOException的一个子类或MySubsystemException。
- 4.不要压制异常:在Java中,往往非常希望关闭异常。如果你编写了一个方法要调用另一个方法,而那个方法有可能100年才抛出一个异常,但是,如果没有在你的方法的throws列表中声明这个异常,编译器就会报错。你不想把它放在throws列表中,以为这样一来,编译器会对调用了你的方法的所有方法报错。因此,你会将这个异常关闭:
1 | public Image loadImage(String s) |
现在你的代码可以通过编译了。除非出现异常,否则它能很好地运行。但是一旦出现异常,这个异常会悄无声息地被忽略。如果你认为异常都非常重要,就应当是当地进行处理。
- 5.在检测错误时,“苛刻”要比放任更好:当检测到错误的时候,有些程序员对抛出异常很担心。在用无效的参数调用一个方法时,返回一个虚拟值是不是比抛出一个异常更好?例如,党栈为空时,Stack.pop是该返回一个null,还是要抛出一个异常?我们认为:最好在出错的地方抛出一个EmptyStackException异常,这要好于以后抛出一个NullPointerException异常。
- 6.不要羞于传递异常:很多程序员都感觉应该捕获抛出的全部异常。如果调用了一个抛出异常的方法,例如,FileInputStream构造器或readLine方法,它们就会本能地捕获这些可能产生的异常。其实,最好继续传递这个异常,而不是自己捕获:
1 | public void readStuff(String filename)throws IOException |
更高层的方法通常可以更好地通知用户发生了错误,或者放弃不成功的命令。
7.4 使用断言
7.4.1 断言的概念
假设确信某个属性符合要求,并且代码的执行依赖于这个属性。例如,需要计算
1 | double y = Math.sqrt(x); |
你确信这里的x是一个非负数。原因是:x是另外一个计算结果,而这个结果不可能是一个负值;或者x是一个方法的参数,这个方法要求它的调用者只能提供一个正数输入。不过,你可能还是想再做一次检查,不希望计算中潜入让人困惑的“不是一个数”(NaN)浮点数。当然,也可以抛出一个异常:
1 | if(x < 0) throw new IllagalArgumentException("x < 0"); |
但是即使测试完毕后,这个代码还会一直保留在程序中。如果在程序中含有大量这种检查,程序运行起来慢很多。
断言机制允许在测试期间向代码中插入一些检查,而在生产代码中会自动删除这些检查。
Java语言引入了关键字assert。这个关键字有两种形式:
1 | assert condition; |
这两个语句都会计算条件,如果结果为false,则抛出一个AssertionError异常。在第二个语句中,表达式将传入AssertionError对象构造器,并转换成一个消息字符串。
想要断言x是一个非负数,只需要简单地使用下面这条语句
1 | assert x >= 0; |
7.4.2 启用和禁用断言
在默认情况下,断言是禁用的。可以在运行程序时用 -enableassertions或 -ea 选项启用断言:
1 | java -enableassertions MyApp |
需要注意的是,不必重新编译程序来启用或禁用断言。启用或禁用断言是类加载器(class loader)的功能。禁用断言时,类加载器会取出断言代码,因此,不会降低程序运行的速度。也可以在某个类或整个包中启用断言,例如:
1 | java -ea:MyClass -ea:com.mycompany.mylib MyApp |
这条命令将为MyClass类以及com.mycompany.mylib包和它的子包中的所有类打开断言。选项 -ea将打开无名包中所有类的断言。也可以用选项 -disableassertions 或 - da 在某个特定类和包中禁用断言:
1 | java -ea:... -da:MyClass MyApp |
有些类不是由类加载器加载,而是直接由虚拟机加载的。可以使用这些开关有选择地启用或禁用那些类中的断言。
不过,启用和禁用所有断言的 -ea和 -da开关不能应用到那些没有类加载器的“系统类”上。对于这些系统类,需要使用 -enablesystemassertions/-esa 开关启用断言。也可以通过变成控制类加载器的断言状态。