第7章 异常、断言和日志
对于异常情况,例如,可能造成程序崩溃的错误输入,Java使用一种称为异常处理(exception handing)的错误捕获机制处理。
在测试期间,需要进行大量的检测以验证程序操作的正确性。然而,这些检测可能非常耗时,在测试完成后也不必保留它们,因此,可以将这些检测删掉,并在其他测试需要时将它们粘贴回来,这是一件很乏味的事情。
当程序出现错误时,并不总是能够与用户或终端进行沟通。此时,可能希望记录下出现的问题,以备日后进行分析。
7.1 处理错误
如果由于出现错误而使得某些操作没有完成,程序应该:
- 返回到一种安全状态,并能够让用户执行一些其他的命令;或者
- 允许用户保存所有操作的结果,并以妥善的方式终止程序。
异常处理的任务就是将控制权从错误产生的地方转移给能够处理这种情况的错误处理器。
7.1.1 异常分类
在Java程序设计语言中,异常对象都是派生于Throwable类的一个实例。稍后还可以看到,如果Java中内置的异常类不能够满足需求,用户可以创建自己的异常类。
需要注意的是,所有的异常都是由Throwable继承而来,但在下一层立即分解为两个分支:Error和Exception。
Error类层次结构描述了Java运行时系统的内部错误和资源耗尽错误。应用程序不应该抛出这种类型的对象。如果出现了这样的内部错误,除了通告给用户,并尽力使程序安全地终止之外,再也无能为力了。这种情况很少出现。
在设计Java程序时,需要关注Exception层次结构。这个层次结构又分解为两个分支:一个分支派生于RuntimeException;另一个分支包含其他异常。划分两个分支的规则是:由程序错误导致的异常属于RuntimeException;而程序本身没有问题,但由于像I/O错误这类问题导致的异常属于其他异常。
派生于RuntimeException的异常包含下面几种情况:
- 错误的类型转换。
- 数组访问越界。
- 访问null指针。
不是派生于RuntimeException的异常包括:
- 试图在文件尾部后面读取数据。
- 试图打开一个不存在的文件。
- 试图根据给定的字符串查找Class对象,而这个字符串表示的类并不存在。
“如果出现RuntimeException异常,那么就一定是你的问题”是一条相当有道理的规则。应该通过检测数组下标是否越界来避免ArrayIndexOutOfBoundsException异常;应该通过在使用变量之前检测是否为null来杜绝NullPointerException异常的发生。
Java语言规范将派生于Error类或RuntimeException类的所有异常称为非受查(unchecked)异常,所有其他的异常称为受查(checked)异常。
7.1.2 声明受查异常
如果遇到了无法处理的情况,那么Java的方法可以抛出一个异常。这个道理很简单:一个方法不仅需要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误。
在自己编写方法时,不必将所有可能抛出的异常都进行声明。至于什么时候需要在方法中用throws子句声明异常,什么异常必须使用throws子句声明,需要记住在遇到下面4种情况时应该抛出异常:
- 调用一个抛出受查异常的方法,例如,FileInputStream构造器。
- 程序运行过程中发现错误,并且利用throw语句抛出一个受查异常(下一节将详细地介绍throw语句)。
- 程序出现错误,例如,a[-1]=0会抛出一个ArrayIndexOutOfBoundsException这样的非受查异常。
- Java虚拟机和运行时库出现的内部错误。
对于那些可能被他人使用的Java方法,应该根据异常规范(exceptionspecification),在方法的首部声明这个方法可能抛出的异常。
如果一个方法有可能抛出多个受查异常类型,那么就必须在方法的首部列出所有的异常类。每个异常类之间用逗号隔开。
public Image loadImage(String s) throws IOException
{
}
public Image loadImage(String s) throws FileNotFoundException, IOException
{
}
7.1.3 如何抛出异常
对于一个已经存在的异常类,将其抛出非常容易。在这种情况下:
1)找到一个合适的异常类。
2)创建这个类的一个对象。
3)将对象抛出。
7.1.4 创建异常类
们需要做的只是定义一个派生于Exception的类,或者派生于Exception子类的类。例如,定义一个派生于IOException的类。习惯上,定义的类应该包含两个构造器,一个是默认的构造器;另一个是带有详细描述信息的构造器(超类Throwable的toString方法将会打印出这些详细信息,这在调试中非常有用)。
public class FileFormatException extends IOException {
public FileFormatException() {
}
public FileFormatException(String gripe)
{
super(gripe);
}
}
// 抛出异常的信息
public static void repeat1(int n, IntConsumer action) throws FileFormatException
{
throw new FileFormatException();
}
7.2 捕获异常
7.2.1 捕获异常
如果某个异常发生的时候没有在任何地方进行捕获,那程序就会终止执行,并在控制台上打印出异常信息,其中包括异常的类型和堆栈的内容。
要想捕获一个异常,必须设置try/catch语句块。最简单的try语句块如下所示:
try {
code
more code
more code
}
catch (ExceptionType e)
{
handler for this type
}
如果在try语句块中的任何代码抛出了一个在catch子句中说明的异常类,那么
1)程序将跳过try语句块的其余代码。
2)程序将执行catch子句中的处理器代码。
如果在try语句块中的代码没有抛出任何异常,那么程序将跳过catch子句。
如果方法中的任何代码抛出了一个在catch子句中没有声明的异常类型,那么这个方法就会立刻退出(希望调用者为这种类型的异常设计了catch子句)。
7.2.2 捕获多个异常
在一个try语句块中可以捕获多个异常类型,并对不同类型的异常做出不同的处理。可以按照下列方式为每个异常类型使用一个单独的catch子句:
try {
code that might throw exceptions
}
catch (FileNotFoundException e)
{
emergency action for missing files
}
catch (UnknownHostException e)
{
emergency action for unknown hosts
}
catch (IOException e)
{
emergency action for IO
}
7.2.3 再次抛出异常与异常链
在catch子句中可以抛出一个异常,这样做的目的是改变异常的类型。如果开发了一个供其他程序员使用的子系统,那么,用于表示子系统故障的异常类型可能会产生多种解释。
try {
code that might throw exceptions;
}
catch (SQLException e)
{
Throwable se = new ServletException;
se.initCause(e);
throw se;
}
// 当捕获到异常时,就可以使用下面这条语句重新得到原始异常:
Throwable e = se.getCause();
强烈建议使用这种包装技术。这样可以让用户抛出子系统中的高级异常,而不会丢失原始异常的细节。
7.2.4 finally子句
当代码抛出一个异常时,就会终止方法中剩余代码的处理,并退出这个方法的执行。如果方法获得了一些本地资源,并且只有这个方法自己知道,又如果这些资源在退出方法之前必须被回收,那么就会产生资源回收问题。
Java有一种更好的解决方案,这就是finally子句。
不管是否有异常被捕获,finally子句中的代码都被执行。
try语句可以只有finally子句,而没有catch子句。
事实上,我们认为在需要关闭资源时,用这种方式使用finally子句是一种不错的选择。
当finally子句包含return语句时,将会出现一种意想不到的结果。假设利用return语句从try语句块中退出。在方法返回前,finally子句的内容将被执行。如果finally子句中也有一个return语句,这个返回值将会覆盖原始的返回值。
// f(2) = 0
public static int f(int n)
{
try {
int r = n * n;
return r;
}
finally {
if (n == 2)
return 0;
}
}
7.3 使用异常机制的技巧
1.异常处理不能代替简单的测试
2.不要过分地细化异常
3.利用异常层次结构(不要只抛出RuntimeException异常。应该寻找更加适当的子类或创建自己的异常类。不要只捕获Thowable异常,否则,会使程序代码更难读、更难维护)
4.不要压制异常
5.在检测错误时,“苛刻”要比放任更好
6.不要羞于传递异常
7.4 使用断言
7.4.1 断言的概念
在一个具有自我保护能力的程序中,断言很常用。
断言机制允许在测试期间向代码中插入一些检查语句。当代码发布时,这些插入的检测语句将会被自动地移走。
Java语言引入了关键字assert。这个关键字有两种形式:
assert 条件; 和 assert 条件 : 表达式
这两种形式都会对条件进行检测,如果结果为false,则抛出一个AssertionError异常。在第二种形式中,表达式将被传入AssertionError的构造器,并转换成一个消息字符串。
7.4.2 启用和禁用断言
在默认情况下,断言被禁用。可以在运行程序时用-enableassertions或-ea选项启用:
java -enableassertions MyApp
需要注意的是,在启用或禁用断言时不必重新编译程序。启用或禁用断言是类加载器(class loader)的功能。当断言被禁用时,类加载器将跳过断言代码,因此,不会降低程序运行的速度。
7.4.3 使用断言完成参数检查
在Java语言中,给出了3种处理系统错误的机制:
- 抛出一个异常
- 日志
- 使用断言
什么时候应该选择使用断言呢?请记住下面几点:
- 断言失败是致命的、不可恢复的错误。
- 断言检查只用于开发和测阶段。
因此,不应该使用断言向程序的其他部分通告发生了可恢复性的错误,或者,不应该作为程序向用户通告问题的手段。断言只应该用于在测试阶段确定程序内部的错误位置。
7.5 记录日志
日志的优点
- 可以很容易地取消全部日志记录,或者仅仅取消某个级别的日志,而且打开和关闭这个操作也很容易。
- 可以很简单地禁止日志记录的输出,因此,将这些日志代码留在程序中的开销很小。
- 日志记录可以被定向到不同的处理器,用于在控制台中显示,用于存储在文件中等。
- 日志记录器和处理器都可以对记录进行过滤。过滤器可以根据过滤实现器制定的标准丢弃那些无用的记录项。
- 日志记录可以采用不同的方式格式化,例如,纯文本或XML。
- 应用程序可以使用多个日志记录器,它们使用类似包名的这种具有层次结构的名字,例如,com.mycompany.myapp。
- 在默认情况下,日志系统的配置由配置文件控制。如果需要的话,应用程序可以替换这个配置。
7.5.1 基本日志
要生成简单的日志记录,可以使用全局日志记录器(global logger)并调用其info。
// 输出日志 May 10,2020 10:12:22 PM LoggingImageViewer fileOpen INFO File->open menu item selected
Logger.getGlobal().info("File->open menu item selected");
// 取消日志
Logger.getGlobal().setLevel(Level.OFF);
7.5.2 高级日志
在一个专业的应用程序中,不要将所有的日志都记录到一个全局日志记录器中,而是可以自定义日志记录器。
可以调用getLogger方法创建或获取记录器:
private static final Logger myLogger = Logger.getLogger("com.company.myapp");
未被任何变量引用的日志记录器可能会被垃圾回收。为了防止这种情况发生,要像上面的例子中一样,用一个静态变量存储日志记录器的一个引用。
通常,有以下7个日志记录器级别:
- SEVERE
- WARNING
- INFO
- CONFIG
- FINE
- FINER
- FINEST
在默认情况下,只记录前三个级别
7.6 调试技巧
- 一个不太为人所知但却非常有效的技巧是在每一个类中放置一个单独的main方法。这样就可以对每一个类进行单元测试。
- JUnit是一个非常常见的单元测试框架,利用它可以很容易地组织测试用例套件。只要修改类,就需要运行测试。在发现bug时,还要补充一些其他的测试用例。
- 日志代理(logging proxy)是一个子类的对象,它可以截获方法调用,并进行日志记录,然后调用超类中的方法。
- 利用Throwable类提供的printStackTrace方法,可以从任何一个异常对象中获得堆栈情况。
- Java虚拟机增加了对Java应用程序进行监控(monitoring)和管理(management)的支持。它允许利用虚拟机中的代理装置跟踪内存消耗、线程使用、类加载等情况。
- 可以使用jmap实用工具获得一个堆的转储,其中显示了堆中的每个对象