Java 异常处理

1. 概述

在本文中, 我们将介绍Java 中异常处理的基础知识,及其一些常见的坑.

2. 前置知识

2.1. 什么是异常?

为了更好的理解异常和处理异常,让我们看一个真实的场景. 想象一下我们在线购物,但是途中交付失败,优秀的公司可以优雅的处理此问题,并重新安排我们的包裹,保证包裹能够按时送达 . 同样,在Java中,代码在执行我们指令的时候也可能会遇到错误,出色的exception 处理 可以处理错误,并优雅的重新路由程序,从而给用户带来良好的体验.

2.2. 为什么要使用异常?

我们通常在理想得环境中编写代码: 文件系统始终包含我们得文件, 网络状况永远良好, 并且 JVM 始终具有足够得内存. 有时我们称之为 “幸福之路”. 但是,在生产环境中, 文件系统可能会损坏, 网络崩溃, 并且JVM 内存不足. 我们代码得优劣取决于如何处理这些 “不愉快得道路”. 我们必须处理这些情况,因为它们会对应用程序得流程产生负面影响,并产生exceptions:

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

如果不处理此异常, 则正常运行得程序可能会完全停止运行! 我们需要确保代码在万一有异常得情况下能够正常运行. 需要说明得是,异常也有好处, 那就是异常堆栈本身. 因为堆栈具有溯源功能, 因此我们经常可以精确定位有问题得代码,而无需使用调试器.

3. Exception 层次结构

exceptions 终归也是属于Java对象, 所有异常都是从 Throwable 拓展而来:

              ---> Throwable <--- 
              |    (checked)     |
              |                  |
              |                  |
      ---> Exception           Error
      |    (checked)        (unchecked)
      |
RuntimeException
  (unchecked)

异常主要分为以下三类:

  • Checked exceptions
  • Unchecked exceptions / Runtime exceptions
  • Errors

Runtime and unchecked exceptions 是同一回事. 我们在使用中可以对它们进行互换.

3.1. Checked Exceptions

Checked exceptions 是Java 编译器要求我们必须处理的 exceptions . 我们要么声明式的将异常抛出到调用堆栈中, 要么我们必须自己处理它. 稍后我们将详细讨论这两个方面. Oracle's documentation 告诉我们,当我们期望方法调用者能够合理的恢复时, 请使用 checked exceptions . IOExceptionServletException 是 checked exceptions的示例.

3.2. Unchecked Exceptions

Unchecked exceptions 是Java编译器不要求我们处理的异常. 简而言之,如果我们创建一个继承自 RuntimeException 的异常, 则属于Unchecked exceptions; 反之, 属于Checked exceptions. 尽管听起来好像比较简单, Oracle's documentation 很好的讲解了这两个概念, 例如区别 error (checked) 和 error (unchecked). NullPointerException, IllegalArgumentException,SecurityException 属于 unchecked exceptions 的示例.

3.3. Errors

Errors 表示严重且通常不可恢复的情况, 例如库不兼容, 无限递归, 内存泄漏 . 即使它们不是继承自 RuntimeException, 编译器也不会对其进行检查. 在大多数情况下,我们不需要处理,实例化或是继承 Errors. 通常,我们希望它们能够一直向上传递. StackOverflowErrorOutOfMemoryError 属于 errors 的示例.

4. Exceptions 处理

在 Java API 中, 有很多地方可能会出错, 并且其中一些地方会在签名或Javadoc 中都会声明exceptions :

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

如上所述, 当我们调用这些 “risky” 方法时, 我们 必须 处理 checked exceptions, 并且 可能 会处理 unchecked exceptions. Java 提供了处理这些问题的几种方法:

4.1. throws

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

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

因为 FileNotFoundException 是一个 checked exception, 所以重新抛出去是满足编译器最简单的方法, 但是 这意味着调用我们方法的其他人也必须要处理它! parseInt 会抛出 NumberFormatException, 但是因为它是 unchecked, 所以我们不是必须要处理它.

4.2. try-catch

如果想要尝试自己处理异常, 我们可以使用 try-catch 语句块. 我们可以通过重新抛出我们的异常来处理它(和throws不同的是,抛出的异常类型我们可以自定义):

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 默认情况下不会将文件句柄返回给操作系统 . 当然,无论我们是否能够读取文件, 我们都希望确保流的连接释放. 下面让我们先看看这种“lazy”方式:

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 同样也是一个 “risky” 方法, 我们同样需要捕获它的 exception! 这看起来很复杂, 但是我们需要确保每一次的调用都能正确的处理可能出现的潜在问题。

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, 是因为它 extends 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. Throwing Exceptions

如果我们不想自己处理异常,或者想要自定义异常以供其他人处理,那么我们需要了解 throw 关键字. 假设我们自定义 checked exception :

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

而且我们有一个方法可能需要花费很长时间来执行:

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

5.1. Throwing a Checked Exception

就像从方法中返回一样, 我们可以随时 throw . 当然,当我们需要表示可能出现的问题时,我们应该抛出:

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

因为 TimeoutException 是 checked Exception, 所以我们还必须在签名中使用 throws 关键字,以便我们方法的调用者知道如何去处理它.

5.2. Throwing an Unchecked Exception

如果我们想执行如输入验证之类的操作, 则可以使用unchecked exception :

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

因为 IllegalArgumentException 是 unchecked exception, 所以我们不必标记该方法 , 虽然同样能够标记会更好一点 .

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

现在又一种特殊情况. 如果给定代码块可能引发的唯一可能的异常时 unchecked exceptions, 那么我们可以捕获并重新抛出 ThrowableException ,而无需将它们添加到方法签名上:

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

虽然简单, 但上面的代码无法抛出checked exception, 因此,即使我们重新抛出 checked exception, 我们也不必使用throws 子句标记签名. 这对于代理类和方法很方便. 更多详情请看 这里.

5.5. Inheritance

当我们使用 throws 关键字标记方法时, 它将影响子类如何覆盖我们的方法. 在我们的方法 throws a checked exception 的情况下:

public class Exceptions {
    public List<Player> loadAllPlayers(String playersFile) 
      throws TimeoutException {
        // ...
    }
}

子类可以具有 “小风险” signature:

public class FewerExceptions extends Exceptions {   
    @Override
    public List<Player> loadAllPlayers(String playersFile) {
        // overridden
    }
}

但不是 “多风险” signature:

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 抛出一个不同的异常 . 简而言之,子类可以抛出的 checked exceptions 比父类少,但不能更多.

6. Anti-Patterns

6.1. 吞噬 Exceptions

现在, 我们有一种可以满足编译器的另一种处理异常的方式:

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

以上称之为吞噬 exception. 在大多数情况下, 这样做对我们而言意义不大,因为它不能解决问题, 并且也使得其他使用该方法的代码也同样无法解决问题. 有时候,我们有信心代码将永远不会发生 checked 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;
}

我们至少通过将错误写到某个地方供以后诊断,改善了完全吞噬异常的弊端. 不过,最好使用logger来记录:

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. Using return in a finally Block

吞噬异常的另一种方式是在 finally 块中 return . 这是不好的, 因为通过突然返回 , the JVM 丢弃该异常, 即使该异常是由我们代码抛出的:

public int getPlayerScore(String playerFile) {
    int score = 0;
    try {
        throw new IOException();
    } finally {
        return score; // <== the IOException is dropped
    }
}

根据 Java Language Specification:

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).

6.3. Using throw in a finally Block

与在 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. Using throw as a 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. 常见 Exceptions 和 Errors

一下是我们经常遇到的一些常见 exceptions 和 errors :

7.1. Checked Exceptions

  • IOException – 此异常通常表示网络, 文件系统, 或 数据库发生故障.

7.2. RuntimeExceptions

  • ArrayIndexOutOfBoundsException – 此异常表示我们试图访问不存在的数组索引, 比如试图从长度为3的数组中获取索引为5的内容.

  • ClassCastException – 此异常表示我们试图执行非法转换, 例如尝试将 String 转化为 List. 我们通常在转化之前通过 instanceof 检查来避免这种情况.

  • IllegalArgumentException – 此异常通常是方法或构造函数的参数无效.

  • IllegalStateException – 此异常表示内部状态(如对象状态)无效.

  • NullPointerException – 此异常表示我们尝试引用 null 对象. 我们通常使用 Optional 检查来避免这种这种情况.

  • NumberFormatException – 此异常表示我们试图将 String 转化为数字 , 但是字符串包含非法字符, 如将 “5f3” 转化为数字.

7.3. Errors

  • StackOverflowError – 此异常表示栈跟踪太深. 有时在大规模应用中可能会发生这种情况; 但是, 通常来说表示我们的代码中发生了无限递归.

  • NoClassDefFoundError – 此异常表示类加载失败,原因是由于类不再 classpath 下, 或静态初始化失败.

  • OutOfMemoryError – 此异常表示 JVM 没有足够的内存来分配更多的对象. 有时, 这是因为内存泄漏引起的.

8. 结语

在本文中, 我们介绍了异常处理的基础知识以及一些实践示例. 所有的代码请看这里 GitHub!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345