文章目录
声明:
本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。
本博客已标明出处,如有侵权请告知,马上删除。
开头语
之前我们介绍的基本类型、类、接口、枚举都是在表示和操作数据,操作的过程中可能有很多出错的情况,出错的原因可能是多方面的,有的是不可控的内部原因,比如内存不够了、磁盘满了,有的是不可控的外部原因,比如网络连接有问题,更多的可能是程序的编程错误,比如引用变量未初始化就直接调用实例方法。
这些非正常情况在 Java 中统一被认为是异常,Java 使用异常机制来统一处理,由于内容较多,我们分为两节来介绍,本节介绍异常的初步概念,以及异常类本身,下节主要介绍异常的处理。
6.1 初识异常
我们先来看两个具体的异常 NullPointerException (空指针异常) 和 NumberFormatException (数字格式异常)
6.1.1 NullPointerException (空指针异常)
我们来看段代码:
public class ExceptionTest {
public static void main(String[] args) {
String s = null;
s.indexOf("a");
System.out.println("end");
}
}
变量 s 没有初始化就调用其实例方法 indexOf,运行,屏幕输出为:
Exception in thread "main" java.lang.NullPointerException
at ExceptionTest.main(ExceptionTest.java:5)
输出是告诉我们:在 ExceptionTest 类的 main 函数中,代码第 5 行,出现了空指针异常(java.lang.NullPointerException)。
但,具体发生了什么呢?当执行 s.indexOf(“a”) 的时候,Java 系统发现 s 的值为 null,没有办法继续执行了,这时就启用异常处理机制,首先创建一个异常对象,这里是类 NullPointerException 的对象,然后查找看谁能处理这个异常,在示例代码中,没有代码能处理这个异常,Java 就启用默认处理机制,那就是打印异常栈信息到屏幕,并退出程序。
在介绍函数调用原理的时候,我们介绍过栈,异常栈信息就包括了从异常发生点到最上层调用者的轨迹,还包括行号,可以说,这个栈信息是分析异常最为重要的信息。
Java 的默认异常处理机制是退出程序,异常发生点后的代码都不会执行,所以示例代码中最后一行 System.out.println(“end”) 不会执行。
6.1.2 NumberFormatException (数字格式异常)
我们再来看一个例子,代码如下:
public class ExceptionTest {
public static void main(String[] args) {
if(args.length<1){
System.out.println("请输入数字");
return;
}
int num = Integer.parseInt(args[0]);
System.out.println(num);
}
}
args 表示命令行参数,这段代码要求参数为一个数字,它通过 Integer.parseInt 将参数转换为一个整数,并输出这个整数。参数是用户输入的,我们没有办法强制用户输入什么,如果用户输的是数字,比如 123,屏幕会输出 123,但如果用户输的不是数字,比如 abc,屏幕会输出:
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:492)
at java.lang.Integer.parseInt(Integer.java:527)
at ExceptionTest.main(ExceptionTest.java:7)
出现了异常 NumberFormatException。这个异常是怎么产生的呢?根据异常栈信息,我们看相关代码:
这是 NumberFormatException 类 65 行附近代码:
64 static NumberFormatException forInputString(String s) {
65 return new NumberFormatException("For input string: \"" + s + "\"");
66 }
这是 Integer 类 492 行附近代码:
490 digit = Character.digit(s.charAt(i++),radix);
491 if (digit < 0) {
492 throw NumberFormatException.forInputString(s);
493 }
494 if (result < multmin) {
495 throw NumberFormatException.forInputString(s);
496 }
将这两处合为一行,主要代码就是:
throw new NumberFormatException(...)
new NumberFormatException(…) 是我们容易理解的,就是创建了一个类的对象,只是这个类是一个异常类。throw 是什么意思呢?就是抛出异常,它会触发 Java 的异常处理机制。在之前的空指针异常中,我们没有看到 throw 的代码,可以认为 throw 是由 Java 虚拟机自己实现的。
throw 关键字可以与 return 关键字进行对比,return 代表正常退出,throw 代表异常退出,return 的返回位置是确定的,就是上一级调用者,而 throw 后执行哪行代码则经常是不确定的,由异常处理机制动态确定。
异常处理机制会从当前函数开始查找看谁"捕获"了这个异常,当前函数没有就查看上一层,直到主函数,如果主函数也没有,就使用默认机制,即输出异常栈信息并退出,这正是我们在屏幕输出中看到的。
对于屏幕输出中的异常栈信息,程序员是可以理解的,但普通用户无法理解,也不知道该怎么办,我们需要给用户一个更为友好的信息,告诉用户,他应该输入的是数字,要做到这一点,我们需要自己"捕获"异常。
"捕获"是指使用 try/catch 关键字,我们看捕获异常后的示例代码:
public class ExceptionTest {
public static void main(String[] args) {
if(args.length<1){
System.out.println("请输入数字");
return;
}
try{
int num = Integer.parseInt(args[0]);
System.out.println(num);
}catch(NumberFormatException e){
System.err.println("参数"+args[0]
+"不是有效的数字,请输入数字");
}
}
}
我们使用 try/catch 捕获并处理了异常,try 后面的大括号 {} 内包含可能抛出异常的代码,括号后的 catch 语句包含能捕获的异常和处理代码,catch 后面括号内是异常信息,包括异常类型和变量名,这里是 NumberFormatException e,通过它可以获取更多异常信息,大括号 {} 内是处理代码,这里输出了一个更为友好的提示信息。
捕获异常后,程序就不会异常退出了,但 try 语句内异常点之后的其他代码就不会执行了,执行完 catch 内的语句后,程序会继续执行 catch 大括号外的代码。
这样,我们就对异常有了一个初步的了解,异常是相对于 return 的一种退出机制,可以由系统触发,也可以由程序通过 throw 语句触发,异常可以通过 try/catch 语句进行捕获并处理,如果没有捕获,则会导致程序退出并输出异常栈信息。异常有不同的类型,接下来,我们来认识一下。
6.2 异常类
NullPointerException 和 NumberFormatException 都是异常类,所有异常类都有一个共同的父类 Throwable,我们先来介绍这个父类,然后介绍 Java 中异常类的体系,最后介绍怎么自定义异常。
6.2.1 Throwable
NullPointerException 和 NumberFormatException 都是异常类,所有异常类都有一个共同的父类 Throwable,它有 4 个 public 构造方法:
- public Throwable()
- public Throwable(String message)
- public Throwable(String message, Throwable cause)
- public Throwable(Throwable cause)
有两个主要参数,一个是 message,表示异常消息,另一个是 cause,表示触发该异常的其他异常。异常可以形成一个异常链,上层的异常由底层异常触发,cause 表示底层异常。
Throwable 还有一个 public 方法用于设置 cause,Throwable 的某些子类没有带 cause 参数的构造方法,就可以通过这个方法来设置,这个方法最多只能被调用一次。
Throwable initCause(Throwable cause)
所有构造方法中都有一句重要的函数调用,它会将异常栈信息保存下来,这是我们能看到异常栈的关键。
fillInStackTrace();
Throwable 有一些常用方法用于获取异常信息:
void printStackTrace() // 打印异常栈信息到标准错误输出流
// 打印栈信息到指定的流,关于 PrintStream 和 PrintWriter 我们后续文章介绍
void printStackTrace(PrintStream s)
void printStackTrace(PrintWriter s)
String getMessage() // 获取设置的异常 message
Throwable getCause() // 获取异常的 cause
// 获取异常栈每一层的信息,每个 StackTraceElement 包括文件名、类名、函数名、行号等信息
StackTraceElement[] getStackTrace()
6.2.2 异常类体系
以 Throwable 为根,Java API 中定义了非常多的异常类,表示各种类型的异常,部分类如图 6-1 所示。
Throwable 是所有异常的基类,它有两个子类 Error 和 Exception。
Error 表示系统错误或资源耗尽,由 Java 系统自己使用,应用程序不应抛出和处理。比如图中列出的虚拟机错误(VirtualMacheError)及其子类内存溢出错误(OutOfMemoryError)和栈溢出错误(StackOverflowError)。
Exception 表示应用程序错误,它有很多子类,应用程序也可以通过继承 Exception 或其子类创建自定义异常。图中列出了三个直接子类:IOException(输入输出I/O异常),SQLException(数据库SQL异常),RuntimeException(运行时异常)。
RuntimeException(运行时异常) 比较特殊,它的名字有点误导,因为其他异常也是运行时产生的,它表示的实际含义是 unchecked exception (未受检异常),相对而言,Exception 的其他子类和 Exception 自身则是 checked exception (受检异常),Error 及其子类也是 unchecked exception。
checked 还是 unchecked,区别在于 Java 如何处理这两种异常,对于 checked 异常,Java 会强制要求程序员进行处理,否则会有编译错误,而对于 unchecked 异常则没有这个要求。下节我们会进一步解释。
RuntimeException 也有很多子类,表 6-1 列出了其中常见的一些。
这么多不同的异常类其实并没有比 Throwable 这个基类多多少属性和方法,大部分类在继承父类后只是定义了几个构造方法,这些构造方法也只是调用了父类的构造方法,并没有额外的操作。
那为什么定义这么多不同的类呢?主要是为了名字不同,异常类的名字本身就代表了异常的关键信息,无论是抛出还是捕获异常时,使用合适的名字都有助于代码的可读性和可维护性。
6.2.3 自定义异常
除了 Java API 中定义的异常类,我们也可以自己定义异常类,一般通过继承 Exception 或者它的某个子类。如果父类是 RuntimeException 或它的某个子类,则自定义异常也是 unchecked exception,如果是 Exception 或 Exception 的其他子类,则自定义异常是 checked exception。
我们通过继承 Exception 来定义一个异常,代码如下:
public class AppException extends Exception {
public AppException() {
super();
}
public AppException(String message,
Throwable cause) {
super(message, cause);
}
public AppException(String message) {
super(message);
}
public AppException(Throwable cause) {
super(cause);
}
}
和很多其他异常类一样,我们没有定义额外的属性和代码,只是继承了 Exception,定义了构造方法并调用了父类的构造方法。
6.3 异常处理
在了解了异常的基本概念和异常类之后,我们来看 Java 语言对异常处理的支持,包括 catch、throw、finally、try-with-resources 和 throws,最后对比受检和未受检异常。
6.3.1 catch 匹配
上节简单介绍了使用 try/catch 捕获异常,其中 catch 只有一条,其实,catch 还可以有多条,每条对应一个异常类型,比如说:
try{
//可能触发异常的代码
}catch(NumberFormatException e){
System.out.println("not valid number");
}catch(RuntimeException e){
System.out.println("runtime exception "+e.getMessage());
}catch(Exception e){
e.printStackTrace();
}
异常处理机制将根据抛出的异常类型找第一个匹配的 catch 块,找到后,执行 catch 块内的代码,其他 catch 块就不执行了,如果没有找到,会继续到上层方法中查找。需要注意的是,抛出的异常类型是 catch 中声明异常的子类也算匹配,所以需要将最具体的子类放在前面,如果基类 Exception 放在前面,则其他更具体的 catch 代码将得不到执行。
示例也演示了对异常信息的利用,e.getMessage() 获取异常消息,e.printStackTrace() 打印异常栈到标准错误输出流。通过这些信息有助于理解为什么会出异常,这是解决编程错误的常用方法。示例是直接将信息输出到标准流上,实际系统中更常用的做法是输出到专门的日志中。
6.3.2 重新抛出异常
在 catch 块内处理完后,可以重新抛出异常,异常可以是原来的,也可以是新建的,如下所示:
try{
//可能触发异常的代码
}catch(NumberFormatException e){
System.out.println("not valid number");
throw new AppException("输入格式不正确", e);
}catch(Exception e){
e.printStackTrace();
throw e;
}
对于 Exception,在打印出异常栈后,就通过 throw e 重新抛出了。
而对于 NumberFormatException,我们重新抛出了一个 AppException,当前 Exception 作为 cause 传递给了 AppException,这样就形成了一个异常链,捕获到 AppException 的代码可以通过 getCause() 得到 NumberFormatException。
为什么要重新抛出呢?因为当前代码不能够完全处理该异常,需要调用者进一步处理。
为什么要抛出一个新的异常呢?当然是当前异常不太合适,不合适可能是信息不够,需要补充一些新信息,还可能是过于细节,不便于调用者理解和使用,如果调用者对细节感兴趣,还可以继续通过 getCause() 获取到原始异常。
6.3.3 finally
异常机制中还有一个重要的部分,就是 finally,catch 后面可以跟 finally 语句,语法如下所示:
try{
//可能抛出异常
}catch(Exception e){
//捕获异常
}finally{
//不管有无异常都执行
}
finally 内的代码不管有无异常发生,都会执行。具体来说:
- 如果没有异常发生,在 try 内的代码执行结束后执行。
- 如果有异常发生且被 catch 捕获,在 catch 内的代码执行结束后执行
- 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。
由于 finally 的这个特点,它一般用于释放资源,如数据库连接、文件流等。
try/catch/finally 语法中,catch 不是必需的,也就是可以只有 try/finally,表示不捕获异常,异常自动向上传递,但 finally 中的代码在异常发生后也执行。
finally 语句有一个执行细节,如果在 try 或者 catch 语句内有 return 语句,则 return 语句在 finally 语句执行结束后才执行,但 finally 并不能改变返回值,我们来看下代码:
public static int test(){
int ret = 0;
try{
return ret;
}finally{
ret = 2;
}
}
这个函数的返回值是 0,而不是 2,实际执行过程是,在执行到 try 内的 return ret; 语句前,会先将返回值 ret 保存在一个临时变量中,然后才执行 finally 语句,最后 try 再返回那个临时变量,finally 中对 ret 的修改不会被返回。
如果在 finally 中也有 return 语句呢?try 和 catch 内的 return 会丢失,实际会返回 finally 中的返回值。finally 中有 return 不仅会覆盖 try 和 catch 内的返回值,还会掩盖 try 和 catch 内的异常,就像异常没有发生一样,比如说:
public static int test(){
int ret = 0;
try{
int a = 5/0;
return ret;
}finally{
return 2;
}
}
以上代码中,5/0 会触发 ArithmeticException,但是 finally 中有 return 语句,这个方法就会返回 2,而不再向上传递异常了。
finally 中不仅 return 语句会掩盖异常,如果 finally 中抛出了异常,则原异常就会被掩盖,看下面代码:
public static void test(){
try{
int a = 5/0;
}finally{
throw new RuntimeException("hello");
}
}
finally 中抛出了 RuntimeException,则原异常 ArithmeticException 就丢失了。
所以,一般而言,为避免混淆,应该避免在 finally 中使用 return 语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理。
6.3.4 throws
异常机制中,还有一个和 throw 很像的关键字 throws,用于声明一个方法可能抛出的异常,语法如下所示:
public void test() throws AppException, SQLException, NumberFormatException {
//....
}
throws 跟在方法的括号后面,可以声明多个异常,以逗号分隔。这个声明的含义是说,我这个方法内可能抛出这些异常,我没有进行处理,至少没有处理完,调用者必须进行处理。这个声明没有说明,具体什么情况会抛出什么异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好的处理异常。
对于 RuntimeException(unchecked exception),是不要求使用 throws 进行声明的,但对于 checked exception,则必须进行声明,换句话说,如果没有声明,则不能抛出。
对于 checked exception,不可以抛出而不声明,但可以声明抛出但实际不抛出,不抛出声明它干嘛?主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了,子类不能抛出父类方法中没有声明的 checked exception,所以就将所有可能抛出的异常都写到父类上了。
如果一个方法内调用了另一个声明抛出 checked exception 的方法,则必须处理这些 checked exception,不过,处理的方式既可以是 catch,也可以是继续使用 throws,如下代码所示:
public void tester() throws AppException {
try {
test();
} catch (SQLException e) {
e.printStackTrace();
}
}
对于 test 抛出的 SQLException,这里使用了 catch,而对于 AppException,则将其添加到了自己方法的 throws 语句中,表示当前方法也处理不了,还是由上层处理吧。
6.3.5 对比受检和未受检异常
通过以上介绍可以看出,RuntimeException(unchecked exception) 和 checked exception 的区别,checked exception 必须出现在 throws 语句中,调用者必须处理,Java 编译器会强制这一点,而 RuntimeException 则没有这个要求。
为什么要有这个区分呢?我们自己定义异常的时候应该使用 checked 还是 unchecked exception 啊?对于这个问题,业界有各种各样的观点和争论,没有特别一致的结论。
一种普遍的说法是,RuntimeException(unchecked) 表示编程的逻辑错误,编程时应该检查以避免这些错误,比如说像空指针异常,如果真的出现了这些异常,程序退出也是正常的,程序员应该检查程序代码的 bug 而不是想办法处理这种异常。Checked exception 表示程序本身没问题,但由于 I/O、网络、数据库等其他不可预测的错误导致的异常,调用者应该进行适当处理。
但其实编程错误也是应该进行处理的,尤其是,Java 被广泛应用于服务器程序中,不能因为一个逻辑错误就使程序退出。所以,目前一种更被认同的观点是,Java 中的这个区分是没有太大意义的,可以统一使用 RuntimeException 即 unchcked exception 来代替。
这个观点的基本理由是,无论是 checked 还是 unchecked 异常,无论是否出现在 throws 声明中,我们都应该在合适的地方以适当的方式进行处理,而不是只为了满足编译器的要求,盲目处理异常,既然都要进行处理异常,checked exception 的强制声明和处理就显得啰嗦,尤其是在调用层次比较深的情况下。
其实观点本身并不太重要,更重要的是一致性,一个项目中,应该对如何使用异常达成一致,按照约定使用即可。Java 中已有的异常和类库也已经在哪里,我们还是要按照他们的要求进行使用。
6.4 如何使用异常
针对异常,我们介绍了 try/catch/finally、catch 匹配、重新抛出、throws、checked/unchecked exception,那到底该如何使用异常呢?
6.4.1 异常应该且仅用于异常情况
异常应该且仅用于异常情况,是指异常不能代替正常的条件判断。比如说,循环处理数组元素的时候,你应该先检查索引是否有效再进行处理,而不是等着抛出索引异常再结束循环。对于一个引用变量,如果正常情况下它的值也可能为 null,那就应该先检查是不是 null,不为 null 的情况下再进行调用。
另一方面,真正出现异常的时候,应该抛出异常,而不是返回特殊值。比如说,我们看 String 的 substring 方法,它返回一个子字符串,它的代码如下:
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
代码会检查 beginIndex 的有效性,如果无效,会抛出 StringIndexOutOfBoundsException。纯技术上一种可能的替代方法是不抛异常而返回特殊值 null,但 beginIndex 无效是异常情况,异常不能假装当正常处理。
6.4.2 异常处理的目标
异常大概可以分为三个来源:用户、程序员、第三方。用户是指用户的输入有问题,程序员是指编程错误,第三方泛指其他情况如 I/O 错误、网络、数据库、第三方服务等。每种异常都应该进行适当的处理。
处理的目标可以分为报告和恢复。恢复是指通过程序自动解决问题。报告的最终对象可能是用户,即程序使用者,也可能是系统运维人员或程序员。报告的目的也是为了恢复,但这个恢复经常需要人的参与。
对用户,如果用户输入不对,可能提示用户具体哪里输入不对,如果是编程错误,可能提示用户系统错误、建议联系客服,如果是第三方连接问题,可能提示用户稍后重试。
对系统运维人员或程序员,他们一般不关心用户输入错误,而关注编程错误或第三方错误,对于这些错误,需要报告尽量完整的细节,包括异常链、异常栈等,以便尽快定位和解决问题。
对于用户输入或编程错误,一般都是难以通过程序自动解决的,第三方错误则可能可以,甚至很多时候,程序都不应该假定第三方是可靠的,应该有容错机制。比如说,某个第三方服务连接不上(比如发短信),可能的容错机制是,换另一个提供同样功能的第三方试试,还可能是,间隔一段时间进行重试,在多次失败之后再报告错误。
6.4.3 异常处理的一般逻辑
如果自己知道怎么处理异常,就进行处理,如果可以通过程序自动解决,就自动解决,如果异常可以被自己解决,就不需要再向上报告。
如果自己不能完全解决,就应该向上报告。如果自己有额外信息可以提供,有助于分析和解决问题,就应该提供,可以以原异常为 cause 重新抛出一个异常。
总有一层代码需要为异常负责,可能是知道如何处理该异常的代码,可能是面对用户的代码,也可能是主程序。如果异常不能自动解决,对于用户,应该根据异常信息提供用户能理解和对用户有帮助的信息,对运维和程序员,则应该输出详细的异常链和异常栈到日志。
这个逻辑与在公司中处理问题的逻辑是类似的,每个级别都有自己应该解决的问题,自己能处理的自己处理,不能处理的就应该报告上级,把下级告诉他的,和他自己知道的,一并告诉上级,最终,公司老板必须要为所有问题负责。每个级别既不应该掩盖问题,也不应该逃避责任。
6.4.4 小结
本章介绍了 Java 中的异常机制。在没有异常机制的情况下,唯一的退出机制是 return,判断是否异常的方法就是返回值。方法根据是否异常返回不同的返回值,调用者根据不同返回值进行判断,并进行相应处理。每一层方法都需要对调用的方法的每个不同返回值进行检查和处理,程序的正常逻辑和异常逻辑混杂在一起,代码往往难以阅读理解和维护。
另外,因为异常毕竟是少数情况,程序员经常偷懒,假定异常不会发生,而忽略对异常返回值的检查,降低了程序的可靠性。
在有了异常机制后,程序的正常逻辑与异常逻辑可以相分离,异常情况可以集中进行处理,异常还可以自动向上传递,不再需要每层方法都进行处理,异常也不再可能被自动忽略,从而,处理异常情况的代码可以大大减少,代码的可读性、可靠性、可维护性也都可以得到提高。