Java异常控制机制又被称为“违例控制机制”。
捕获程序错误最理想的时机是在编译阶段,这样可以彻底避免错误的代码运行。但并非所有的错误都能在编译期间侦测到,有些问题必须在运行期间解决。
错误在运行期间发生时,我们可能不知道具体应该怎样解决,但我们清楚此时不能不管不顾地继续执行下去。此时应该做的事情是:
- 暂停程序的运行
- 指出何时、何地发生了什么样的错误
- 可能的话应处理此错误并恢复程序的执行
Java异常控制机制的作用流程:
-
异常产生
首先程序引擎需要能够获知异常的产生。Java中预置了一系列基本的异常条件,如数组下标越界、空指针、被零除等等,这些异常是由JVM自动产生的(也被称为运行时异常,见后);另一部分异常则是由Java代码(可能是JDK的代码或开发人员自己编写的代码)产生的(也被称为checked异常,见后)。
异常产生即是异常对象的实例化,该对象的类型通常就说明了异常条件的类型,实例化的异常对象中还会包含对异常条件的补充说明(message),以及异常发生时的线程调用栈信息(stacktrace)。
在这个环节中,JAVA完成了对错误的描述,包括错误发生的时间、错误的类型(即异常对象的Class)、对错误的描述(message)和错误发生的位置(stacktrace)。 -
异常抛出
异常抛出是JAVA程序流中的一种特殊流程,当异常产生后,JVM会停止继续执行后面的代码,并将异常对象抛出。抛出的异常对象会进入调用栈的上一层,如果异常对象没有被捕获,它会沿着调用栈的顺序逐层向上抛出,直至调用栈为空,此时该线程的运行也就彻底终止了。
异常的抛出解决了当前作用域可能不具备处理异常所需的信息的问题,将异常对象在调用栈中逐级向上传递,直至有能力处理异常的作用域将其捕获。 -
异常捕获
在异常对象逐级向上抛出的过程中,如果调用栈中某一层有捕获该类型异常的逻辑,该异常对象便会被捕捉,异常被捕获后JVM会终止抛出异常对象的过程。 -
异常处理
当异常对象被捕获后,JVM会执行捕获后的处理逻辑(处理逻辑是由程序员编写的)。当处理逻辑执行完成后,JVM会继续执行捕获了异常的作用域中接下来的代码(除非异常处理逻辑中将该异常继续抛出,或异常处理逻辑中产生了新的异常)。
try-catch-finally
前文所述的异常控制流程,在JAVA程序中以try-catch-finally结构实现:
图片.png- try块也被称为“警戒区”,try块包裹的代码在执行过程如果产生异常,或其调用栈的下层中产生了异常并被抛至本层,则会被与此try块关联的catch命令尝试捕获。若异常产生于警戒区之外,则会直接向上层抛出。
- catch命令后的括号内指定希望捕捉的异常对象类型(可以指定多个),如果产生或被抛至此层的异常对象是catch指定的异常类型(或其子类),则异常对象会被捕捉。上例中,所有Exception对象及其子类的对象在此处均会被捕获。
- 被捕获后,JVM会执行catch块中的代码,catch块中的代码能够访问被捕捉到的异常对象(即上例中的Exception e)。
catch块中的代码仍然有可能产生异常,所以也可以在catch块中插入try-catch-finally。 - finally块为可选块,如果有,则无论是否有异常被抛出,JVM都会在try-catch块执行完成后执行finally块中的代码。
Exception与Error
前文所述的Java异常控制机制实际上并不仅对“异常”起作用。除了我们所说的异常(Exception)能够被产生、抛出和捕捉之外,还有另一种类型“错误(Error)”。
Java中,Throwable是所有可以被抛出并捕获的类的父类。Throwable有两大子类,分别是Exception和Error。
Java官方并没有给出Error和Exception的严格定义,而是将Error描述为“应用程序不应尝试捕捉处理的严重问题”,Exception则是“应用程序应该尝试捕捉处理的问题”。
我们从几个例子看一下:
- NoClassDefFoundError:JVM的ClassLoader在尝试加载某个类,但该类在Classpath中并不存在时会产生的错误。例如a.jar依赖b.jar中的某个类,如果我们使用编译完成的a.jar时并没有引入b.jar,编译器并不会发现问题(因为a.jar已经完成了编译,需要编译的代码中只使用了a.jar中的api,并没有直接使用b.jar),但在运行时JVM找不到b.jar中被a所依赖的类,便会发生错误。
- UnsupportedClassVersionError:当JVM尝试加载一个class但发现该class的版本并不被支持时产生的错误。例如我们使用JDK1.8开发并编译一个类,但在JDK1.7的环境中运行时,便会发生此错误
- OutOfMemoryError:当JVM内存不足,无法为一个对象分配内存时发生的错误,例如堆区内存溢出、Perm区内存溢出等。
- StackOverFlowError:当程序的递归调用过深,导致线程调用栈溢出时发生的错误。
- NoSuchFieldError/NoSuchMethodError:当JVM试图访问某个成员属性或某个方法时,发现目标不存在。一般都是由于class信息在运行时被改变导致的,多见于使用反射时。
通过上面的例子能够看出,Error一般都与程序本身的直接关系不大,更多是由于环境导致的问题。而且Error发生后通常程序都没有再继续执行下去的可能性,所以Java官方将其定义为“应用程序不应尝试捕捉处理的严重问题”。
Exception的分类
Java将Exception分为两类,checked异常和unchecked异常,也被称为非运行时异常和运行时(runtime)异常。
RuntimeException是Exception的一个子类,RuntimeException的子类都属于unchecked异常(也就是运行时异常),其他所有的Exception都是checked异常(也就是非运行时异常)。
这两种异常的区别从字面上即可理解,checked代表“必须被check”,而unchecked代表“无须被check”:
Java要求checked异常必须被在代码编写阶段就调用者了解,unchecked异常则不用。如果一个方法中有可能产生checked异常,则Java编译器会要求该方法定义中必须加入throws定义,明确说明该方法可能会抛出某类checked异常。如下图:
foo方法可能产生IOException(这是一种checked异常),所以bar方法在调用foo时,编译器会提示错误。此时可以在bar方法的定义行中加入throws:
public void bar() throws IOException
也可以在bar方法内将IOException捕获处理:
图片.png另一个理解checked异常与unchecked异常区别的角度是:所有由JVM自动生成的异常都是unchecked异常,反之,由java程序主动生成的异常是checked异常。
例如:
上图中f.createNewFile()方法可能会产生checked异常IOException,我们看看File类的源码:
这里写图片描述可以看到红框处,IOException异常是在代码中被主动抛出的,凡是这样在代码中主动抛出的异常,都是checked异常。
相应地,unchecked异常是JVM在运行时自动产生的,例如下图的方法,只要传入的参数b等于0,就会在运行时自动产生ArithmeticException:
图片.png 图片.png代码中永远不需要这样写:
图片.png异常处理的原则
异常处理的原则主要有三个:
- 具体明确
- 提早抛出
- 延迟捕获
具体明确:
指抛出的异常应能通过异常类名和message准确说明异常的类型和产生异常的原因。
我们通过例子来看:
代码1:
图片.png代码2:
图片.png这两段代码的处理逻辑是类似的,均是在入参input1或input2为null或空串时抛出异常,但只有第二段符合“具体明确”的标准:
首先,第二段代码通过异常类型【IllegalArgumentException】明确了异常是由于传入了不合法的参数导致的;其次,在message中说明了具体是哪个参数不合法,为什么不合法。这样不仅能够在查阅日志时快速知晓异常产生的原因,也让上层的程序能够针对IllegalArgumentException这一特定类型的异常进行有针对性的捕捉和处理。
相比之下,第一段代码中抛出的异常就不够具体明确,异常类型Exception不具有说明性质,异常message也不够明确,上层程序难以处理,阅读日志时也难以快速定位。
提早抛出:
指应尽可能早的发现并抛出异常,便于精确定位问题。
同样通过例子来看:
代码1:
图片.png代码2:
图片.png在传入的filename为null时,这两段代码都会抛出异常,第一段代码抛出的异常是:
图片.png第二段代码抛出的异常是:
图片.png第一段代码抛出的异常是在标准Java类库【InputFileStream】中抛出的,这首先就提升了问题定位的难度,不过幸好stacktrace中也打印出了前面的调用链,我们可以在标准类库的调用者身上查找问题(可以定位到Test.java的第38行)。
同时NullPointerException是Java中信息量最少的(却也是最常遭遇且让人崩溃的)异常。它压根不提我们最关心的事情:到底哪里是null。在稍微复杂一些的场景中(如一行代码中有多处都可能导致NullPointerException)会让人更加崩溃。
而相比之下第二段代码对filename提前进行了校验,并以IllegalArgumentException的形式抛出,这样在第一段代码中遇到的两个问题都可以得到解决,这便是提早抛出的好处。
延迟捕获:
指异常的捕获和处理应尽可能延迟,让掌握更多信息的作用域来处理异常。
代码1:
图片.png上面的代码中,readSomeFile方法将new FileInputStream处有可能产生的FileNotFoundException捕获,并将异常信息记录到了日志中。
这么做看起来似乎没什么问题,但readSomeFile这个方法有可能是一个通用的底层方法,会在各种业务场景下被调用,不同的业务场景下,发生FileNotFoundException时的处理策略可能不一样(例如某些场景要求记录异常并告警,某些场景会使用其他文件名重试),但readSomeFile方法并不知道自己所处的业务场景是什么样的,这一信息只有更上层的作用域才了解,所以在方法内部直接捕获并处理异常的做法就显得有问题了,程序将无法通过甄别业务场景来执行不同的异常处理逻辑。
代码2:
图片.png第二段代码看起来反而更加简单了,没有对FileNotFoundException加以处理,而是直接在方法定义中将其抛出。然而在上面所述的场景下,这种处理方式反而是正确的。将异常抛出交由掌握了足够多信息的上层调用者捕获,这样就可以根据异常产生所处的具体业务流程来进行不同的处理。
例如我们可以在一个业务逻辑中这样处理:
图片.png同时在另一个业务逻辑中这样处理:
图片.png其他重要原则
-
不要让异常逃掉
当一个异常在整个调用栈中的任意一层都没有被捕获,这个异常就“逃掉”了。这对于任何程序来说都是一个灾难性的事件。
对于B/S系统,从请求处理线程中逃掉的异常很可能会被B/S框架(如Struts/SpringMVC等)捕捉到。如果没有正确配置,这些逃掉的异常很可能就被框架“吃掉”了,即框架捕获了从业务代码层抛出的异常,且没有记录或没有完整记录异常信息。这样的异常来无影去无踪,完全无迹可寻,堪称程序员的大敌。
某些情况下,异常会被抛到中间件或容器(Tomcat/Jboss/Weblogic/Websphere等)层(可能是没有使用B/S框架或B/S框架没有“吃掉”异常)。被中间件或容器捕获到的异常,一般情况下会被记录在中间件或容器自己的日志中(也有可能不会记),但问题在于,这种情况下,用户会看到中间件或容器提供的错误页,这些错误页基本没有用户友好型可言,而且有可能会把异常堆栈的信息直接显示在页面上,在开放性的系统中,暴露堆栈信息极有可能引发严重的安全问题。
而在后台进程中,如果异常逃掉了,将会导致线程的退出。如果没有守护线程及时补充异常退出的线程,那么将有可能发生整个进程因为异常而中止的灾难性后果。
所以说,在编程时应绝对避免异常“逃逸”的情况,对于B/S系统来说,我们可以在每个Action中都加入try-catch块,捕获所有Exception,也可以利用B/S框架的特性来实现从Action层抛出的异常的统一处理(如Struts2和SpringMVC都有的拦截器机制)。对于后台进程来说,可以利用try-catch块避免异常导致线程中止,也可以通过添加守护线程来及时补充因异常而退出的线程,同时还应使用Thread.setDefaultUncaughtExceptionHandler来确保未捕获异常的正确记录。 -
正确记录异常信息
即在异常的stacktrace信息完整、未缺失的基础上,确保异常的stacktrace被正确记录到日志中
错误的做法:
图片.png上面的5种处理全都是错误的,前两种将异常信息输出到了控制台而不是日志文件中。后三种错误的使用了log4j的error方法,均没有正确记录异常的stacktrace
正确的方法:
图片.png注意应使用正确的error方法,传入两个参数,参数1是对异常的附加描述,参数2是未被篡改过的异常对象
在某些情况下,可能需要在处理异常后继续抛出,让上层捕获后继续处理,在这种情况下,需要注意抛出的异常对象未被篡改。
错误的:
图片.png如果像上图这样写的话,下层的异常stacktrace会全部被吃掉。
正确的写法:
图片.png
网友评论