第12章 异常处理和文本I/O
12.1 引言
异常处理使得程序可以处理非预期的情景,并且继续正常的处理。
在程序运行过程中,如果JVM检测出一个不可能执行的操作,就会出现运行时错误(runtime error)。
在Java中,运行时错误会作为异常抛出。异常就是一种对象,表示阻止正常进行程序执行的错误或情况。如果异常没有被处理,那么程序将会非正常终止。
12.2 异常处理概述
异常是从方法抛出的。方法的调用者可以捕获以及处理该异常。
throw语句的执行称为抛出一个异常(throwing an exception)。异常就是一个从异常类创建的对象。
当异常被抛出时,正常的执行流程就被中断。就像它的名字所提示的,“抛出异常”就是将异常从一个地方传递到另一个地方。调用方法的语句包含在一个try块和catch块。try块包含了正常情况下执行的代码。异常被catch块所捕获。catch块中的代码执行以处理异常。之后,catch块之后的语句被执行。
throw语句类似于方法的调用,但不同于调用方法的是,它调用的是catch块。从某种意义上讲,在执行完catch块之后,程序控制不返回到throw语句;而是执行catch块后的下一条语句。
catch头部
catch (ArithmeticExeption ex)
标识符ex的作用很像是方法中的参数。所以,这个参数称为catch块的参数。ex之前的类型指定了catch块可以捕获的异常类型。一旦捕获该异常,就能从catch块体中的参数访问这个抛出的值。
总之,一个try-throw-catch块的模板可能会如下所示:
try {
Code to run;
A statement or a method that may throw an exception;
More code to run;
}
catch (type ex) {
Code to process the exception;
}
一个异常可能是通过try块中的throw语句直接抛出,或者调用一个可能会抛出异常的方法而抛出。
使用异常处理能使方法抛出一个异常给它的调用者,并由调用者处理该异常。如果没有这个能力,那么被调用的方法就必须自己处理异常或者终止该程序。被调用的方法通常不知道在出错的情况下该做些什么。异常处理最根本的优势就是将检测错误(被调用的方法完成)从处理错误(由调用方法完成)中分离出来。
12.3 异常类型
异常是对象,而对象都采用来类来定义。异常的根类是java.lang.Throwable。

类名Error、Exception和RuntimeException有时候容易引起混淆。这三种类都是异常,这里讨论的错误都发生在运行时。
Throwable类是所有异常类的根。所有的类Java异常类都直接或者间接地继承自Throwable。可以通过继承Exception或者Exception的子类来创建自己的异常类。
这些异常类可以分为三种主要类型:系统错误、异常和运行时异常。
系统错误(system error)是由Java虚拟机抛出的,用Error类表示。Error类描述的是内部系统错误。这样的错误很少发生。如果发生,除了通知用户以及尽量稳妥地终止程序外,几乎什么也不能做。
类 | 可能引起异常的原因 |
---|---|
LinkageError | 一个类对另一个类有某种依赖性,但是在编译前者后,后者进行了修改,变得不兼容 |
VirtualMachineError | Java虚拟机崩溃,或者运行所必需的资源已经耗尽 |
异常(exception)是用Exception类表示的,它描述的是由程序和外部环境所引起的错误,这些错误能被程序捕获和处理。
类 | 可能引起异常的原因 |
---|---|
ClassNotFoundException | 试图使用一个不存在的类。例如,如果试图使用命令java来运行一个不存在的类,或者程序要调用三个类文件而只能找到两个,都会发生这种异常。 |
IOException | 同输入/输出相关的操作,例如,无效的输入、读文件时超过文件尾,打开一个不存在的文件等。IOException的子类的例子有InterruptedIOException、EOFException(EOF是End Of File的缩写)和FileNotFoundException |
运行时异常(runtime exception)是用RuntimeException类表示的,它描述的是程序设计错误,例如,错误的是类型转换,访问一个越界数组或数值错误。运行时异常通常是由Java虚拟机抛出的。
类 | 可能引起异常的原因 |
---|---|
ArithmeticException |
一个整数除以0。注意,浮点数的算术运算不抛出异常。 |
NullPointerException | 试图通过一个null引用变量访问一个对象 |
IndexOutOfBoundsException | 数组的下标超出范围 |
IllegalArgumentException | 传递给方法的参数非法或不合适 |
RuntimeException、Error以及它们的子类都称为免检异常(unceked exception)。所有其他异常都称为必检异常(checked exception),意思是编译器会强制程序员检查并通过try-catch块处理它们,或者在方法头进行声明。
在大多数情况下,免检异常都会反映出程序设计上不可恢复的逻辑错误。例如,如果通过一个引用变量访问一个对象之前并未将一个对象赋值给它,就会抛出NullPointerException异常;如果访问一个数组的越界元素,就会抛出IndexOutOfBoundsException异常。这些都是程序中必须纠正的逻辑错误。免检异常可能在程序的任何一个地方出现。为避免过多地使用try-catch块,Java语言不强制要求编写代码捕获或声明免检异常。
12.4 关于异常处理更多的知识
异常的处理器是通过从当前的方法开始,沿着方法调用链,按照异常的反向传播方向找到的。
Java的异常处理模型基于三种操作:声明一个异常(declaring an exception)、抛出一个异常(throwing an exception)和捕获一个异常(catching an exception)。
12.4.1 声明异常
在Java中,当前执行的语句必术语某个方法。Java解释器调用main方法开始执行一个程序。每个方法都必须声明它可能抛出的必检异常的类型。这称为声明异常(declaring an exception)。因为任何代码的可能发生系统错误和运行时错误,因此,Java不要求在方法中显式声明Error和RuntimeException(免检异常)。但是,方法要抛出的其他异常都必须在方法头显式声明,这样,方法的调用者会被告知有异常。
为了在方法中声明一个异常,就要在方法头中使用关键字throws,如下所示:
public void myMethod() throws IOException
关键字throws表明myMethod方法可能会抛出异常IOException。如果方法可能会抛出多个异常,就可以在关键字throws后添加一个用逗号分隔的异常列表:
public void myMethod()
throws Exception1, Exception2, ..., ExceptionN
如果方法没有在父类中声明异常,那么就不能在子类中对其进行继承来声明异常。
12.4.2 抛出异常
检测到错误的程序可以创建一个合适的异常类型的实例并抛出它,这就称为抛出一个异常(throwing an exception)。这里有一个例子,假如程序发现传递给方法的参数与方法的合约不符(例如,方法中的参数必须是非负的,但是传入的是一个负参数),这个程序就可以创建IllegalArgumentException的一个实例并抛出它,如下所示:
IllegalArgumentException ex = new IlleaglArgumentEXception("Wrong Argument");
throw ex;
或者,根据你的偏好,也可以使用下面的语句:
throw new IllegalArgumentException("Wrong Argument");
IllegalArgumentException是Java API中的一个异常类。通常,Java API中的每个异常类至少有两个构造方法:一个无参构造方法和一个带可描述这个异常的String参数的构造方法。该参数称为异常消息(exception message),它可以用getMessage()获取。
声明异常的关键字是throws,抛出异常的关键字是throw。
12.4.3 捕获异常
当抛出一个异常时,可以在try-catch块中捕获和处理它,如下所示:
try {
statements; // Statements that may throw exceptions
}
catch (Exception exVar1) {
handler for exception1;
}
catch (Exception exVar2) {
handler for exception2;
}
...
catch (Exception exVarN) {
handler for exceptionN;
}try {
statements; // Statements that may throw exceptions
}
catch (Exception exVar1) {
handler for exception1;
}
catch (Exception exVar2) {
handler for exception2;
}
...
catch (Exception exVarN) {
handler for exceptionN;
}
如果在执行try块的过程中没有出现异常,则跳过catch子句。
如果try块中的某条语句抛出一个异常,Java就会跳过try块中剩余的语句,然后开始查找处理这个异常的代码的过程。处理这个异常的代码称为异常处理器(exception handler);可以从当前的方法开始,沿着方法调用链,按照异常的反向传播方向找到这个处理器。从第一个到最后一个逐个检查catch块,判断在catch块中的异常类实例是否是该异常对象的类型。如果是,就将该异常对象赋值给所声明的变量,然后执行catch块中的代码。如果没有发现异常处理器,Java会退出这个方法,把异常传递给调用这个方法的方法,继续同意的过程来查找处理器。如果在调用的方法链中找不到处理器,程序就会终止并且在控制台上打印出错信息。寻找处理器的过程称为捕获一个异常(catching exception)。
从一个通用的父类可以派生出各种异常类。如果一个catch块可以捕获一个父类的异常对象,它就能捕获那个父类的所有子类的异常对象。
在catch块中异常被指定的顺序是非常重要的 。如果父类的catch块出现在子类的catch之前,就会导致编译错误。
Java强迫程序员处理必检异常。如果方法声明了一个必检异常(即Error或Runtime Exception之外的异常),就必须在try-catch块中调用它。
对于使用同样的处理代码处理多个异常的情况,可以使用新的 JDK7的多捕获特性(multi-catch feature)简化异常的代码编写。语法是:
每个异常类型使用竖线( | )与下一个分隔。如果其中一个异常被捕获,则执行处理的代码。
catch (Exception1 | Exception2 | ... | Exception ex) {
// Same code for handling these exceptions
}
12.4.4 从异常中获取信息
异常对象包含关于的有价值的信息,可以利用下面这些java.lang.Throwable类中的实例方法获取有关异常的信息,如图所示。printStackTrace()方法在控制台上打印栈跟踪信息。getStackTrace()方法提供编程的方式,来访问由printStackTrace()打印输出的栈跟踪信息。

12.5 finally子句
无论异常是否产生,finally子句总是会被执行的。
有时候,不论异常是否出现或者是否被捕获,都希望执行某些代码。Java有一个finally子句,可以用来达到这个目的。finally子句的语法如下所示:
try{
statements;
}
catch (Exception ex) {
handling ex;
}
finally {
finalStatements;
}
在任何情况下,finally块中的代码都会执行,不论try块中是否出现异常或者是否被捕获。考虑下面三种可能出现的情况:
如果try块中没有出现异常,执行finalStatements,然后执行try语句的下一条语句。
如果try块中有一条语句引起异常,并被catch块捕获,然后跳过try块的其他语句,执行catch块和finally子句。执行try语句之后的下一条语句。
如果try块中有一条语句引起异常,但是没有被任何catch块捕获,就会跳过try块中的其他语句,执行finally子句,并且将异常传递给这个方法的调用者。
即使在到达finally块之前有一个return语句,finally块还是会执行。
使用finally子句时可以省略掉ctach块。
12.6 何时使用异常
当错误需要被方法的调用者处理的时候,方法应该抛出一个异常。
try块包含正常情况下执行的代码。catch块包含异常情况下执行的代码。异常处理将错误处理代码从正常的程序设计任务中分离出来,这样,可以使程序更易读、更易修改。但是,应该注意,由于异常处理需要初始化新的异常对象,需要从调用栈返回,而且还需要沿着方法调用链来传播异常以便找到它的异常处理器,所以,异常处理通常需要更多的时间和资源。
异常出现在方法中。如果想让该方法的调用者处理异常,应该创建一个异常对象并将其抛出。如果能在发生异常的方法中处理异常,那么就不需要抛出或使用异常。
一般来说,一个项目多个类都会发生的共同异常应该考虑作为一种异常类。对于发生在个别方法中的简单错误最好进行局部处理,无须抛出异常。
在代码中,应该什么时候使用try-catch块呢?当必须处理不可预料的错误状况时应该使用它。不要用try-catch块处理简单的、可预料的情况。例如,下面的代码:
try {
System.out.println(refVar.toString());
}
catch (NullPointerException) {
System.out.println("refVar is null");
}
最好用以下代码代替:
if (refVar != null)
System.out.println(refVar.toString());
else
System.out.println("refVar is null");
哪些情况是异常的,哪些情况是可预料的,有时很难判断。但有一点要把握住,不要把异常处理作简单的逻辑测试。
12.7 重新抛出异常
如果异常处理器不能处理一个异常,或者总是简单地希望它的调用者注意到该异常,Java允许该异常处理器重新抛出异常。
重新抛出异常的语法如下所示:
try {
statements;
}
catch (TheException ex) {
perform exception before exits;
throw ex;
}
语句throw ex重新抛出异常给调用者,以便调用者的其他处理器获得处理异常ex的机会。
12.8 链式异常
和其他异常一起抛出一个异常,构成了链式异常。
有时候,可能需要同原始异常一起抛出一个新异常(带有附加信息),这称为链式异常(chained exception)。
12.9 创建自定义异常类
可以通过派生java.lang.Exception类来定义一个自定义异常类。
Java提供相当多的异常类,尽量使用它们而不要创建自己的异常类。然而,如果遇到一个不能用预定义异常类恰当描述的问题,那么就通过Exception类或其子类,例如,IOException,来创建自己的异常类。
Exception类扩展自java.lang.Throwable。Exception类中的所有方法(例如,getMessage(),toString()和printStackTrace())都是从Throwable继承而来的。Exception包括四个构造方法。其中经常使用的是下面两个构造方法:
+Exception() | 构建一个没有消息的异常 |
+Exception(message: String) | 构建一个给定消息的异常 |
Java API中的大多数异常类都包含两个构造方法:一个无参构造方法和一个带消息的构造方法。
可以扩展RuntimeException声明一个自定义异常类吗?可以,但不是一个好方法,因为这会使自定义异常成为免检异常。最好是自定义异常必检,这样,编译器就可以在程序中强制捕获这些异常。
12.10 File类
File包含了获得一个文件/目录的属性,以及对文件/目录进行改名和删除的方法。
存储在程序中的数据是暂时的,当数据终结时它们就会丢失。为了能够永久地保存程序中创建的数据,需要将它们存储到磁盘或其他永久存储设备的文件中。这样,这些文件其后可以被其他程序传递和读取。由于数据存储在文件中,所以本节就介绍如何使用FIle类获取文件/目录的属性以及删除和重命名文件/目录,以及创建目录。
在文件系统中,每个文件都存放在一个目录下。绝对文件名(absolute file name)是由文件名和它的完整路径已经驱动器字母组成。例如,c:\book\Welcome.java是Welcome.java在Windows操作系统上的绝对文件名。这里的c:\book称为该文件的目录路径(directory path)。绝对文件名是依赖机器的,在UNIX平台上,绝对文件名可能会是/home/liang/book/Welcome.java,其中/home/liang/book是文件Welcome.java的目录路径。
相对文件名是相对于当前工作目录的。对于相对文件名而言,完整目录被忽略。例如,Welcome.java是一个相对文件名。如果当前工作目录是c:\book,绝对文件名将是c:\book\Welcome.java。
File类意图提供了一种抽象,这种抽象是指以不依赖机器的方式来处理很多依赖于机器的文件和路径名的复杂性。File类包含许多获取文件属性的方法,以及重命名和删除文件和目录的方法,如图所示。但是,File类不包含读写文件内容的方法。


文件名是一个字符串。File类文件名及其目录路径的一个包装类。例如,在Windows中,语句new File("c:\\book\\test.dat")为文件c:\book\test.dat创建一个File对象。可以用File类的isDirectory()方法来判断这个对象是否表示一个目录,还可以用isFile()方法来判断这个对象是否表示一个文件名。
在Windows中目录的分隔符是反斜杠(\)。但是在Java中,反斜杠是一个特殊的字符,应该写成\\的形式。
构建一个File实例并不会在机器上创建一个文件。不管文件是否存在,都可以创建任意文件名的File实例。可以调用File实例上的exists()方法来判断这个文件是否存在。
在程序中,不要直接使用绝对文件名。如果使用了像c:\\book\\Welcome.java之类的文件名,那么它能在Windows上工作,但是不能在其他平台上工作。应该使用与当前相关的文件名。例如,可以使用new File("Welcome.java")为在当前目录下的文件Welcome.java创建一个File对象。可以使用new File("image/us.gif")为在当前目录下的image目录下的文件us.gif创建一个File对象。斜杠(/)是Java的目录分隔符,这点和UNIX是一样的。语句new File("image/us.gif")在Windows、UNIX或任何其他系统都能工作。
12.11 文件输入和输出
使用Scanner类从文件中读取文本数据,使用PrintWriter类向文本文件写入数据。
File对象封装了文件或路径的属性,但是它既不创建文件的方法,也不包括从/向文件读/写数据(称为数据输入输出,简称I\O)的方法。为了完成I\O操作,需要使用恰当的Java I\O类创建对象。这些对象包含从/向文件读/写数据的方法。文本文件本质上是存在磁盘上的字符。本节介绍如何使用Scanner和PrintWriter类从(向)文本文件读(写)字符串和数值信息。
12.11.1 使用PrintWriter写数据
java.io.PrintWriter类可用来创建一个文件并向文本文件写入数据。首先,必须为一个文本文件创建一个PrintWriter对象,如下所示:
PrintWriter output = new PrintWriter(filename);
然后,可以调用PrintWriter对象上的print、println和printf方法向文件写入数据。图总结了PrintWriter中的常用方法。


如果文件不存在。调用PrintWriter的构造方法会创建一个新文件。如果文件已经存在,那么文件的当前内容将在不和用户确认的情况下被废弃。
调用PrintWriter的构造方法可能会抛出某种I/O异常。Java强制要求编写代码来处理这类异常。为简单起见,只要在方法头中声明throws Exception即可。
System.out是控制台的标注Java对象。可以创建对象,然后使用print、println和printf向文件中写入文本。
必须使用close()方法关闭文件。如果没有调用该方法,数据就不能正确地保存在文件中。
12.11.2 使用try-with-resources自动关闭资源
程序员经常会关闭文件。JDK 7提供了下面的新的try-with-resources语法来自动关闭文件。
try(声明和创建资源) {
使用资源来处理文件;
}
使用try-with-resources语法,写出以下程序
import java.io.PrintWriter;
public class WriteDataWithAutoClose {
public static void main(String[] args) throws Exception{
// TODO Auto-generated method stub
java.io.File file = new java.io.File("scores.txt");
if (file.exists()) {
System.out.println("File already exists");
System.exit(0);
}
try (
// Create a file
PrintWriter output = new PrintWriter(file);
) {
// Write formatted output to the file
output.print("John T Smith ");
output.println(90);
output.print("Eric K Jones ");
output.println(85);
}
}
}
}
关键字try后声明和创建了一个资源。注意,资源放在括号中。资源必须是AutoCloseable的子类型,比如PrintWriter,具有一个close()方法。资源的声明和创建必须在同一行语句中,可以在括号中进行多个资源的声明和创建。紧接着资源声明的块中的语句使用资源。块结束后,资源的close()方法自动调用以关闭资源。使用try-with-recourse不仅可以避免错误,而且可以简化代码。
12.11.3 使用Scanner读数据
Scanner可以将输入分为由空白字符分隔的标记。为了能从键盘读取,需要为Sytem.in创建一个Scanner,如下所示:
Scanner input = new Scanner(System.in);
为了从文件中读取,为文件创建一个Scanner,如下所示:
Scanner input = new Scanner(new File(filename));
图总结出Scanner中的常见方法。

例子创建了一个Scanner的实例并从文件scores.txt中读取数据。
import java.io.File;
import java.util.Scanner;
public class ReadData {
public static void main(String[] args) throws Exception{
// TODO Auto-generated method stub
// Create a Scanner for the file
java.io.File file = new File("scores.txt");
// Create a Scanner for the file
Scanner input = new Scanner(file);
// Read data from a file
while (input.hasNext()) {
String firstName = input.next();
String mi = input.next();
String lastName = input.next();
int score = input.nextInt();
System.out.println(firstName + " " + mi + " " + lastName + " " + score);
}
// Close the file
input.close();
}
}
注意,new Scanner(String)为给定的字符串创建一个Scanner。为创建Scanner从文件中读取数据,必须使用构造方法new File(filename)利用java.io.File类创建File的一个实例,然后使用new Scanner(File)为文件创建一个Scanner。
调用构造方法new String(File)可能会抛出一个I/O异常。因此,main方法声明了throws Exception。
while循环中的每次迭代都从文本文件中读取名字、中间名、姓和分数。最后关闭文件。
没有不要关闭输入文件,但这样做是验证释放被文件占用的资源的好方法。可以使用try-with-resources语法重写该程序。
使用try-with-close重写后的程序
import java.io.File;
import java.util.Scanner;
public class ReadData {
public static void main(String[] args) throws Exception{
// TODO Auto-generated method stub
// Create a Scanner for the file
java.io.File file = new File("scores.txt");
try(
// Create a Scanner for the file
Scanner input = new Scanner(file);
)
{// Read data from a file
while (input.hasNext()) {
String firstName = input.next();
String mi = input.next();
String lastName = input.next();
int score = input.nextInt();
System.out.println(firstName + " " + mi + " " + lastName + " " + score);
}
}
// Close the file
// input.close();
}
}
12.11.4 Scanner如何工作
方法nextByte()、nextShort()、nextInt()、nextLong()、nextFloat()、nextDouble()和next()等都称为标记读取方法(token-reading method),因为它们会读取用分隔符分隔开的标记。默认情况下,分隔符是空格。可以使用useDlimiter(String regex)方法设置新的分隔符模式。
一个输入方法是如何工作的呢?一个标记读取方法首先跳过任意分隔符(默认情况下是空格),如何读取一个以分隔符结束的标记。然后,对应于nextByte()、nextShort()、nextInt()、nextLong()、nextFloat()和nextDouble(),这个标记就分别被自动地转换为一个byte、short、int、long、float或double型的值。对于next()方法而言无须做转换。如果标记和期望的类型不匹配,就会抛出一个运行异常java.util.InputMismatchException。
方法next()和nextLine()都会读取一个字符串。next()读取一个由分隔符分隔的字符串,但是nextLine()读取一个以换行符结束的行。
注意:行分隔符字符串是由系统定义的,在Windows平台上是\r\n,而在UNIX平台上是\n。为了得到特定平台上的行分隔符,使用
String lineScanner = System.getProperty("line.separetor");
如果从键盘输入,每行就以回车键(Enter key)结束,它对应于\n字符。
标记读取方法不能读取标记后面的分隔符。如果在标记读取方法之后调用nextLine(),该方法读取从这个分隔符开始,到这行的行分隔符结束的字符。这个行分隔符也被读取,但是它不是nextLine()返回的字符串部分。
假设一个名为test.txt的文本文件包含一行:
34 567
在执行完下面的代码之后,
Scanner input = new Scanner(new File("test.txt"));
int intValue = input.nextInt();
String line = input.nextLine();
intValue的值为34,而line包含的字符‘ ’,‘5’,‘6’,‘7’。
如果输入是从键盘输入,会是什么情况?假设下面的代码输入34,然后按回车键,接着输入567,然后再按回车键:
Scanner input = new Scanner(System.in);
int intValue = input.nextInt();
String line = input.nextLine();
将会得到intValue值是34,而line中是一个空的字符串。标记读取方法nextInt()读取34,然后在分隔符处停止,这里的分隔符是行分隔符(回车键)。nextLine()方法会在读取行分隔符之后结束,然后返回在行分隔符之前的字符串。因为在行分隔符之前没有字符,所以lline是空的。
可以使用Scanner类从文件或者键盘读取数据。也可以使用Scanner类从一个字符串扫描数据。例如,下面代码
Scanner input = new Scanner("13 14");
int sum = input.nextInt() + input.nextInt();
System.out.println("Sum is " + sum);
显示sum is 27
12.12 从Web上读取数据
如同从电脑中的文件中读取数据一样,也可以从Web上的文件中读取数据。
除开从电脑中的本地文件或者文件服务器中读取数据,如果知道Web上文件的URL(Uniform Resource Locator,统一资源定位器,即为Web上的文件提供唯一的地址),也可以从Web上访问数据。例如,www.google.com/index.html是位于Google Web服务器上的文件index.html的URL。当在一个Web浏览器中输入URL之后,Web服务器将数据传送给浏览器,浏览器则将数据渲染成图形。图演示了这个过程是如何工作的。

为了读取一个文件,首先要使用java.net.URL类的这个构造方法,为该文件创建一个URL对象。
public URL(String spec) throws MalformedURLException
例如,下面给出的语句为http://www.google.com/index.html创建一个URL对象。
try {
URL url = new URL("http://www.google.com/index.html");
}
catch (MalfromedURLException ex) {
ex.printStackTrace();
}
如果URL字符串出现语法错误的话,将会有一个MalformedURLException被抛出。例如,URL字符串 "http:www.google.com/index.html" 将会引起MalformedURLException运行错误,因为在冒号(:)之后要求有双斜杠(//)。注意,要让URL类来识别一个有效的URL,前缀http://是必需的。如果将第二行替换为下面代码,将会出错:
URL url = new URL("wwww.google.com/index.html");
创建一个URL对象后,可以使用URL类中定义的openStream()方法来打开输入流和利用输入流创建如下Scanner对象。
Scanner input = new Scanner(url.openStream());
现在可以从输入流中读取数据了,如同从本地文件中读取一样。