Java中的异常处理

1.介绍

1.1 什么是异常处理?

为了更好地理解异常和异常处理,让我们做一个实际的比较。

想象一下,我们在网上订购了一种产品,但在途中,却出现了交货失败的情况。一个好的公司可以处理这个问题,并优雅地重新路由我们的包,使它仍然按时到达。

同样,在Java中,代码在执行指令时也会遇到错误。良好的异常处理可以处理错误并优雅地重新路由程序,给用户一个积极的体验。

1.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 19

如果不处理此异常,程序可能会完全停止运行!我们需要确保我们的代码在出错时有一个计划。

还要注意异常还有一个好处,那就是堆栈跟踪本身。由于这种堆栈跟踪,我们通常可以在不需要附加调试器的情况下找出有问题的代码。

2.异常层级

最终,异常只是Java对象,它们都是从Throwable继承而来的:

在这里插入图片描述
异常情况主要有三类:

  • 检查时异常(Checked Exception)
  • 未检查的异常/运行时异常( (unchecked) RuntimeException)
  • 错误(Errors)

2.1 检查时异常

检查时异常是Java编译器要求我们处理的异常。我们要么以声明方式将异常抛出调用堆栈,要么自己处理。这两个问题马上就要谈了。Oracle的文档告诉我们,当我们可以期望方法的调用方能够恢复时,应该使用checked异常。

检查异常的两个例子是IOException和ServletException。

2.2 未检查的异常

未检查的异常是Java编译器不需要我们处理的异常。简单地说,如果我们创建一个继承RuntimeException的异常,它将被取消编译时检查;否则,将进行检查。虽然这听起来很方便,但Oracle的文档告诉我们这两个概念都有很好的理由,比如区分情景异常(检查时异常)和使用时异常(未检查异常)。

未检查异常的一些示例有NullPointerException、IllegalArgumentException和SecurityException。

2.3 错误(Errors)

错误表示严重且通常不可恢复的情况,如库不兼容、无限递归或内存泄漏。即使它们不继承RuntimeException,它们也是未经检查的。在大多数情况下,处理、实例化或继承错误对我们来说是很奇怪的。通常,我们想让它们一路传播。两个错误示例是StackOverflowerError和OutOfMemoryError。

3.处理异常

在Java API中,有很多地方会出错,其中一些地方会在注释或Javadoc中标记为异常:

/**
 * @exception FileNotFoundException ...
 */
public Scanner(String fileName) throws FileNotFoundException {
   // ...
}

如上所述,当我们调用这些“有风险”的方法时,我们必须处理检查时异常,并且我们可以处理未检查的异常。Java为我们提供了几种方法:

3.1 抛出(throws)

“处理”异常的最简单方法是重新抛出异常:

public int getPlayerScore(String playerFile)
  throws FileNotFoundException {
 
    Scanner contents = new Scanner(new File(playerFile));
    return Integer.parseInt(contents.nextLine());
}

因为FileNotFoundException是一个检查时异常,这是满足编译器要求的最简单的方法,但是这确实意味着任何调用我们方法的人现在也需要处理它!

parseInt可以抛出NumberFormatException,但是因为它是运行时异常,所以我们不需要处理它。

3.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;
    }
}

3.3 利用finally语句块

有时我们需要在不管是否发生异常时执行某些代码,这就是finally关键字出现的地方。到目前为止,在我们的示例中,有一个可怕的bug潜伏在阴影中,那就是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也是一个“有风险”的方法,所以我们还需要捕捉它的异常!这看起来可能相当复杂,但我们需要每一部分来正确处理可能出现的每个潜在问题。

3.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声明中放置可自动关闭的引用时,我们不需要自己关闭资源。不过,我们仍然可以使用finally块来进行任何其他类型的清理。

3.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允许我们单独处理子类异常,记住要将它们放在捕获列表的更高位置。

3.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;
    }
}

4.抛出异常

如果我们不想自己处理异常,或者我们想为其他人生成要处理的异常,那么我们需要熟悉throw关键字。

假设我们自己创建了以下检查时异常:

public class TimeoutException extends Exception {
    public TimeoutException(String message) {
        super(message);
    }
}

我们有一种可能需要很长时间才能完成的方法:

public List<Player> loadAllPlayers(String playersFile) {
    // ... potentially long operation
}

4.1 抛出一个检查时异常

就像从一个方法返回一样,我们可以在任何点抛出。当然,当我们试图表明出了问题时,我们应该抛出:

public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
    while ( !tooLong ) {
        // ... potentially long operation
    }
    throw new TimeoutException("This operation took too long");
}

因为TimeoutException是检查时异常,所以我们还必须在签名中使用throws关键字,以便方法的调用者知道如何处理它。

4.2 抛出一个未检查异常

如果我们想做一些事情,比如验证输入,我们可以使用未检查的异常:

public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
    if(!isFilenameValid(playersFile)) {
        throw new IllegalArgumentException("Filename isn't valid!");
    }
   
    // ...
}

因为IllegalArgumentException是未检查异常,所以我们不必注释该方法,尽管欢迎使用。

有些人将该方法注释为一种文档形式。

4.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);
    }
}

这对于将许多不同的异常合并为一个异常来说是很好的。

4.4 重新抛出Throwable或Exception

现在来看一个特例。如果给定代码块唯一可能引发的异常是未检查的异常,那么我们可以捕获并重新抛出Throwable后Exception,而无需将它们添加到我们的方法签名中:

public List<Player> loadAllPlayers(String playersFile) {
    try {
        throw new NullPointerException();
    } catch (Throwable t) {
        throw t;
    }
}

虽然很简单,但是上面的代码不能抛出检查时异常。因此,即使我们重新抛出checked异常,也不必用throws子句标记签名。这对于代理类和方法很方便。

4.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抛出不同的异常。

简单地说,子类可以比它们的超类抛出更少的检查异常,但不会更多。

5.反面教材

5.1 吞没异常

现在,还有一种方法可以让编译器满意:

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch (Exception e) {} // <== catch and swallow
    return 0;
}

现在,还有一种方法我们可以满足上面的要求,那就是吞下一个异常。大多数情况下,这样做对我们来说有点苛刻,因为它不能解决问题,而且它会阻止其他代码去解决问题。

有些时候,我们确信一个检查时异常永远不会发生。在这种情况下,我们至少应该添加一条评论,说明我们故意吞没了exception。

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抛出去。因此,我们丢失了调用者或操作者可以用来诊断问题的重要信息。我们最好这样做:

5.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语言规范:

If execution of the try block completes abruptly for any other reason R, then the finally block is executed, and then there is a choice.

If the finally block completes normally, then the try statement completes abruptly for reason R.

If the finally block completes abruptly for reason S, then the try statement completes abruptly for reason S (and reason R is discarded).

5.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();
    }
}

5.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
    }		
}

这很奇怪,因为代码试图使用异常来进行流控制,而不是错误处理。

6.常见异常和错误

6.1 检查时异常

  • IOException:此异常通常表示网络、文件系统或数据库中的某些内容出现故障。

6.2 运行时异常

  • ArrayIndexOutOfBoundsException - 数组下标越界异常
  • ClassCastException - 类转换异常
  • IllegalArgumentException - 非法参数异常
  • IllegalStateException - 非法状态异常
  • NullPointerException - 空指针异常
  • NumberFormatException - 数字格式化异常

6.3 错误

  • StackOverflowError - 堆栈溢出错误
  • NoClassDefFoundError - 未找到类定义错误
  • OutOfMemoryError - 内存溢出错误

7.本文代码

示例代码

posted @ 2021-06-22 11:06  一锤子技术员  阅读(5)  评论(0编辑  收藏  举报  来源