10.4.5 异常链

对于真实的企业级应用而言,常常有严格的分层关系,层与层之间有非常清晰的划分,上层功能的实现严格依赖于下层的API,也不会跨层访问。图10.5显示了这种具有分层结构应用的大致示意图。

这里有一张图片

对于一个采用图10.5所示结构的应用,当业务逻辑层访问持久层出现SQLException异常时,程序不应该把底层的SQLException异常传到用户界面,有如下两个原因。

  • 对于正常用户而言,他们不想看到底层SQLException异常,SQLException异常对他们使用该系统没有任何帮助。
  • 对于恶意用户而言,将SQLException异常暴露出来不安全。

异常转译

把底层的原始异常直接传给用户是一种不负责任的表现。通常的做法是:
程序先捕获原始异常,然后抛出一个新的业务异常,新的业务异常中包含了对用户的提示信息,这种处理方式被称为异常转译。假设程序需要实现工资计算的方法,则程序应该采用如下结构的代码来实现该方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public calSal() throws SalException
{
try
{
//实现结算工资的业务逻辑
...
}
catch(SQLException sqle)
{
//把原始异常记录下来,留给管理员
...
//下面异常中的 message就是对用户的提示
throw new SalException("访问底层数据库出现异常");
}
catch(Exception e)
{
//把原始异常记录下来,留给管理员
...
//下面异常中的message就是对用户的提示
throw new SalException("系统出现未知异常");
}
}

这种把原始异常信息隐藏起来,仅向上提供必要的异常提示信息的处理方式,可以保证底层异常不会扩散到表现层,可以避免向上暴露太多的实现细节,这完全符合面向对象的封装原则。

职责链模式

这种把捕获一个异常然后接着抛出另一个异常,并把原始异常信息保存下来的做法是一种典型的链式处理(23种设计模式之一:职责链模式),也被称为“异常链”。

JDK1.4以前,程序员必须自己编写代码来保持原始异常信息。从JDK1.4以后,所有Throwable的子类在构造器中都可以接收一个cause对象作为参数。这个cause就用来表示原始异常,这样可以把原始异常传递给新的异常,使得即使在当前位置创建并抛出了新的异常,你也能通过这个异常链追踪到异常最初发生的位置。例如希望通过上面的SalException去追踪到最原始的异常信息,则可以将该方法改写为如下形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public calSal() throws SalException
{
try
{
//实现结算工资的业务逻辑
...
}
catch(SQLException sqle)
{
//把原始异常记录下来,留给管理员
...
//下面异常中的sqle就是原始异常
throw new SalException(sqle);//代码1
}
catch(Exception e)
{
//把原始异常记录下来,留给管理员
...
//下面异常中的e就是原始异常
throw new SalException(e);//代码2
}
}

上面程序中的代码1代码2在创建SalException对象时,传入了一个Exception对象,而不是传入了一个String对象,这就需要SalException类有相应的构造器。从JDK1.4以后, Throwable基类已有了一个可以接收Exception参数的方法,所以可以采用如下代码来定义SalException类。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SalException extends Exception
{
public SalException(){}
public SalException(String msg)
{
super(msg);
}
// 创建一个可以接受Throwable参数的构造器
public SalException(Throwable t)
{
super(t);
}
}

创建了这个SalException业务异常类后,就可以用它来封装原始异常,从而实现对异常的链式处理

总结

要支持异常链,则自定义异常类需要创建三个构造器:

  • 一个无参构造器,
  • 一个带String参数的构造器,用来传递异常信息
  • 一个带Throwable参数的构造器,用来传入原始的异常

10.4.4 Java 7增强的throw语句

对于如下代码:

1
2
3
4
5
6
7
8
9
try
{
new FileOutputStream("a.txt");
}
catch (Exception ex)
{
ex.printStackTrace();
throw ex; // 1
}

上面代码片段中的1号代码再次抛出了捕获到的异常,但这个ex对象的情况比较特殊:

程序捕获该异常时,声明该异常的类型为Exception

但实际上try块中只调用了FileOutputStream构造器,这个构造器声明只是抛出了FileNotFoundException异常。
Java 7以前,Java编译器的处理“简单而粗暴”——由于在捕获该异常时声明ex的类型是Exception,因此Java编译器认为这段代码可能抛出Exception异常,所以包含这段代码的方法通常需要声明抛出Exception异常。例如如下main方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.*;

public class ThrowTest2
{
public static void main(String[] args)
// Java 6认为1号代码可能抛出Exception,
// 所以此处声明抛出Exception
throws Exception
{
try
{
new FileOutputStream("a.txt");
}
catch (Exception ex)
{
ex.printStackTrace();
throw ex; // 1
}
}
}

Java 7开始,Java编译器会执行更细致的检查,Java编译器会检查throw语句抛出异常的实际类型,这样编译器知道1号代码处实际上只可能抛出FileNotFoundException异常,因此在调用者main方法签名中只要声明抛出FileNotFoundException异常即可。即可以将代码改为如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.*;

public class ThrowTest2
{
public static void main(String[] args)
// Java 7会检查1号代码可能抛出异常的实际类型,
// 因此此处只需声明抛出FileNotFoundException即可。
throws FileNotFoundException
{
try
{
new FileOutputStream("a.txt");
}
catch (Exception ex)
{
ex.printStackTrace();
throw ex; // 1
}
}
}

总结

Java 7开始,Java编译器会检查throw语句抛出异常的实际类型,而不是直接使用catch块括号中声明异常形参的异常类型

10.4.3 catch和throw同时使用

前面介绍的异常处理方式有如下两种:

  • 在出现异常的方法内捕获并处理异常,该方法的调用者将不能再次捕获该异常。
  • 该方法签名中声明抛出该异常,将该异常完全交给方法调用者处理。

在实际应用中往往需要更复杂的处理方式——当一个异常出现时,单靠某个方法无法完全处理该异常,必须由几个方法协作才可完全处理该异常。也就是说,在异常出现的当前方法中,程序只对异常进行部分处理,还有些处理需要在该方法的调用者中才能完成,所以应该再次抛出异常,让该方法的调用者也能捕获到异常

为了实现这种通过多个方法协作处理同一个异常的情形,可以catch块中使用throw语句来完成。如下例子程序示范了这种catchthrow同时使用的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class AuctionTest
{
private double initPrice = 30.0;
// 因为该方法中显式抛出了AuctionException异常,
// 所以此处需要声明抛出AuctionException异常
public void bid(String bidPrice)
throws AuctionException
{
double d = 0.0;
try
{
d = Double.parseDouble(bidPrice);
}
catch (Exception e)
{
// 此处完成本方法中可以对异常执行的修复处理,
// 此处仅仅是在控制台打印异常跟踪栈信息。
e.printStackTrace();
// 再次抛出自定义异常
throw new AuctionException("竞拍价必须是数值,"
+ "不能包含其他字符!");
}
if (initPrice > d)
{
throw new AuctionException("竞拍价比起拍价低,"
+ "不允许竞拍!");
}
initPrice = d;
}
public static void main(String[] args)
{
AuctionTest at = new AuctionTest();
try
{
at.bid("df");
}
catch (AuctionException ae)
{
// 再次捕捉到bid方法中的异常。并对该异常进行处理
System.err.println(ae.getMessage());
}
}
}

上面程序中粗体字代码对应的catch块捕获到异常后,系统打印了该异常的跟踪栈信息,接着抛出一个AuctionException异常,通知该方法的调用者再次处理该AuctionException异常。所以程序中的main方法,也就是bid()方法调用者还可以再次捕获AuctionException异常,并将该异常的详细描述信息输出到标准错误输出。

catch块中使用throw的应用

这种catch块中使用throw的情况在大型企业级应用中非常常用。企业级应用对异常的处理通常分成两个部分:

  1. 应用后台需要通过日志来记录异常发生的详细情况;
  2. 应用还需要根据异常向应用使用者传达某种提示。
    在这种情形下,所有异常都需要两个方法共同完成,也就必须将catchthrow结合使用。

10.4.2 自定义异常类

在通常情况下,程序很少会自行抛出系统异常,因为异常的类名通常也包含了该异常的有用信息。所以在选择抛出异常时,应该选择合适的异常类,从而可以明确地描述该异常情况。在这种情形下,应用程序常常需要抛出自定义异常。

用户自定义异常都应该继承Exception基类,如果希望自定义Runtime异常,则应该继承RuntimeException基类。

定义异常类通常需要提供两个构造器

定义异常类时通常需要提供两个构造器

  • 一个是无参数的构造器;
  • 另一个是带个字符串参数的构造器,这个字符串将作为该异常对象的描述信息(也就是异常对象的getMessage()方法的返回值)。

下面例子程序创建了一个自定义异常类。

1
2
3
4
5
6
7
8
9
10
public class AuctionException extends Exception
{
// 1.提供无参数的构造器
public AuctionException(){} //1
// 2.提供带一个字符串参数的构造器
public AuctionException(String msg) //2
{
super(msg);
}
}

上面程序创建了AuctionException异常类,并为该异常类提供了两个构造器。尤其是2号代码部分创建的带一个字符串参数的构造器,其执行体也非常简单,仅通过super来调用父类的构造器,正是这行super调用可以将此字符串参数传给异常对象的message属性,该message属性就是该异常对象的详细描述信息。

如果需要自定义Runtime异常,只需将AuctionException.java程序中的Exception基类改为RuntimeException基类,其他地方无须修改。

在大部分情况下,创建自定义异常都可采用与AuctionException.java相似的代码完成只需改变AuctionException异常的类名即可,让该异常类的类名可以准确描述该异常

10.4 使用throw抛出异常

当程序出现错误时,系统会自动抛出异常;除此之外,Java也允许程序自行抛出异常,自行抛出异常使用throw语句来完成(注意此处的throw没有后面的s,与前面声明抛出的throws是有区别的)。

顺便说一下:java中以字母s结尾的一般用在声明中,如throwsextendsimplements

10.4.1 抛出异常

很多时候,系统是否要抛出异常,可能需要根据应用的业务需求来决定,如果程序中的数据、执行与既定的业务需求不符,这就是一种异常由于与业务需求不符而产生的异常,必须由程序员来决定抛出,系统无法抛出这种异常。

throw抛出的是一个异常对象

如果需要在程序中自行抛出异常,则应使用throw语句,throw语句可以单独使用,throw语句抛出的不是异常类,而是一个异常实例,而且每次只能抛出一个异常实例

1
throw ExceptionInstance;

java处理自定义异常和处理系统异常一样

Java运行时接收到开发者自行抛出的异常时,同样会中止当前的执行流,跳到该异常对应的catch块,由该catch块来处理该异常。

也就是说,不管是系统自动抛出的异常,还是程序员手动抛出的异常,Java运行时环境对异常的处理没有任何差别。

  • 如果throw语句抛出的异常是Checked异常,则该throw语句
    • 要么处于try块里,显式捕获该异常,
    • 要么放在一个带throws声明抛出的方法中,即把该异常交给该方法的调用者处理;
  • 如果throw语句抛出的异常是Runtime异常,则该语句无须放在try块里,也无须放在带throws声明抛出的方法中;程序既可以显式使用try-catch来捕获并处理该Runtime异常,也可以完全不理会该异常,把该异常交给该方法调用者处理。

程序示例

例如下面例子程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class ThrowTest
{
public static void main(String[] args)
{
try
{
// 调用声明抛出Checked异常的方法,要么显式捕获该异常
// 要么在main方法中再次声明抛出
throwChecked(-3);
}
catch (Exception e)
{
System.out.println(e.getMessage());
}
// 调用声明抛出Runtime异常的方法既可以显式捕获该异常,
// 也可不理会该异常
throwRuntime(3);
}
public static void throwChecked(int a)throws Exception
{
if (a > 0)
{
// 自行抛出Exception异常
// 该代码必须处于try块里,或处于带throws声明的方法中
throw new Exception("a的值大于0,不符合要求");
}
}
public static void throwRuntime(int a)
{
if (a > 0)
{
// 自行抛出RuntimeException异常,既可以显式捕获该异常
// 也可完全不理会该异常,把该异常交给该方法调用者处理
throw new RuntimeException("a的值大于0,不符合要求");
}
}
}

通过上面程序也可以看出,自行抛出Runtime异常比自行抛出Checked异常的灵活性更好。同样,抛出Checked异常则可以让编译器提醒程序员必须处理该异常。

10.3.2 方法重写时声明抛出异常的限制

使用throws声明抛出异常时有一个限制,就是方法重写时“两小”中的一条规则:子类方法声明抛出的异常类型应该是父类方法声明抛出的异常类型的子类或相同,子类方法声明抛出的异常不允许比父类方法声明抛出的异常多。

看如下程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.io.*;

public class OverrideThrows
{
public void test()throws IOException
{
FileInputStream fis = new FileInputStream("a.txt");
}
}
class Sub extends OverrideThrows
{
// 子类方法声明抛出了比父类方法更大的异常
// 所以下面方法出错
public void test()throws Exception
{
}
}

上面程序中Sub子类中的test方法声明抛出Exception,该Exception是其父类声明抛出异常IOException类的父类,这将导致程序无法通过编译。

Checked异常的缺点

使用Checked异常至少存在如下两大不便之处。

  • 对于程序中的Checked异常,Java要求必须显式捕获并处理该异常,或者显式声明抛出该异常这样就增加了编程复杂度。
  • 如果在方法签名中显式声明抛出Checked异常,将会导致方法签名与异常耦合,如果该方法是重写父类的方法,则该方法抛出的异常还会受到被重写方法所抛出异常的限制。

推荐使用Runtime异常

在大部分时候推荐使用Runtime异常,而不使用Checked异常。尤其当程序需要自行抛出异常时,使用Runtime异常将更加简洁。

不需要在方法签名中声明中抛出Runtime异常

当使用Runtime异常时,程序无须在方法中声明抛出Runtime异常,一旦发生了自定义错误,程序只管抛出Runtime异常即可。

Runtime异常可以使用try-catch捕获

如果程序需要在合适的地方捕获异常并对异常进行处理,则一样可以使用try-catch块来捕获Runtime异常。

使用Runtime异常是比较省事的方式,使用这种方式既可以享受“正常代码和错误处理代码分离”,“保证程序具有较好的健壮性”的优势,又可以避免因为使用Checked异常带来的编程烦琐性。因此,C#RubyPython等语言没有所谓的Checked异常,所有的异常都是Runtime异常。

Checked异常也有其优势Checked异常能在编译时提醒程序员代码可能存在的问题,提醒程序员必须注意处理该异常,或者声明该异常由该方法调用者来处理,从而可以避免程序员因为粗心而忘记处理该异常的错误。

10.3.1 使用throws声明抛出异常

使用throws声明抛出异常的思路是,当前方法不知道如何处理这种类型的异常,该异常应该由上级调用者处理;

如果main方法也不知道如何处理这种类型的异常,也可以使用throws声明抛出异常,该异常将交给JVM处理。

JVM对异常的处理方法是,打印异常的跟踪栈信息,并中止程序运行,这就是前面程序在遇到异常后自动结束的原因。

在方法签名中使用throws声明抛出异常

throws声明抛出只能在方法签名中使用,throws可以声明抛出多个异常类,多个异常类之间以逗号隔开。

throws声明抛出的语法格式仅跟在方法签名之后,如下例子程序使用了throws来声明抛出IOException异常,一旦使用throws语句声明抛出该异常,程序就无须使用try-catch块来捕获该异常了

1
2
3
4
5
6
7
8
9
10
import java.io.*;

public class ThrowsTest
{
public static void main(String[] args)
throws IOException
{
FileInputStream fis = new FileInputStream("a.txt");
}
}

上面程序声明不处理IOException异常,将该异常交给JVM处理,所以程序一旦遇到该异常,JVM就会打印该异常的跟踪栈信息,并结束程序。运行上面程序,效果如下所示:

1
2
3
4
5
6
Exception in thread "main" java.io.FileNotFoundException: a.txt (系统找不到指定的文件。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.<init>(FileInputStream.java:138)
at java.io.FileInputStream.<init>(FileInputStream.java:93)
at ThrowsTest.main(ThrowsTest.java:8)

如果某段代码中调用了一个带throws声明的方法,该方法声明抛出了Checked异常,则表明该方法希望它的调用者来处理该异常。也就是说,调用该方法时要么放在try块中显式捕获该异常,要么放在另一个带throws声明抛出的方法中。如下例子程序示范了这种用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.io.*;

public class ThrowsTest2
{
public static void main(String[] args) throws Exception
{
// 因为test()方法声明抛出IOException异常,
// 所以调用该方法的代码要么处于try...catch块中,
// 要么处于另一个带throws声明抛出的方法中。
test();
}
public static void test() throws IOException
{
// 因为FileInputStream的构造器声明抛出IOException异常,
// 所以调用FileInputStream的代码要么处于try...catch块中,
// 要么处于另一个带throws声明抛出的方法中。
FileInputStream fis = new FileInputStream("a.txt");
}
}

10.3 Checked异常和Runtime异常体系

java异常分类

Java的异常被分为两大类: Checked异常和Runtime异常(运行时异常)。

  • 所有的RuntimeException类及其子类的实例被称为Runtime异常;
  • 不是RuntimeException类及其子类的异常实例,则被称为Checked异常。

Java程序必须显示处理Checked异常

Java认为Checked异常都是可以被处理(修复)的异常,所以Java程序必须显式处理Checked异常。如果程序没有处理Checked异常,该程序在编译时就会发生错误,无法通过编译

Checked异常处理的两种方式

对于Checked异常的处理方式有如下两种。

  • 如果当前方法明确知道如何处理该异常,那么程序应该使用try...catch块来捕获该异常,然后在对应的catch块中修复该异常。
  • 当前方法不知道如何处理这种异常,应该在定义该方法时声明抛出该异常。

Runtime异常不需要显示处理

Runtime异常则更加灵活, Runtime异常无须显式声明抛出,如果程序需要捕获Runtime异常,也可以使用try...catch块来实现。

总结

  • Checked必须显示处理,处理时可以使用try...catch语句,也可以直接抛出
  • Runtime不需要显示处理,如有必要也可以使用try...catch捕获Runtime异常。

Java7自动关闭资源的try语句

Java 7增强了try语句的功能:它允许在try关键字后紧跟一对圆括号,圆括号可以声明、初始化一个或多个资源。此处的资源指的是那些必须在程序结束时显式关闭的资源(比如数据库连接网络连接等),try语句在该语句结束时 自动关闭 这些资源

什么样的资源可以让try语句自动关闭

为了保证try语句可以正常关闭资源,这些资源实现类必须实现AutoCloseableCloseable接口,实现这两个接口就必须实现close()方法。

Closeable和AutoCloseable的区别

CloseableAutoCloseable的子接口,可以被自动关闭的资源类要么实现AutoCloseable接口,要么实现Closeable接口。

  • Closeable接口里的close()方法声明抛出了IOException,因此它的实现类在实现close()方法时只能声明抛出IOException或其子类
  • AutoCloseable接口里的close()方法声明抛出了Exception,因此它的实现类在实现close()方法时可以声明拋出任何异常

程序示例

下面程序示范了如何使用自动关闭资源的try语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.*;

public class AutoCloseTest
{
public static void main(String[] args)
throws IOException
{
try (
// 声明、初始化两个可关闭的资源
// try语句会自动关闭这两个资源。
BufferedReader br = new BufferedReader(
new FileReader("AutoCloseTest.java"));
PrintStream ps = new PrintStream(new
FileOutputStream("a.txt"));
)
{
// 使用两个资源
System.out.println(br.readLine());
ps.println("庄生晓梦迷蝴蝶");
}
}
}

上面程序中try后面圆括号中的代码分别声明、初始化了两个IO流,由于BufferedReaderPrintStream都实现了Closeable接口,而且它们放在try语句中声明、初始化,所以try语句会自动关闭它们。因此上面程序是安全的。

自动关闭资源的try语句可以没有catch块也可以没有finally块

自动关闭资源的try语句相当于包含了隐式的finally(这个finally块用于关闭资源),因此这个try语句可以既没有catch块,也没有finally块

Java 7几乎所有的资源类都可以自动关闭

Java 7几乎把所有的“资源类”(包括文件IO的各种类、JDBC编程的ConnectionStatement等接口)进行了改写,改写后资源类都实现了AutoCloseableCloseable接口。

如果程序需要,自动关闭资源的try语句后也可以带多个catch块和一个finally块。

Java9对自动关闭资源的try语句的增强

Java9再次增强了这种try语句,Java9不要求在try后的圆括号内声明并创建资源。

Java9只需要将资源的引用变量放在try后的括号里即可

Java9允许将需要自动关闭的资源的引用变量放在try后的圆括号内,只要这些引用变量有final修饰或者是有效的final( effectively final)即可

什么是有效的final

只要不对该变量重新赋值,按该变量就是有效的final。

程序示例

上面程序在Java9中可改写为如下形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.*;

public class AutoCloseTest2 {
public static void main(String[] args) throws IOException {
// 有final修饰的资源
final BufferedReader br = new BufferedReader(new FileReader("AutoCloseTest.java"));
// 没有显式使用final修饰,但只要不对该变量重新赋值,按该变量就是有效的final
PrintStream ps = new PrintStream(new FileOutputStream("a.txt"));
// 只要将两个资源放在try后的圆括号内即可
try (br; ps) {
// 使用两个资源
System.out.println(br.readLine());
ps.println("庄生晓梦迷蝴蝶");
}
}
}

可以看到,相较于java7try后面的括号更加精简了,看起来更加舒服。

总结

  • java7开始支持自动关闭资源
  • java7需要在try后的括号里创建需要自动关闭的资源。
  • java9只需要将需要自动关闭的资源的引用变量放到try后面的括号里即可。

10.2.6 异常处理的嵌套

什么是异常处理的嵌套

正如Finally.Test.java程序所示, finally块中也包含了一个完整的异常处理流程,这种在try块、 catch块或finally块中包含完整的异常处理流程的情形被称为异常处理的嵌套

异常处理流程代码可以放在任何能放可执行性代码的地方,因此完整的异常处理流程既可放在try块里,也可放在catch块里,还可放在finally块里

异常处理嵌套的深度没有很明确的限制,但通常没有必要使用超过两层的嵌套异常处理,层次太深的嵌套异常处理没有太大必要,而且导致程序可读性降低。

总结

异常处理可以嵌套