我们在第 1 章“Java12 入门”中简要介绍了异常。在本章中,我们将更系统地讨论这个问题。Java 中有两种异常:受检异常和非受检异常。两者都将被演示,并解释两者之间的区别。读者还将了解与异常处理相关的 Java 构造的语法以及处理异常的最佳实践。本章将以可用于调试生产代码的断言语句的相关主题结束。
本章将讨论以下主题:
- Java 异常框架
- 受检和非受检(运行时)异常
try
、catch
和finally
块throws
声明throw
声明assert
声明- 异常处理的最佳实践
正如我们在第一章“Java12 入门”中所描述的,一个意外的情况可能会导致 Java 虚拟机(JVM)创建并抛出一个异常对象,或者应用代码可以这样做。一旦发生异常,如果异常是在一个try
块中抛出的,那么控制流就被转移到catch
子句。让我们看一个例子。考虑以下方法:
void method(String s){
if(s.equals("abc")){
System.out.println("Equals abc");
} else {
System.out.println("Not equal");
}
}
如果输入参数值为null
,则可以预期输出为Not equal
。不幸的是,情况并非如此。s.equals("abc")
表达式对s
变量引用的对象调用equals()
方法,但是,如果s
变量是null
,则它不引用任何对象。让我们看看会发生什么。
让我们运行以下代码:
try {
method(null);
} catch (Exception ex){
System.out.println(ex.getClass().getCanonicalName());
//prints: java.lang.NullPointerException
ex.printStackTrace(); //prints: see the screenshot
if(ex instanceof NullPointerException){
//do something
} else {
//do something else
}
}
此代码的输出如下:
在屏幕截图上看到的红色部分称为栈跟踪。名称来自方法调用在 JVM 内存中的存储方式(作为栈):一个方法调用另一个方法,而另一个方法又反过来调用另一个方法,依此类推。在最内部的方法返回后,遍历栈,并从栈中移除返回的方法(栈帧)(我们将在第 9 章、“JVM 结构和垃圾收集”中详细讨论 JVM 内存结构)。当发生异常时,所有栈内容(栈帧)都作为栈跟踪返回。它允许我们追踪导致问题的代码行。
在前面的代码示例中,根据异常的类型执行不同的代码块。在我们的案例中,是java.lang.NullPointerException
。如果应用代码没有捕获它,这个异常将通过被调用方法的栈一直传播到 JVM 中,JVM 随后停止执行应用。为了避免这种情况的发生,可以捕获异常并执行一些代码来从异常情况中恢复。
Java 中异常处理框架的目的是保护应用代码不受意外情况的影响,并在可能的情况下从中恢复。在下面的部分中,我们将更详细地剖析它,并使用框架功能重新编写给定的示例。
如果你查阅java.lang
包 API 的文档,你会发现这个包包含了近三十个异常类和几十个错误类。两个组都扩展了java.lang.Throwable
类,从中继承所有方法,并且不添加其他方法。java.lang.Throwable
类最常用的方法如下:
void printStackTrace()
:输出方法调用的栈跟踪(栈帧)StackTraceElement[] getStackTrace()
:返回与printStackTrace()
相同的信息,但允许对栈跟踪的任何帧进行编程访问String getMessage()
:检索通常包含异常或错误原因的用户友好解释的消息Throwable getCause()
:检索java.lang.Throwable
的可选对象,该对象是异常的原始原因(但代码的作者决定将其包装在另一个异常或错误中)
所有错误都扩展了java.lang.Error
类,而java.lang.Error
类又扩展了java.lang.Throwable
类。一个错误通常是由 JVM 抛出的,根据官方文档,表示一个合理的应用不应该试图捕捉的严重问题。以下是几个例子:
OutOfMemoryError
:当 JVM 耗尽内存并且无法使用垃圾收集清理内存时抛出StackOverflowError
:当分配给方法调用栈的内存不足以存储另一个栈帧时抛出NoClassDefFoundError
:当 JVM 找不到当前加载的类所请求的类的定义时抛出
框架的作者假设应用不能自动从这些错误中恢复,这在很大程度上被证明是正确的假设。这就是为什么程序员通常不会捕捉到错误,我们将不再讨论它们。
另一方面,异常通常与特定于应用的问题相关,通常不需要我们关闭应用并允许恢复。这就是为什么程序员通常会捕捉到它们并实现应用逻辑的替代(主流程)路径,或者至少在不关闭应用的情况下报告问题。以下是几个例子:
ArrayIndexOutOfBoundsException
:当代码试图通过等于或大于数组长度的索引访问元素时抛出(记住数组的第一个元素有索引0
,所以索引等于数组之外的数组长度点)ClassCastException
:当代码对与变量引用的对象无关的类或接口进行引用时抛出NumberFormatException
:当代码试图将字符串转换为数字类型,但字符串不包含必需的数字格式时抛出
所有异常都扩展了java.lang.Exception
类,而java.lang.Exception
类又扩展了java.lang.Throwable
类。这就是为什么通过捕捉java.lang.Exception
类的对象,代码捕捉任何异常类型的对象。我们已经在“Java 异常框架”一节中通过这种方式捕获了java.lang.NullPointerException
进行了演示。
异常之一是java.lang.RuntimeException
。扩展它的异常称为运行时异常或非受检异常。我们已经提到了其中的一些:NullPointerException
、ArrayIndexOutOfBoundsException
、ClassCastException
和NumberFormatException
。为什么它们被称为运行时异常是很清楚的,而为什么它们被称为非受检的异常将在下一段中变得很清楚。
祖先中没有java.lang.RuntimeException
的称为检查异常。这样命名的原因是编译器确保(检查)这些异常被捕获或列在方法的throws
子句中(参见“throws
语句”部分)。这种设计迫使程序员做出有意识的决定,要么捕获受检的异常,要么通知方法的客户端该异常可能由方法引发,并且必须由客户端处理(处理)。以下是一些受检异常的示例:
ClassNotFoundException
:当尝试用Class
类的forName()
方法加载使用其字符串名称的类失败时抛出CloneNotSupportedException
:当代码试图克隆未实现Cloneable
接口的对象时抛出NoSuchMethodException
:代码没有调用方法时抛出
并非所有的异常都存在于java.lang
包中。许多其他包包含与包支持的功能相关的异常。例如,java.util.MissingResourceException
运行时异常和java.io.IOException
检查异常。
尽管不是被迫的,程序员也经常捕捉运行时(非受检的)异常,以便更好地控制程序流,使应用的行为更稳定和可预测。顺便说一下,所有的错误都是运行时(非受检的)异常,但是,正如我们已经说过的,通常不可能以编程方式处理它们,因此捕捉java.lang.Error
类的后代是没有意义的。
当在try
块中抛出异常时,它将控制流重定向到第一个catch
子句。如果没有可以捕获异常的catch
块(但是finally
块必须就位),异常会一直向上传播并从方法中传播出去。如果有多个catch
子句,编译器会强制您排列它们,以便子异常列在父异常之前。让我们看看下面的例子:
void someMethod(String s){
try {
method(s);
} catch (NullPointerException ex){
//do something
} catch (Exception ex){
//do something else
}
}
在上例中,由于NullPointerException
扩展RuntimeException
,而RuntimeException
又扩展Exception
,所以将具有NullPointerException
的catch
块放置在具有Exception
的块之前。我们甚至可以实现以下示例:
void someMethod(String s){
try {
method(s);
} catch (NullPointerException ex){
//do something
} catch (RuntimeException ex){
//do something else
} catch (Exception ex){
//do something different
}
}
第一个catch
子句只包含NullPointerException
。其他扩展了RuntimeException
的异常将被第二个catch
子句捕获。其余的异常类型(所有选中的异常)将被最后一个catch
块捕获。请注意,这些catch
子句中的任何一个都不会捕捉到错误。为了捕获它们,应该为Error
(在任何位置)或Throwable
(在上一个示例中的最后一个catch
子句之后)添加catch
子句,但是程序员通常不会这样做,并且允许错误一直传播到 JVM 中。
每个异常类型都有一个catch
块,这允许我们提供一个特定于异常类型的处理。但是,如果在异常处理中没有差异,则可以只使用一个具有Exception
基类的catch
块来捕获所有类型的异常:
void someMethod(String s){
try {
method(s);
} catch (Exception ex){
//do something
}
}
如果没有一个子句捕捉到异常,则会进一步抛出异常,直到它被某个方法调用者中的try...catch
语句处理,或者传播到应用代码之外。在这种情况下,JVM 终止应用并退出。
添加一个finally
块不会改变所描述的行为。如果存在,不管是否生成了异常,它总是被执行。finally
块通常用于释放资源:关闭数据库连接、文件等。但是,如果资源实现了Closeable
接口,那么最好使用资源尝试语句,该语句允许自动释放资源。下面是如何使用 Java7 实现的:
try (Connection conn = DriverManager.getConnection("dburl",
"username", "password");
ResultSet rs = conn.createStatement()
.executeQuery("select * from some_table")) {
while (rs.next()) {
//process the retrieved data
}
} catch (SQLException ex) {
//Do something
//The exception was probably caused by incorrect SQL statement
}
本例创建数据库连接,检索数据并对其进行处理,然后关闭(调用close()
方法)conn
和rs
对象。
Java9 增强了资源尝试语句功能,允许创建表示try
块外资源的对象,然后在资源尝试语句中使用这些对象,如下所示:
void method(Connection conn, ResultSet rs) {
try (conn; rs) {
while (rs.next()) {
//process the retrieved data
}
} catch (SQLException ex) {
//Do something
//The exception was probably caused by incorrect SQL statement
}
}
前面的代码看起来更简洁,尽管在实践中,程序员更喜欢在同一上下文中创建和释放(关闭)资源。如果这也是您的偏好,请考虑将throws
语句与资源尝试语句结合使用。
前面使用资源尝试语句的示例可以使用在相同上下文中创建的资源对象重新编写,如下所示:
Connection conn;
ResultSet rs;
try {
conn = DriverManager.getConnection("dburl", "username", "password");
rs = conn.createStatement().executeQuery("select * from some_table");
} catch (SQLException e) {
e.printStackTrace();
return;
}
try (conn; rs) {
while (rs.next()) {
//process the retrieved data
}
} catch (SQLException ex) {
//Do something
//The exception was probably caused by incorrect SQL statement
}
我们必须处理SQLException
,因为它是一个受检异常,getConnection()
、createStatement()
、executeQuery()
和next()
方法在它们的throws
子句中声明它,下面是一个例子:
Statement createStatement() throws SQLException;
这意味着该方法的作者警告该方法的用户它可能抛出这样一个异常,并强制他们要么捕获异常,要么在方法的throws
子句中声明异常。在前面的例子中,我们选择捕捉它,并且必须使用两个try...catch
语句。或者,我们也可以在throws
子句中列出异常,从而有效地将异常处理的负担推给我们方法的用户,从而消除混乱:
void throwsDemo() throws SQLException {
Connection conn = DriverManager.getConnection("url","user","pass");
ResultSet rs = conn.createStatement().executeQuery("select * ...");
try (conn; rs) {
while (rs.next()) {
//process the retrieved data
}
} finally { }
}
我们去掉了catch
子句,但是 Java 语法要求catch
或finally
块必须跟在try
块后面,所以我们添加了一个空的finally
块
throws
条款允许但不要求我们列出非受检异常的情况。添加非受检的异常不会强制方法的用户处理它们。
最后,如果方法抛出几个不同的异常,可以列出基本的Exception
异常类,而不是列出所有异常。这将使编译器感到高兴,但这并不是一个好的实践,因为它隐藏了方法用户可能期望的特定异常的细节。
请注意,编译器不会检查方法体中的代码可以引发何种异常。因此,可以在throws
子句中列出任何异常,这可能会导致不必要的开销。如果程序员错误地在throws
子句中包含一个受检异常,而该异常从未被方法实际抛出,那么该方法的用户可能会为它编写一个从未执行过的catch
块
throw
语句允许抛出程序员认为必要的任何异常。人们甚至可以创建自己的异常。要创建选中的异常,请扩展java.lang.Exception
类:
class MyCheckedException extends Exception{
public MyCheckedException(String message){
super(message);
}
//add code you need to have here
}
另外,要创建非受检的异常,请扩展java.lang.RunitmeException
类,如下所示:
class MyUncheckedException extends RuntimeException{
public MyUncheckedException(String message){
super(message);
}
//add code you need to have here
}
注意注释这里需要添加代码。您可以像向任何其他常规类一样向自定义异常添加方法和属性,但程序员很少这样做。最佳实践甚至明确建议避免使用异常来驱动业务逻辑。异常应该是顾名思义,只包括异常的,非常罕见的情况。
但是,如果您需要宣布异常情况,请使用throw
关键字和new
运算符来创建并触发异常对象的传播。以下是几个例子:
throw new Exception("Something happend");
throw new RunitmeException("Something happened");
throw new MyCheckedException("Something happened");
throw new MyUncheckedException("Something happened");
甚至可以按如下方式抛出null
:
throw null;
上述语句的结果与此语句的结果相同:
throw new NullPointerException;
在这两种情况下,非受检的NullPointerException
的对象开始在系统中传播,直到它被应用或 JVM 捕获。
有时,程序员需要知道代码中是否发生了特定的情况,即使应用已经部署到生产环境中。同时,没有必要一直运行检查。这就是分支assert
语句派上用场的地方。举个例子:
public someMethod(String s){
//any code goes here
assert(assertSomething(x, y, z));
//any code goes here
}
boolean assertSomething(int x, String y, double z){
//do something and return boolean
}
在前面的代码中,assert()
方法从assertSomething()
方法获取输入,如果assertSomething()
方法返回false
,程序停止执行。
只有当 JVM 使用-ea
选项运行时,assert()
方法才会执行。-ea
标志不应该在生产中使用,除非可能暂时用于测试目的,因为它会产生影响应用性能的开销。
当应用可以自动执行某些操作来修改或解决问题时,选中的异常被设计为用于可恢复条件。实际上,这种情况并不经常发生。通常,当捕捉到异常时,应用会记录栈跟踪并中止当前操作。根据记录的信息,应用支持团队修改代码以解决未知情况或防止将来发生这种情况
每个应用都是不同的,因此最佳实践取决于特定的应用需求、设计和上下文。一般来说,在开发社区中似乎有一个协议,即避免使用检查过的异常,并尽量减少它们在应用代码中的传播。以下是其他一些被证明是有用的建议:
- 始终捕获靠近源的所有受检异常
- 如果有疑问,也可以在源代码附近捕获非受检的异常
- 尽可能靠近源处理异常,因为它是上下文最具体的地方,也是根本原因所在的地方
- 除非必须,否则不要抛出选中的异常,因为您强制为可能永远不会发生的情况生成额外代码
- 如果有必要,将第三方的受检异常转换为非受检的异常,方法是将它们作为
RuntimeException
重新抛出,并显示相应的消息 - 除非必须,否则不要创建自定义异常
- 除非必须,否则不要使用异常处理机制来驱动业务逻辑
- 通过使用消息系统和可选的枚举类型(而不是使用异常类型)来定制泛型
RuntimeException
,以传达错误的原因
本章向读者介绍了 Java 异常处理框架,了解了两种异常:受检和非受检(运行时),以及如何使用try-catch-finally
和throws
语句处理它们。读者还学习了如何生成(抛出)异常以及如何创建自己的(自定义)异常。本章最后介绍了异常处理的最佳实践。
在下一章中,我们将详细讨论字符串及其处理,以及输入/输出流和文件读写技术。
-
什么是栈跟踪?选择所有适用项:
- 当前加载的类的列表
- 当前正在执行的方法的列表
- 当前正在执行的代码行的列表
- 当前使用的变量列表
-
有哪些异常?选择所有适用的选项:
- 编译异常
- 运行时异常
- 读取异常
- 写入异常
-
以下代码的输出是什么?
try {
throw null;
} catch (RuntimeException ex) {
System.out.print("RuntimeException ");
} catch (Exception ex) {
System.out.print("Exception ");
} catch (Error ex) {
System.out.print("Error ");
} catch (Throwable ex) {
System.out.print("Throwable ");
} finally {
System.out.println("Finally ");
}
- 下列哪种方法编译时不会出错?
void method1() throws Exception { throw null; }
void method2() throws RuntimeException { throw null; }
void method3() throws Throwable { throw null; }
void method4() throws Error { throw null; }
- 下列哪个语句编译时不会出错?
throw new NullPointerException("Hi there!"); //1
throws new Exception("Hi there!"); //2
throw RuntimeException("Hi there!"); //3
throws RuntimeException("Hi there!"); //4
- 假设
int x = 4
,下列哪条语句编译时不会出错?
assert (x > 3); //1
assert (x = 3); //2
assert (x < 4); //3
assert (x = 4); //4
- 以下列表中的最佳实践是什么?
- 始终捕获所有异常和错误
- 总是捕获所有异常
- 从不抛出非受检的异常
- 除非必须,否则不要抛出受检的异常