一、前言
只要写过Java代码,基本上都会遇到异常,由于以前学习的不够系统,所以趁现在有时间,再来重新回顾及梳理下Java的异常处理。
二、异常处理
1. 概念
当一个用户在使用我们的程序期间,如果由于程序的错误或一些外部环境的影响造成用户数据的丢失,用户可能就不会再使用这个程序了,为了避免这种事情的发生,一般我们的程序应该能做到如下几点:
- 向用户通报错误;
- 保存所有的工作结果;
- 允许用户以妥善的形式退出程序;
针对这种异常情况,Java中使用一种称为异常处理的错误捕获机制处理。所谓异常处理,就是将控制权从错误产生的地方转移给能够处理这种情况的异常处理器(Exception Handler),其实处理器的目的就是让我们以一种友好的交互方式将异常展示给用户。
2. 异常分类
在Java语言中,所有的异常都是继承自Throwable这个基础类,我们可以看一个简单示意图:
可以看到,在Throwable的直属子类中又分为了两部分:Error和Exception。
Error 表示Java运行时系统的内部错误和资源耗尽错误,应用程序不应该抛出这种类型的错误,如果真的出现了这样的内部错误,处理通告给用户,并尽力使程序安全的终止之外,我们就再也无能为力了,不过这种情况极少出现;
而我们所关注的重点,也就是Exception结构,而Exception 的实现类又分为两部分:继承自RuntimeException的异常和其他异常。而划分这两种分类的规则是:
由程序错误导致的异常属于RuntimeException,而程序本身没有问题,但由于像I/O 错误这类问题导致的异常属于其他异常。
继承自RuntimeException的异常,通常包含以下几种情况:
- 错误的类型转换;
- 数组访问越界;
- 访问的时候产生空指针等;
而不是继承自RuntimeException的异常则包括:
- 试图在文件尾部后面读取数据;
- 打开一个不存在的文件;
- 根据类的名称查找类,但该类不存在等情况;
所以说,有这么一句话还是很有道理的:
如果出现了RuntimeException异常,那么就一定是你的问题。
3 已检查异常和未检查异常
Java语言规范将继承自Error类或者RuntimeException类的所有异常称为未检查异常(unchecked),而所有其他的异常则称为已检查异常(checked),而编译器则会检查是否为所有的已检查异常提供了异常处理器。我们先来看下已检查异常。
3.1 已检查异常
已检查异常也就是说,一个方法不仅需要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误,比如,一段读取问文件的代码 知道有可能读取的文件不存在,因此在读取的时候就需要抛出FileNotFoundException。方法应该在其首部声明所有可能抛出的异常,这样就可以从首部反映出这个方法可能抛出哪类已检查异常。
比如FileInputStream的构造方法:
public FileInputStream(String name) throws FileNotFoundException
针对我们定义的有可能被他人调用的方法,我们应该根据异常规范,在方法的首部声明这个方法可能抛出的已检查异常,如果有多个的话,需要使用逗号分开:
public static void callable() throws ExecutionException, InterruptedException, TimeoutException
而通常情况下,有两种情况需要我们抛出异常:
- 调用一个抛出已检查异常的方法,例如FileInputStream构造器;
- 程序运行中出现错误,并且利用throw 语句抛出一个已检查异常;
3.2 未检查异常
对未检查异常,我们不用像已检查异常那样在方法的首部声明可能抛出的异常,因为运行时异常完全是可控的,就是说,我们应该通过程序尽量避免未检查异常的发生。而如果是Error异常的话,自然不用手动抛出,任何代码都有抛出Error异常的风险,而我们是无法控制该类异常的。
所以说,一个方法需要声明所有可能抛出的已检查异常,而未检查异常要么不可控制(Error),要么就应该避免发生(RuntimeException)。
可能需要注意一点,就是子类继承父类方法问题:
子类方法中声明的异常不能比父类的范围更大,也就是说,子类可以抛出更特定的异常或者不抛出异常;而如果父类中没有抛出任何异常,那么子类也不能抛出任何已检查异常。
当然,除了抛出异常,还可以捕获异常,在下文我们会来介绍异常的捕获。
3. 抛出异常
我们是通过 throw
关键字来抛出异常的,比如说,读取文件的时候,如果文件内容不包含我们所需要的,那我们可以手动抛出一个IOException;再比如,如果某一个对象不能为空,那么我们可以抛出一个继承自RuntimeException的自定义异常:
public static void main(String[] args) throws IOException {
...
String content = "";
if (!content.contains("hello")) {
throw new IOException();
}
}
public static void main(String[] args) {
String content = null;
if (content == null) {
throw new RuntimeException();
}
}
相应的,如果是抛出已检查异常,需要在方法首部进行声明该异常。
4. 自定义异常
如果已有的异常满足不了我们的需求的话,我们可以选择自定义异常,一般情况下可以选择继承自Exception或者Exception子类的类。而习惯上,该类应该至少包含两个构造器,一个是默认的构造器,另一个是带有详细描述信息的构造器:
public class OrderException extends Exception {
public OrderException() {
}
public OrderException(String message) {
super(message);
}
}
同样,我们可以选择自定义的异常时check exception或者unchek exception。
5. 捕获异常
5.1 捕获单个异常
前面我们了解了抛出异常,抛出异常其实很简单,但有些情况下我们需要把异常给捕获,比如说直接展示给用户的时候,我们需要把异常以一种更直观更优雅的方式展示给用户。这时候我们就可以通过 try catch finally语句块来处理。
- 对于资源的关闭,在JDK1.7 之前,我们一般都是通过在finally块中手动关闭,而JDK7引入了
try-with-resources
代码块,也就是说 try可以添加资源,这样该资源会在try语句介绍的时候自动关闭(多个按顺序),不用再手动在finally块中进行关闭;而如果是多个资源的话,同样和普通的代码一样,使用分号进行分割;但是需要注意的是,在try里面的资源,需要实现了java.lang.AutoCloseable接口;
static String readFirstLineFromFileWithFinallyBlock(String path)
throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
if (br != null) br.close();
}
}
static String readFirstLineFromFile(String path) throws IOException {
try (BufferedReader br =
new BufferedReader(new FileReader(path))) {
return br.readLine();
}
}
针对异常的捕获,通常,最好的选择是什么也不做,而是将异常传递给调用者:
如果read方法出现了异常,就让read方法的调用者去操心!但如果采用这种处理方法,就必须声明这个方法可能会抛出的异常。
针对捕获异常,同样需要注意的是继承问题,比如说,如果父类的方法没有抛出异常,那么子类的方法就必须捕获方法中出现的每一个已检查异常,不允许在子类的throws 中出现超过父类方法所列出的异常类范围。
至于为什么try里面的资源要实现java.lang.AutoCloseable接口,因为该接口提供了一个close方法,try块正常退出或者发生异常退出时,都会自动调用res.close(),相当于使用了finally块:
void close() throws Exception;
不过可能还需要注意另一个接口:Closeable
,该接口实现自AutoCloseable
,也包含一个close方法,不过,这个方法声明抛出的异常是IOException。
5.2 捕获多个异常
在一个try语句块中可以使用多个catch捕获多个异常,但JDK7之后,同一个catch子句中可以捕获多个异常类型:
// before JDK 7
try {
...
} catch (FileNotFoundException ex) {
...
} catch (Exception e) {
...
}
// after JDK 7
try {
...
} catch (FileNotFoundException | UnknownHostException e) {
...
} catch (Exception ex) {
...
}
- 针对第一种多个catch的情况,捕获的时候注意异常的包含关系,异常范围越大的越往后,比如Exception应该放到最后;
- 第二种情况,使用 | 捕获多个异常的时候,捕获的异常类型彼此之间不能存在继承关系,并且这种情况下,异常变量e隐含为final类型,所以不能对该类型执行赋值等操作;
6. 再次抛出异常于异常链
在catch子句中可以抛出一个异常,这样做的目的是改变异常的类型,比如说方法中发生了一个已检查异常但该方法不允许抛出异常,所以我们可以捕获后将其包装成一个运行时异常再抛出,如:
try {
} catch (SQLException e) {
throw new MyException("database error:" + e.getMessage());
}
但还有一种有好的处理方法,并且将原始异常信息设置为新异常的信息:
try {
} catch (SQLException e) {
Throwable se = new MyException("database error");
se.initCause(e);
throw se;
}
这样当捕获到异常时,就可以使用下面这条语句重新得到原始异常:
Throwable e = se.getCause();
官方建议使用这种包装技术,这样可以让用户抛出子系统中的高级异常,而不会丢失原始异常的细节。
有一点可能需要注意,就是下面这种:
public static void main(String[] args) throws FileNotFoundException {
try {
InputStream inputStream = new FileInputStream("");
...
} catch (Exception e) {
// 日志记录
throw e;
}
}
首先,try块里面有已检查异常FileNotFoundException,这时候我们捕获Exception异常之后,只想做个记录,然后再直接抛出,如果try块里只有一个已检查异常,并且在catch中该异常未发生任何变化,这时候我们在方法首部声明的要抛出的异常可以是try块里唯一的一个已检查类型FileNotFoundException,因为编译器会跟踪try块里的异常(注意JDK7之后)。
7. finally块
finally块通常用来执行一些资源的关闭,锁的释放等操作,因为无论是否发生异常,finall块中的代码都会被执行。这里官方建议使用嵌套的try/catch 和try/finally语句块,比如:
InputStream inputStream = null;
try {
try {
new FileInputStream("");
// do something
} finally {
inputStream.close();
}
} catch (Exception e) {
// 日志记录
}
内层的try/finally 只有一个职责,就是确保关闭输入流。外出的try/catch就是捕获出现的异常,这种设计方式不仅清楚,而且还有一个功能就是会捕获finally 子句中出现的异常。
而当try/catch/finally块包含return语句时,是一件比较有意思的事,我们放到最后借助例子来了解。
8. 堆栈跟踪
堆栈跟踪(stack trace)是一个方法调用过程的列表,它包含了程序执行过程中方法调用的特定位置,当Java程序正常终止,而没有捕获异常时,这个列表就会展示出来,比如:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at jdk8.thread.CallableTest.test(CallableTest.java:21)
at jdk8.thread.CallableTest.main(CallableTest.java:17)
我们可以调用Throwable类的printStackTrace
方法访问堆栈跟踪的文本描述信息,另一种更灵活的办法是使用getStackTrace
方法,它会得到一个StackTraceElement对象数组:
Throwable throwable = new Throwable();
StackTraceElement[] stackTraceElements = throwable.getStackTrace();
for (StackTraceElement stackTraceElement : stackTraceElements) {
// className, fileName, methodName, lineName
stackTraceElement.getClassName();
stackTraceElement.getFileName();
stackTraceElement.getMethodName();
stackTraceElement.getLineNumber();
stackTraceElement.toString();
}
StackTraceElement 类能够获得类名(包含包路径),文件名,方法名及代码行号,而toString方法则会产生一个格式化的字符串,就是上面例子中的一行异常信息。
而静态的Thread.getAllStackTrace
方法则可以产生所有线程的堆栈跟踪:
Map map = Thread.getAllStackTraces();
for (Thread t : map.keySet()) {
StackTraceElement[] stackTraceElements = map.get(t);
// doSomething
}
三、总结
1. 注意事项
- 异常处理机制的一个目标,将正常处理与错误处理分开;比如说展示给用户的错误信息尽量不要和程序混淆到一块,可以使用枚举或者配置文件统一管理错误信息;
- 只在有必要的时候才使用异常,不要用异常来控制程序的流程,因为异常使用过多会影响程序的性能,能用代码解决的异常就不要抛出;
- 不要只抛出RuntimeException,应该寻找更加适当的子类或创建自己的异常类;不要只捕获Throwable异常,会使代码不太容易维护;
- 不要使用空的catch块,如果我们想忽略掉异常,可以在catch块中添加日志,这样假如这里出现了问题可以及时排查到;
- 避免多次在日志信息中记录同一个异常,一般情况下异常都是往上层抛出,如果每次抛出都log一下的话,则不利于我们定位到问题所在;
- 异常的抛出与捕获可以遵循“早抛出,晚捕获”这种规则;
2. 异常所能解决的问题
我们之所以使用异常,在于异常可以解决如下问题:
- 出了什么问题;
- 在哪里出的问题;
- 为什么会出问题;
在有效使用异常的情况下,异常类型回答了“什么错误”被抛出,异常堆栈跟踪回答了“在哪里”抛出,异常信息回答了“为什么”会抛出,所以我们在进行抛出或捕获异常的时候,要能准备的解决这些问题。
3. try/catch/finally中包含return的执行顺序问题
当try/catch/finally中包含return语句的时候,很有意思。这个问题以前专门测试过,不过后来忘记记录到哪了,这次记录下,目前可以分为以下几种情况:
3.1 try有return,那么finally里的代码会不会执行,在try的return前还是后执行?
public static void main(String[] args) {
System.out.println(returnTest());
}
public static boolean returnTest() {
try {
System.out.println("try块");
return true;
} finally {
System.out.println("finally块");
}
}
/*
try块
finally块
true
*/
这个问题就比较简单了,finally里的代码一定会执行,并且是在try的return前执行;
3.2 try有return,finally也有return,那么执行结果是?
// main方法省略
public static boolean returnTest() {
try {
System.out.println("try块");
return true;
} finally {
System.out.println("finally块");
return false;
}
}
/*
try块
finally块
false
*/
首先,finally块代码一定会执行,可以看到,finally里的return覆盖掉了try里的return;
3.3 try后面有catch,catch里也有return,怎么执行?
// main方法省略
public static boolean returnTest() {
try {
System.out.println("try块");
int temp = 23 / 0;
return true;
} catch (Exception e) {
System.out.println("catch块");
return false;
} finally {
System.out.println("finally块");
}
}
/*
try块
catch块
finally块
false
*/
可以看到,在流程都执行完成之后,catch块中的return覆盖了try块的return;接下来如果给finally也加上return的话,可以看下执行结果:
// main方法省略
public static boolean returnTest() {
try {
System.out.println("try块");
int temp = 23 / 0;
return true;
} catch (Exception e) {
System.out.println("catch块");
return true;
} finally {
System.out.println("finally块");
return false;
}
}
/*
try块
catch块
finally块
false
*/
可以看到,首先finally里的代码一定会执行,并且如果finally里没有return语句,而catch里有return语句,则catch里的return语句会覆盖掉try的;而如果finally里也有return语句,则finally里的return语句会覆盖掉前面的。
3.4 数字相加问题
直接看代码:
public static int returnTest() {
int temp = 23;
try {
System.out.println("try块");
return temp += 88;
} catch (Exception e) {
System.out.println("catch块");
} finally {
if (temp > 25) {
System.out.println("temp>25:" + temp);
}
System.out.println("finally块");
}
return temp;
}
大家可以先猜一下结果,然后再看结果:
try块
temp>25:111
finally块
111
从表面上看,temp的值先变为了111,然后再执行的finally,那是不是try里先return了,再执行的finally呢?其实,并不是try语句中return执行完之后才执行的finally,而是在执行return temp+=88
时,分成了两步,先temp+=88;
再return temp;
,如果我们将return temp;
放到System.out.println("finally块");
后面,则输出结果不变;我们来修改下finally语句:
} finally {
if (temp > 25) {
System.out.println("temp>25:" + temp);
}
System.out.println("finally块");
temp = 100;
}
因为finally没有return,那么不管你是不是改变了要返回的那个变量,返回的值依然不变。
3.5 总结
可以来简单总结下:
- 当包含finally语句时,无论try/catch有没有return语句,finally块中的代码一定会执行;
- 当finally块中也包含return语句时,finally块的return会覆盖掉try/catch中的return语句;
本文参考自:
《Java核心技术 卷I》
海子 - Java异常处理和设计
知乎 - Java中如何优雅的处理异常
IBM - Java 异常处理的误区和经验总结
Java 8 Exceptions