Java 中的异常处理
1. 概述
异常处理是 Java 编程中至关重要的一个部分,它允许我们优雅地处理程序执行过程中出现的错误。在本文内容中,我们将介绍 Java 中异常处理的基础知识以及一些陷阱。
在 Java 中,异常是一种程序运行时发生的错误或异常事件。它们可以由多种原因引起,例如:
- 无效的输入:用户输入了错误的类型或格式的数据。
- 资源不可用:无法访问文件或网络连接。
- 内存不足:程序尝试分配超出可用内存的内存。
- 算术运算错误:例如除以零或溢出。
- 数组越界:试图访问数组中不存在的元素。
2. 第一原理
2.1. 它是什么?
为了更好地理解异常和异常处理,让我们进行一个现实生活中的比较。
想象一下,我们在网上订购了一件商品,但在途中,送货出现了问题。一家好的公司可以处理这个问题,并妥善地重新安排我们的包裹,以便它仍然能准时到达。
同样,在 Java 中,代码在执行我们的指令时可能会出错。良好的异常处理可以处理错误并优雅地重新路由程序,以给用户带来积极的体验 。
2.2. 为什么要使用它?
我们通常在一个理想化的环境中编写代码:文件系统始终包含我们的文件、网络健康、JVM 始终有足够的内存。有时我们称之为“快乐路径”。
但在生产环境中,文件系统可能会损坏、网络可能会中断、JVM 内存可能会耗尽。我们的代码是否健康取决于它如何处理“不愉快的路径”。
我们必须处理这些情况,因为它们会对应用程序的流程产生负面影响并形成异常:
public static List<Player> getPlayers() throws IOException {
Path path = Paths.get("players.dat");
List<String> players = Files.readAllLines(path);
return players.stream()
.map(Player::new)
.collect(Collectors.toList());
}
此代码选择不处理 IOException , 而是将其传递到调用堆栈。在理想环境中,代码运行良好。
但是如果players.dat 丢失,生产中可能会发生什么?
Exception in thread "main" java.nio.file.NoSuchFileException: players.dat <-- players.dat file doesn't exist
at sun.nio.fs.WindowsException.translateToIOException(Unknown Source)
at sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source)
// ... more stack trace
at java.nio.file.Files.readAllLines(Unknown Source)
at java.nio.file.Files.readAllLines(Unknown Source)
at Exceptions.getPlayers(Exceptions.java:12) <-- Exception arises in getPlayers() method, on line 12
at Exceptions.main(Exceptions.java:19) <-- getPlayers() is called by main(), on line
如果不处理此异常,原本健康的程序可能会完全停止运行! 我们需要确保我们的代码有一个应对出现问题的计划。
还要注意异常的另一个好处,那就是堆栈跟踪本身。由于这个堆栈跟踪,我们通常可以精确定位有问题的代码,而无需附加调试器。
3. 异常层次
归根结底,异常 只是 Java 对象,它们都是从 Throwable扩展而来的:
---> Throwable <---
| (checked) |
| |
| |
---> Exception Error
| (checked) (unchecked)
|
RuntimeException
(unchecked)
特殊情况主要有三类:
- 检查异常
- 未检查异常/运行时异常
- 错误
运行时异常和未检查异常指的是同一件事。我们经常可以互换使用它们。
3.1. 检查异常 Checked Exception
检查异常是 Java 编译器要求我们处理的异常。我们必须声明性地将异常抛出到调用堆栈,或者我们必须自己处理它。稍后将详细介绍这两种方法。
Oracle 的文档告诉我们,当我们可以合理地预期我们的方法的调用者能够恢复时,使用已检查的异常。
已检查异常的两个示例是IOException和 ServletException。
3.2. 未检查异常 Unchecked Exception
未检查异常是 Java 编译器不要求我们处理的异常。
简而言之,如果我们创建一个扩展 RuntimeException的异常,它将不会被检查;否则,它将被检查。
虽然这听起来很方便,但是Oracle 的文档告诉我们这两个概念都有充分的理由,例如区分情境错误(已检查)和使用错误(未检查)。
未经检查的异常的一些示例是 *NullPointerException、 * IllegalArgumentException 和 SecurityException。
3.3. 错误 error
错误表示严重且通常无法恢复的情况,例如库不兼容、无限递归或内存泄漏。
尽管它们没有扩展 RuntimeException,但它们也是未经检查的。
在大多数情况下,处理、实例化或扩展Errors对我们来说都是很奇怪的 。通常,我们希望这些错��能够一直传播下去。
错误的两个例子是 StackOverflowError和 OutOfMemoryError。
4.处理异常
在 Java API 中,有很多地方可能出错,其中一些地方在签名或 Javadoc 中被标记为异常:
/**
* @exception FileNotFoundException ...
*/
public Scanner(String fileName) throws FileNotFoundException {
// ...
}
如前所述,当我们调用这些“有风险”的方法时,我们必须处理已检查的异常,也可以处理未检查的异常。Java 为我们提供了几种方法来做到��一点:
4.1. 抛出 throws
“处理”异常的最简单方法是重新抛出它:
public int getPlayerScore(String playerFile)
throws FileNotFoundException {
Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
}
因为FileNotFoundException 是一个已检查异常,所以这是满足编译器的最简单方法,但这确实意味着现在调用我们方法的任何人也需要处理它!
parseInt可能抛出NumberFormatException,但是因为未经检查,所以我们不需要处理它。
4.2. try-catch
如果我们想自己尝试处理异常,我们可以使用 try-catch块。我们可以通过重新抛出异常来处理它:
public int getPlayerScore(String playerFile) {
try {
Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException noFile) {
throw new IllegalArgumentException("File not found");
}
}
或者通过执行恢复步骤:
public int getPlayerScore(String playerFile) {
try {
Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch ( FileNotFoundException noFile ) {
logger.warn("File not found, resetting score.");
return 0;
}
}
4.3.Finally
现在,有时我们需要执行一些代码,无论是否发生异常,这就是 finally关键字的作用所在。
在我们迄今为止的例子中,有一个令人讨厌的错误隐藏在阴影中,那就是 Java 默认情况下不会将文件句柄返回给操作系统。
当然,无论我们是否可以读取该文件,我们都希望确保进行适当的清理!
我们先来尝试一下“偷懒”的方法:
public int getPlayerScore(String playerFile)
throws FileNotFoundException {
Scanner contents = null;
try {
contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} finally {
if (contents != null) {
contents.close();
}
}
}
这里, finally块表示无论尝试读取文件时发生什么,我们都希望 Java 运行什么代码。
即使在 调用堆栈中抛出 FileNotFoundException ,Java 也会在执行该操作之前调用 finally的内容。
我们还可以处理异常并确保我们的资源被关闭:
public int getPlayerScore(String playerFile) {
Scanner contents;
try {
contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException noFile ) {
logger.warn("File not found, resetting score.");
return 0;
} finally {
try {
if (contents != null) {
contents.close();
}
} catch (IOException io) {
logger.error("Couldn't close the reader!", io);
}
}
}
因为close也是一种“冒险”的方法,所以我们也需要捕获它的异常!
这看起来可能相当复杂,但我们需要每个部分来正确处理可能出现的每个潜在问题。
4.4. try -with-resources
幸运的是,从 Java 7 开始,我们可以在处理扩展 AutoCloseable的内容时简化上述语法:
public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile))) {
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException e ) {
logger.warn("File not found, resetting score.");
return 0;
}
}
当我们在 try 声明中放置 AutoClosable 的引用时 ,我们就不需要自己关闭资源了。**
不过,我们仍然可以使用 finally块来执行我们想要的任何其他类型的清理。
查看我们专门介绍尝试资源的文章以了解更多信息。
4.5. 多个catch 块
有时,代码可能会引发多个异常,并且我们可以用多个 catch 块分别处理每个异常:
public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile))) {
return Integer.parseInt(contents.nextLine());
} catch (IOException e) {
logger.warn("Player file wouldn't load!", e);
return 0;
} catch (NumberFormatException e) {
logger.warn("Player file was corrupted!", e);
return 0;
}
}
如果需要的话,多次捕获使我们有机会以不同的方式处理每个异常。
还要注意的是,我们没有捕获 FileNotFoundException,这是因为它 扩展了 IOException。因为我们捕获了 IOException,所以 Java 会考虑处理它的任何子类。
但是,假设我们需要将 FileNotFoundException 与更一般的 IOException区别对待:
public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile)) ) {
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException e) {
logger.warn("Player file not found!", e);
return 0;
} catch (IOException e) {
logger.warn("Player file wouldn't load!", e);
return 0;
} catch (NumberFormatException e) {
logger.warn("Player file was corrupted!", e);
return 0;
}
}
Java 允许我们单独处理子类异常,记住将它们放在捕获列表的更高位置。
4.6. 联合 catch 块
当我们知道处理错误的方式将是相同的时,Java 7 引入了在同一个块中捕获多个异常的功能:
public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile))) {
return Integer.parseInt(contents.nextLine());
} catch (IOException | NumberFormatException e) {
logger.warn("Failed to load score!", e);
return 0;
}
}
5. 抛出异常
如果我们不想自己处理异常,或者我们想生成我们的异常让别人处理,那么我们需要熟悉 throw关键字。
假设我们有以下自己创建的检查异常:
public class TimeoutException extends Exception {
public TimeoutException(String message) {
super(message);
}
}
我们有一个可能需要很长时间才能完成的方法:
public List<Player> loadAllPlayers(String playersFile) {
// ... potentially long operation
}
5.1. 抛出已检查异常
就像从方法返回一样,我们可以在任何时候抛出 。
当然,当我们试图表明某些事情已经出错时,我们应该抛出异常:
public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
while ( !tooLong ) {
// ... potentially long operation
}
throw new TimeoutException("This operation took too long");
}
因为检查了 TimeoutException ,所以我们还必须在签名中使用throws关键字,以便我们的方法的调用者知道如何处理它。
5.2.抛出未检查异常
如果我们想做一些事情,比如验证输入,我们可以使用未经检查的异常:
public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
if(!isFilenameValid(playersFile)) {
throw new IllegalArgumentException("Filename isn't valid!");
}
// ...
}
因为IllegalArgumentException未经检查,所以我们不必标记该方法,尽管我们可以这样做。
无论如何,有些人会将该方法标记为一种文档形式。
5.3. 包装和重新抛出
我们还可以选择重新抛出我们捕获的异常:
public List<Player> loadAllPlayers(String playersFile)
throws IOException {
try {
// ...
} catch (IOException io) {
throw io;
}
}
或者进行包装并重新抛出:
public List<Player> loadAllPlayers(String playersFile)
throws PlayerLoadException {
try {
// ...
} catch (IOException io) {
throw new PlayerLoadException(io);
}
}复制
这对于将许多不同的异常合并为一个非常有用。
5.4. 重新抛出Throwable或Exception
现在讨论一个特殊情况。
如果给定代码块可能引发的唯一可能异常是未经检查的异常,那么我们可以捕获并重新抛出 Throwable 或Exception, 而无需将它们添加到我们的方法签名中:
public List<Player> loadAllPlayers(String playersFile) {
try {
throw new NullPointerException();
} catch (Throwable t) {
throw t;
}
}
虽然简单,但上述代码不能抛出已检查的异常,因此,即使我们重新抛出已检查的异常,我们也不必用 throws 子句标记签名。
这对于代理类和方法来说非常方便。 有关更多信息,请参见此处。
5.5. 继承
当我们用throws关键字标记方法时 ,它会影响子类如何覆盖我们的方法。
在我们的方法抛出已检查异常的情况下:
public class Exceptions {
public List<Player> loadAllPlayers(String playersFile)
throws TimeoutException {
// ...
}
}
子类可以具有“风险较低”的签名:
public class FewerExceptions extends Exceptions {
@Override
public List<Player> loadAllPlayers(String playersFile) {
// overridden
}
}
但并非“更 危险”的签名:
public class MoreExceptions extends Exceptions {
@Override
public List<Player> loadAllPlayers(String playersFile) throws MyCheckedException {
// overridden
}
}
这是因为契约是在编译时由引用类型决定的。如果我创建MoreExceptions 的实例并将其保存到 Exceptions中:
Exceptions exceptions = new MoreExceptions();
exceptions.loadAllPlayers("file");
然后 JVM 只会告诉我捕获TimeoutException ,这是错误的 , 因为我说过MoreExceptions#loadAllPlayers会引发不同的异常。
简而言之,子类可以抛出 比其超类 更少的受检异常,但不能 抛出更多。
6.反模式
6.1. 接受异常
现在,我们还有另一种方法可以满足编译器的要求:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (Exception e) {} // <== catch and swallow
return 0;
}
上述情况称为 “吞噬异常” 。大多数情况下,这样做有点不妥,因为这样做并没有解决问题 ,而且也阻止了其他代码解决问题。
有时候,我们确信某个已检查异常永远不会发生。在这种情况下,我们仍然应该至少添加一条注释,说明我们故意处理了这个异常:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
// this will never happen
}
}
我们可以“吞下”异常的另一种方法是简单地将异常打印出到错误流中:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
我们至少将错误写在某处以供以后诊断,从而稍微改善了我们的情况。
不过,我们最好使用记录器:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
logger.error("Couldn't load the score", e);
return 0;
}
}
虽然用这种方式处理异常非常方便,但我们需要确保我们不会忽略代码调用者可以用来解决问题的重要信息。
最后,当我们抛出一个新异常时,我们可能会无意中吞掉一个异常,因为没有将其作为原因:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
throw new PlayerScoreException();
}
}
这里,我们很庆幸自己向调用者发出了错误警报,但却没有将 IOException作为错误原因。 因此,我们丢失了调用者或操作员可以用来诊断问题的重要信息。
我们最好这样做:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
throw new PlayerScoreException(e);
}
}
请注意将IOException作为 PlayerScoreException的 原因的细微差别 。**
6.2. 在finally块中使用return
另一种处理异常的方法是从finally块 返回。这很糟糕,因为通过突然返回,JVM 将丢弃异常,即使它是由我们的代码抛出的:**
public int getPlayerScore(String playerFile) {
int score = 0;
try {
throw new IOException();
} finally {
return score; // <== the IOException is dropped
}
}
根据Java语言规范:
如果 try 块的执行由于任何其他原因R突然完成,则执行 finally 块,然后进行选择。
如果finally块正常完成,则 try 语句会由于原因 R 突然完成。
如果finally块由于原因 S 突然完成,则 try 语句也会由于原因 S 突然完成(并且原因 R 被丢弃)。
6.3.在finally块中使用throw
与在finally块中使用 return类似,在finally块中抛出的异常将优先于 catch 块中出现的异常。****
这将从try块中“擦除”原始异常,并且我们会丢失所有有价值的信息:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch ( IOException io ) {
throw new IllegalStateException(io); // <== eaten by the finally
} finally {
throw new OtherException();
}
}
6.4. 使用throw作为goto
有些人还陷入使用throw作为goto语句的诱惑:
public void doSomething() {
try {
// bunch of code
throw new MyException();
// second bunch of code
} catch (MyException e) {
// third bunch of code
}
}
这很奇怪,因为代码试图使用异常进行流控制而不是错误处理。
7. 常见异常和错误
以下是我们时常遇到的一些常见异常和错误:
7.1. 检查异常
- IOException—— 此异常通常表示网络、文件系统或数据库出现故障。
7.2. 运行时异常
- ArrayIndexOutOfBoundsException – 此异常意味着我们尝试访问不存在的数组索引,例如尝试从长度为 3 的数组中获取索引 5。
- ClassCastException –此异常意味着我们试图执行非法转换,例如尝试将String转换为List。我们通常可以通过在转换之前执行防御性 instanceof 检查来避免这种情况。
- IllegalArgumentException – 此异常是我们表示所提供的方法或构造函数参数之一无效的一种通用方式。
- IllegalStateException – 此异常是我们表示内部状态(如对象的状态)无效的一种通用方式。
- NullPointerException – 此异常表示我们尝试引用一个空对象。我们通常可以通过执行防御性空检查或使用 Optional 来避免它。
- NumberFormatException—— 此异常意味着我们尝试将字符串转换为数字,但字符串包含非法字符,例如尝试将“5f3”转换为数字。
7.3. 错误
- StackOverflowError – 此异常表示堆栈跟踪太大。这种情况有时会发生在大型应用程序中;然而,这通常意味着我们的代码中发生了一些无限递归。
- NoClassDefFoundError – 此异常意味着类由于不在类路径上或静态初始化失败而无法加载。
- OutOfMemoryError – 此异常意味着 JVM 没有更多内存可供分配给更多对象。有时,这是由于内存泄漏造成的。
8. 结论
在本文内容中,我们介绍了异常处理的基础知识以及一些好的和坏的实践示例。异常处理是Java编程中不可或缺的一部分。通过理解和应用异常处理机制,可以编写出更健壮、更可靠、更易于调试的程序。理解和处理异常对于编写健壮的 Java 程序至关重要。通过正确使用 try-catch-finally 块和自定义异常类,可以有效地捕获、处理和避免程序错误。
转载自:https://juejin.cn/post/7389651690297196555