文章目录
1. Java异常的介绍
1.1 Exception与Error
提到Exception时不得不说到Error,因为从继承方面来说,Error和Exception是亲兄弟——都是Throwable的子类。所以从基本概念相似,程序出了问题就抛出。但Error更为严重,它是由 JVM 所侦测到的无法预期的错误,由于属于 JVM 层次的严重错误,导致程序无法继续执行,因此这是不可捕捉到的,无法采取任何恢复的操作,顶多只能显示错误信息;而Exception是指严重程度较轻的程序问题,更容易被后续代码处理和修复的,有些需要通过try catch代码处理,有些不建议通过try catch代码处理。
比较常见的Error有VirtualMachineError、ThreadDeath、IOError等。详见Error API
1.2 Exception的分类介绍
首先介绍一下Exception。Exception依据功能定义可分为编译时异常和运行时异常,而根据使用规则区分,运行时异常(RuntimeException)又可以称为Unchecked异常,编译时异常则成为Checked异常。
所谓运行时异常(Unchecked),即程序在运行时出现操作性错误。所以这类错误一定是开发人员引起的,比如遇到类转换错误ClassCastException、数组越界IndexOutOfBoundsException、空指针NullPointerException等程序无法修复的问题。所以这类错误不需要catch的处理,直接由JVM接管,大概率会对程序进行终止报错操作;(RuntimeException的具体派生类可以参考RuntimeException API)之所以运行时异常被称作Unchecked,是因为该类异常在编译过程中,编译器不会检查开发者是否对其进行了处理。实际上,在1.1中提到的Error也可被视作unchecked exception。
对于编译时异常(Checked),实际上除了RuntimeException以外的异常都为编译时异常。这类异常大多数只是局域性报错而不影响全局,且可以经过程序调整得到修复。因此这类异常是编译器要求开发者对其进行catch操作的,否则编译无法通过。比如常见的IOException、SQLException、以及开发者自定义的异常。
异常的分类关系参考图1.1。图中涉及到的具体异常名为常见举例。
2. Exception的正确打开方式
在java应用中,异常的处理机制分为抛出异常和捕获异常。
抛出异常:当一个方法出现错误而引发异常时,该方法会将该异常类型以及异常出现时的程序状态信息封装为异常对象,并交给本应用。运行时,该应用将寻找处理异常的代码并执行。任何代码都可以通过throw关键词抛出异常,比如java源代码抛出异常、自己编写的代码抛出异常等。
捕获异常:一旦方法抛出异常,系统自动根据该异常对象寻找合适异常处理器(Exception Handler)来处理该异常。所谓合适类型的异常处理器指的是异常对象类型和异常处理器类型一致。
2.1 如何抛出Exception
异常一般有两种抛出的情况:系统自动抛出和主动抛出。
2.1.1 系统自动抛出
系统自动抛出的异常主要是RuntimeException。比如发生空指针、数组越界、计算时除数为0等逻辑、转换、内存访问上的错误时,系统会自动抛出。因此这类异常虽不需要开发者去自行判断抛出,但是应当尽量避免。
2.1.2 主动抛出
对于Checked异常一般是开发者使用throw主动抛出。当方法检查到某些不正确操作时,将错误信息封装到合适的Exception对象中,使用throw将其抛出即可。例:
if (count < MAX_LIMIT) {
throw new IllegalArgumentException("This figure is too large.");
}
这样Exception会被抛向上一层调用者处,且throw后面的语句均不会被执行。当一个方法中有可能抛出异常时,需要在方法中用throws关键词声明, 例如前文提到的MediaPlayer的源码setDataSource中是这样声明的:
public void setDataSource(@NonNull Context context, @NonNull Uri uri) throws IOException, IllegalArgumentException, IllegalStateException, SecurityException
这样编译器就会检查到该方法是有可能抛出异常的,因而强制让调用者对该异常进行处理。
2.2 Exception的处理
对于异常的处理,除了捕获以外,还可以向上层调用者抛出,即可能异常处不做任何处理,而是按照2.1.2中所描述,在方法外声明throws异常,即使调用者来处理该异常。
然后异常最终还是需要处理的。对于所有的checked Exception,最常见的处理方式就是try-catch语句。但实际上try-catch的使用上也需要对不同情况加以区分,否则会引发不可预料的结果。比如在过去的开发过程中曾见到同事对MediaPlayer中setDataSource以及prepareAsync的处理,就是catch(Exception exception),并在语句内部打印信息然后删除当前的媒体文件。而在setDataSource接口中共可以抛出IOException, IllegalArgumentException, IllegalStateException, SecurityException四个异常,这样操作就是对多个接口所抛出的多种异常统一处理,显然是不正确的。导致的结果就是在经过长时间monkey test以后,媒体文件被删光了。
2.2.1 用于Exception捕获的try catch语句
在捕获异常时有几个重要的原则需要遵守:
1) 针对不同异常分别处理;
针对这条内容上文中我们已经举例了,比如下方代码:
try {
mediapleyr.setDataSource(context, uri);
Logger.d(TAG, "data source set.")
//可能产生的异常的代码区,也成为监控区
}catch (ExceptionType1 e) {
//捕获并处理try抛出异常类型为ExceptionType1的异常
}catch(ExceptionType2 e) {
//捕获并处理try抛出异常类型为ExceptionType2的异常
}
将可能出现异常的代码块放在监控区,如果它可能抛出异常,则将其抛出的异常与每个catch的异常种类进行匹配,命中则在该处理区做相应处理。一般我们需要把被监控的代码块中所有可能抛出的异常全都并列出来并以此决定处理方式,比如前文提到的例子中,设置媒体数据源可能出现多种异常,并非每一种都是文件源的问题,有可能只是系统状态错误导致mediaplayer本身出错,此时可以对其复位并重试。
2) 重要语句不要放在异常可能发生点后面。
比如当我们发生异常的语句是setDataSource,那么它后方的Logger.d是不会执行的,而是直接转到了匹配的catch语句中。这就意味着我们不能将一定需要执行的语句放在可能异常点的后方。比如显示锁的解锁操作,关闭输入、输出流,或者释放资源操作。比如:
try {
mLockInst.lock();
mediapleyr.setDataSource(context, uri);
mLockInst.unlock();
//可能产生的异常的代码区,也成为监控区
}catch (ExceptionType1 e) {
//捕获并处理try抛出异常类型为ExceptionType1的异常
}catch(ExceptionType2 e) {
//捕获并处理try抛出异常类型为ExceptionType2的异常
}
这样做如果发生异常,那么会造成死锁。因此我们引入了finally语句。finally块的作用是保证无论出现什么情况,finally块中的代码都被实现(当然,前提是线程、系统没有被终止,且在前方无return语句返回)。因此正确处理方法是:
try {
mLockInst.lock();
mediapleyr.setDataSource(context, uri);
//可能产生的异常的代码区,也成为监控区
}catch (ExceptionType1 e) {
//捕获并处理try抛出异常类型为ExceptionType1的异常
}catch(ExceptionType2 e) {
//捕获并处理try抛出异常类型为ExceptionType2的异常
}finally{
mLockInst.unlock();
}
- 不要大块代码统一来try
这样可能会涉及过多的异常情况,也可能不同的逻辑错误抛出相同异常,导致做异常处理时难以区分,错误率较高。最好是针对不同功能分别做try catch处理。
4)不要在catch代码块中忽略被捕获的异常
只要异常发生,就意味着某些地方出了问题,catch代码块既然捕获了这种异常,就应该提供处理异常的措施,比如:
(a)处理异常。针对该异常采取一些行动,比如弥补异常造成的损失或者给出警告信息等。
(b)重新抛出异常。catch代码块在分析了异常之后,认为自己不能处理它,重新抛出异常。
(c)进行异常转译。把原始异常包装为适合于当前抽象层的另一种异常,再将其抛出(比如自定义一个特殊异常类型)。
(d)假如在catch代码块中不能采取任何措施,那就不要捕获异常,而是用throws子句声明异常抛出。
不采取任何操作或者仅仅打印异常信息是不可取的。在catch代码块中调用异常类的printStackTrack()方法对调试程序有帮助,但程序调试阶段结束之后,printStackTrack()方法就不应该在异常处理代码块中负担主要责任,因为光靠打印信息并不能解决实际存在的问题。
5)尽量避免抛出异常,如不能避免,则应该做好声明及注释。
异常是一种错误处理手段,而解决错误的最好途径是避免其发生。所以不应该对异常机制太依赖。如果实在无法避免,且该方法需要对外提供作为API,则需要注释清晰。
2.2.2 异常信息的打印
在总结这篇文档以前,我对异常信息的打印方法一直是except.printStackTrace(),信息完整方便调试,并未觉得有什么不合适。但是大概看了源码时发现该方法是使用System.err向控制台打印栈信息的。虽然便于调试,但是如果信息量过大,可能会过度消耗堆栈。且在一些博文中看到了某些上线产品因为printStackTrace过量导致进程死锁情况(栈耗尽导致其他字符串处理进程等待)。因此这里提出一点参考性建议:即在产品发布时改用自己的log工具将异常信息直接输入在日志文件中,比如开源框架logj4。
3. Exception的原理
3.1 抛出/捕获到一个异常时,代码发生了什么
在JVM内存中,不同的线程都对应一个单独的栈空间,而线程中的每个方法则对应一个栈帧,用来保存局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。当调用一个方法时,该信息体便被打包为栈帧压入栈中,完成时则会弹出。因此每个线程的栈都会随着方法的往返调用不断地弹出与压入。栈的最底层则是最先调用的方法。
如果当前方法发生错误导致无法继续运行,而又没有错误处理机制清除该错误记录,程序就会崩溃。因此Java提出了异常处理的方案,即将错误信息封装为一个异常对象抛出集中处理。当代码执行到了try关键词时,会标记一个监控区,一旦在该区域发生错误,JVM就会收集错误信息(错误的信息,位置以及栈当前的结构等)并封装为Exception对象,将其抛出。随后JVM会开始沿着栈结构逐帧查找与try相匹配的catch,直至找到或者抵达栈底。匹配到catch并执行完毕以后,该异常信息以及上面的栈帧即会被清除。
下面代码中举了个简单的异常的例子,并编译成字节码对比信息,发现如果抛出异常,则会生成一个Exception table,里面记录了try的代码块范围以及匹配的catch位置。因此可以看出代码执行时便是根据代码位置来寻找匹配的错误处理位置的。
protected void testArgument() throws IOException {
throw new IOException("This argument is too large");
}
public static void main(String[] args) {
ExceptionTest test = new ExceptionTest();
try {
test.testArgument();
} catch (IOException except) {
except.printStackTrace();
}
}
public class com.example.lib.ExceptionTest {
public com.example.lib.ExceptionTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
protected void testArgument() throws java.io.IOException;
Code:
0: new #7 // class java/io/IOException
3: dup
4: ldc #9 // String This argument is too large
6: invokespecial #11 // Method java/io/IOException."<init>":(Ljava/lang/String;)V
9: athrow
public static void main(java.lang.String[]);
Code:
0: new #14 // class com/example/lib/ExceptionTest
3: dup
4: invokespecial #16 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #17 // Method testArgument:()V
12: goto 20
15: astore_2
16: aload_2
17: invokevirtual #20 // Method java/io/IOException.printStackTrace:()V
20: return
Exception table:
from to target type
8 12 15 Class java/io/IOException
}
3.2 异常信息的记录
从3.1中我们知道了异常是如何抛出和处理的,那么我们打印出来的异常信息是如何收集的呢?
忽略具体细节,下面通过Throwable源码大概推测一下异常信息的来源:
private transient Object backtrace;
private String detailMessage;
private static final StackTraceElement[] UNASSIGNED_STACK = new StackTraceElement[0];
private StackTraceElement[] stackTrace;
private static final Throwable[] EMPTY_THROWABLE_ARRAY = new Throwable[0];
public Throwable() {
this.stackTrace = UNASSIGNED_STACK;
this.suppressedExceptions = SUPPRESSED_SENTINEL;
this.fillInStackTrace();
}
public Throwable(String var1) {
this.stackTrace = UNASSIGNED_STACK;
this.suppressedExceptions = SUPPRESSED_SENTINEL;
this.fillInStackTrace();
this.detailMessage = var1;
}
这里我只选择性粘贴了一些。这里能看到Exception的信息基本都是记录在throwable中的。而在它的若干个构造函数中(这里只举例两个),将每一个都有fillInStackTrace()方法。该方法源码如下:
public synchronized Throwable fillInStackTrace() {
if (this.stackTrace != null || this.backtrace != null) {
this.fillInStackTrace(0);
this.stackTrace = UNASSIGNED_STACK;
}
return this;
}
private native Throwable fillInStackTrace(int var1);
这我们发现,它的作用是填充了一个StackTraceElement[],并调用了本地方法。本地方法做什么先不管,具体看这个数组的类型:
public final class StackTraceElement implements Serializable {
private String declaringClass;
private String methodName;
private String fileName;
private int lineNumber;
}
这里只列举了成员。可以看到它是存储了方法名等一系列信息的。实际上这个数组是很常见的,比如线程中打印方法栈也是用该数组存储信息:
public StackTraceElement[] getStackTrace();
该数组在方法调用的过程中便会把方法调用栈中的信息记录其中,以便于后面需要时获取。比如在使用printStackTrace时,便是将该信息打印至控制端。