Java的异常算是Java语言的一个特色了。也是在日常编码中会经常使用到的东西。但你真的了解异常吗?
这里有一些关于异常的经典面试题:
-
Java与异常相关的类结构和主要继承关系是怎样的?
-
Java7在关于异常的语法上做了什么改进?
-
什么是运行时异常和声明式异常?它们有什么区别?
-
什么是“异常丢失(异常覆盖)”问题?
-
什么是异常链?
-
什么是返回值覆盖?
-
编写异常时的一些最佳实践?
如果以上问题的答案你都能了然与胸,那么恭喜你,已经很熟悉Java异常这一块了。
如果一些问题还弄不清楚?没关系,看完这篇文章就可以了。
异常的层次结构
先上图
抛开下面那些异常不谈,我们的关注点可能主要在四个类上:
-
Throwable
-
Error
-
Exception
-
RuntimeException
其中,因为Error
代表“错误”,多为比较严重的错误。如果你了解JVM,应该对OutOfMemoryError
和StackOverflowError
这两个类比较熟悉。
一般我们在写代码时,可能用的比较多的是Exception
类和RuntimeException
类。
那么,到底是继承Exception类好还是继承RuntimeException类好呢?
后面我们在“编写异常的最佳实践”小节会讲到。
Java7与异常
Java7对异常做了两个改进。第一个是try-with-resources
,第二个是catch多个异常
。
try-with-resources
所谓的try-with-resources
是个语法糖。实际上就是自动调用资源的close()
函数。和Python里的with
语句差不多。
不使用try-with-resources,我们在使用io等资源对象时,通常是这样写的:
String getReadLine() throws IOException { BufferedReader br = new BufferedReader(fileReader); try { return br.readLine(); } finally { if (br != null) br.close(); } }
使用try-with-recources的写法:
String getReadLine() throws IOException { try (BufferedReader br = new BufferedReader(fileReader)) { return br.readLine(); } }
显然,编绎器自动在try-with-resources后面增加了判断对象是否为null
,如果不为null,则调用close()
函数的的字节码。
只有实现了java.lang.AutoCloseable
接口,或者java.io.Closable
(实际上继随自java.lang.AutoCloseable)接口的对象,才会自动调用其close()
函数。
有点不同的是java.io.Closable
要求一实现者保证close函数可以被重复调用。而AutoCloseable
的close()函数则不要求是幂等的。具体可以参考Javadoc。
但是,需要注意的是try-with-resources会出现异常覆盖的问题,也就是说catch块抛出的异常可能会被调用close()方法时抛出的异常覆盖掉。我们会在下面的小节讲到异常覆盖。
多异常捕捉
public static void main(String[] args) { try { int a = Integer.parseInt(args[0]); int b = Integer.parseInt(args[1]); int c = a / b; System.out.println("result is:" + c); } catch (IndexOutOfBoundsException | NumberFormatException | ArithmeticException ie) { System.out.println("发生了以上三个异常之一。"); ie.getMessage(); // 捕捉多异常时,异常变量默认有final修饰, // 所以下面代码有错: // ie = new ArithmeticException("test"); } }
需要注意的是,在catch
子句中声明捕获的这些异常类中,不能出现重复的类型,也不允许其中的某个异常是另外一个异常的子类,否则会出现编译错误。如果在catch子句中声明了多个异常类,那么异常参数的具体类型是所有这些异常类型的最小上界。
关于一个catch子句中的异常类型不能出现其中一个是另外一个的子类的情况,实际上涉及捕获多个异常的内部实现方式。比如:
public void testSequence() { try { Integer.parseInt("Hello"); } catch (NumberFormatException | RuntimeException e){} }
比如上面这段代码,虽然NumberFormatException是RuntimeException的子类,但是这段代码是可以通过编译的。但是,如果把catch子句中两个异常的声明位置调换一下,就会出现在编译错误。如例:
public void testSequenceError() { try { Integer.parseInt("Hello"); } catch (RuntimeException | NumberFormatException e) {} }
原因在于,编译器的做法其实是把捕获多个异常的catch子句转换成了多个catch子句,在每个catch子句中捕获一个异常。上面这段代码相当于:
public void testSequenceError() { try { Integer.parseInt("Hello"); } catch (RuntimeException e) { } catch (NumberFormatException e) { } }
Suppressed
如果catch块和finally块都抛出了异常怎么办?请看下下小节分析。
运行时异常和声明式异常
所谓运行时异常指的是RuntimeException
,你不用去显式的捕捉一个运行时异常,也不用在方法上声明。
反之,如果你的异常只是一个Exception
,它就需要显式去捕捉。
示例代码
void test() { hasRuntimeException(); try { hasException(); } catch (Exception e) { e.printStackTrace(); } } void hasException() throws Exception { throw new Exception("exception"); } void hasRuntimeException() { throw new RuntimeException("runtime"); }
虽然从异常的结构图我们可以看到,RuntimeException
继承自Exception
。但Java会“特殊对待”运行时异常。所以如果你的程序里面需要这类异常时,可以继承RuntimeException。
而且如果不是明确要求要把异常交给上层去捕获处理的话,我们建议是优先使用运行时异常,因为它会让你的代码更加简洁。
什么是异常覆盖
正如我们前面提到的,在finally块调用资源的close()方法时,是有可能抛出异常的。与此同时我们可能在catch块抛出了另一个异常。那么catch块抛出的异常就会被finally块的异常“吃掉”。
看看这段代码,调用test()方法会输出什么?
void test() { try { overrideException(); } catch (Exception e) { System.out.println(e.getMessage()); } } void overrideException() throws Exception { try { throw new Exception("A"); } catch (Exception e) { throw new Exception("B"); } finally { throw new Exception("C"); } }
会输出C。可以看到,在catch块的B被吃掉了。
JDK提供了Suppressed的两个方法来解决这个问题:
// 调用test会输出: // C // A void test() { try { overrideException(); } catch (Exception e) { System.out.println(e.getMessage()); Arrays.stream(e.getSuppressed()) .map(Throwable::getMessage) .forEach(System.out::println); } } void overrideException() throws Exception { Exception catchException = null; try { throw new Exception("A"); } catch (Exception e) { catchException = e; } finally { Exception exception = new Exception("C"); exception.addSuppressed(catchException); throw exception; } }
异常链
你可以在抛出一个新异常的时候,使用initCause
方法,指出这个异常是由哪个异常导致的,最终形成一条异常链。
initCause()
这个方法就是对异常来进行包装的,目的就是为了出了问题的时候能够追根究底。因为一个项目,越往底层,可能抛出的异常类型会用很多,如果你在上层想要处理这些异常,你就需要挨个的写很多catch语句块来捕捉异常,这样是很麻烦的。如果我们对底层抛出的异常捕获后,抛出一个新的统一的异常,会避免这个问题。但是直接抛出一个新的异常,会让最原始的异常信息丢失,这样不利于排查问题。
举个例子,在底层会出现一个A异常,然后在中间代码层捕获A异常,对上层抛出一个B异常。如果在中间代码层不对A进行包装,在上层代码捕捉到B异常后就不知道为什么会导致B异常的发生,但是包装以后我们就可以用getCause()
方法获得原始的A异常。这对追查BUG是很有利的。
class A { try { .... } catch(AException a) { throw new BException(); } } class B { try { ... } catch(BException b) { //这时候你需要去看b异常式什么问题导致的,你在A类里面 //没有对AException进行包装,所以你无法知道是A导致的B } }
如果包装以后:
class A { try { ... } catch(AException a) { BException b = new BEexception(); b.initCause(a); throw b; } } class B { try { ... } catch(BException b) { //什么导致了b呢? b.getCause();//得到导致B异常的原始异常 } }
返回值覆盖
跟之前的“异常覆盖”问题类似,finally块会覆盖掉try和catch块的返回值。
所以最佳实践是不要在finaly
块使用return
!!!
最佳实践?
-
如果可以,尽量使用RuntimeException
-
尽量不要在finally块抛出异常或者返回值
-
尽量使用Java7对异常的新语法
-
try-catch块可以单独抽到一个方法里面去,让你的代码更简洁 —— 参考《代码整洁之道》第7章
-
记录异常日志可以结合log和printStackTrace
未经允许请勿转载:程序喵 » 如何善用Java异常?