1. 概述

本文深入讲解 Java 异常处理的核心机制,涵盖基础概念、常见踩坑点以及最佳实践。目标是帮助有经验的开发者快速回顾关键知识点,并避免在实际项目中掉进常见陷阱。

2. 基本原则

2.1 什么是异常处理?

我们可以用一个生活中的例子类比:你在网上下单,但物流途中出了问题。靠谱的公司会自动重新调度包裹,最终仍能准时送达——这就是“异常处理”。

在 Java 中,程序执行过程中也可能出现各种问题(如文件缺失、网络中断)。良好的异常处理机制能让程序“优雅降级”或“自动恢复”,而不是直接崩溃,从而保障用户体验。

2.2 为什么要用异常处理?

我们写代码时往往假设一切正常:文件存在、网络通畅、内存充足——这叫“快乐路径(happy path)”。但在生产环境,这些假设随时可能被打破。

比如下面这段代码:

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
    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 Exceptions.getPlayers(Exceptions.java:12)
    at Exceptions.main(Exceptions.java:19)

关键点

  • 不处理异常可能导致整个程序中断
  • 异常自带的 stack trace 是调试利器,能快速定位问题源头

3. 异常体系结构

所有异常都继承自 Throwable,其继承关系如下:

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

Java 将异常分为三类:

  • Checked Exceptions(检查型异常)
    编译器强制要求处理,否则编译不通过。适用于调用方可能恢复的场景,如 IOExceptionSQLException

  • Unchecked Exceptions / Runtime Exceptions(非检查型异常)
    继承自 RuntimeException,编译器不强制处理。通常表示程序逻辑错误,如 NullPointerExceptionIllegalArgumentException

  • Errors(错误)
    表示严重、不可恢复的问题,如 OutOfMemoryErrorStackOverflowError。一般不应捕获。

⚠️ 注意RuntimeExceptionError 都属于 unchecked 类型,但语义完全不同,不要混淆。

4. 异常处理方式

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 异常,必须处理或声明
  • NumberFormatException 是 unchecked,无需显式声明

缺点:把问题甩锅给调用方,可能造成异常层层上抛。

4.2 try-catch

自己捕获并处理异常:

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

无论是否发生异常,都会执行的代码块,常用于资源释放:

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

⚠️ 经典踩坑close() 方法本身也可能抛出 IOException,需要再次 try-catch:

} finally {
    try {
        if (contents != null) {
            contents.close();
        }
    } catch (IOException io) {
        logger.error("Couldn't close the reader!", io);
    }
}

代码变得冗长,容易出错。

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

优点

  • 自动调用 close()
  • 更简洁、更安全
  • 即使 try 块抛异常,资源也能正确释放

📌 建议:凡是涉及 IO、数据库连接等资源操作,优先使用 try-with-resources。

4.5 多个 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;
    }
}

⚠️ 继承顺序陷阱:子类异常必须放在父类前面:

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

❌ 如果反过来,FileNotFoundException 永远不会被捕获。

4.6 多异常合并捕获(Union Catch)

如果多个异常处理逻辑相同,可用 | 合并:

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. 抛出异常

除了处理异常,我们还需要主动抛出异常来传递错误信息。

5.1 抛出检查型异常

自定义 checked 异常需继承 Exception

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

方法签名需声明 throws

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

5.2 抛出非检查型异常

用于参数校验等逻辑错误:

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

无需在方法签名中声明,但可加作文档提示。

5.3 包装并重新抛出(Exception Wrapping)

将底层异常包装为业务异常,简化调用方处理:

public List<Player> loadAllPlayers(String playersFile) 
  throws PlayerLoadException {
    try { 
        // ...
    } catch (IOException io) {         
        throw new PlayerLoadException(io); // 包装原始异常
    }
}

好处:隐藏技术细节,暴露业务语义。

5.4 重新抛出 ThrowableException

特殊技巧:如果 try 块只可能抛出 unchecked 异常,可安全地 rethrow Throwable 而无需声明:

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

📌 适用场景:代理方法、Lambda 表达式中绕过 checked 异常限制。

5.5 继承与异常声明

子类重写方法时,对 throws 有严格限制:

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

允许:子类不抛出任何 checked 异常

public class FewerExceptions extends Exceptions {    
    @Override
    public List<Player> loadAllPlayers(String playersFile) {
        // OK: 更少风险
    }
}

禁止:子类抛出额外的 checked 异常

public class MoreExceptions extends Exceptions {        
    @Override
    public List<Player> loadAllPlayers(String playersFile) throws MyCheckedException {
        // 编译错误!不能增加 throws
    }
}

总结:子类方法只能抛出父类方法声明异常的子集(或不抛)。

6. 常见反模式(Anti-Patterns)

6.1 吞噬异常(Swallowing Exceptions)

最典型的错误:捕获异常却不处理:

catch (Exception e) {} // ❌ 完全忽略

或只打印 stack trace:

catch (Exception e) {
    e.printStackTrace(); // ❌ 日志不规范,难以追踪
}

正确做法

  • 使用日志框架记录
  • 必要时包装后重新抛出
  • 真的确定不会发生时,加注释说明:
catch (IOException e) {
    // 此处 IO 操作实际来自内存字符串,不可能抛出 IOException
}

错误包装:丢失原始异常信息

catch (IOException e) {
    throw new PlayerScoreException(); // ❌ 原因丢失
}

正确包装

catch (IOException e) {
    throw new PlayerScoreException(e); // ✅ 保留 cause
}

6.2 在 finally 中使用 return

会屏蔽 try 块中的异常:

try {
    throw new IOException();
} finally {
    return 0; // ❌ IOException 被丢弃
}

⚠️ 后果:调用方永远无法感知真实异常。

6.3 在 finally 中使用 throw

会覆盖 catch 块中抛出的异常:

try {
    // ...
} catch (IOException io) {
    throw new IllegalStateException(io); // ❌ 被 finally 吃掉
} finally {
    throw new OtherException();
}

结果:原始 IOException 信息完全丢失。

6.4 用 throwgoto 使用

滥用异常做流程控制:

try {
    // code A
    throw new MyException();
    // code B (never reached)
} catch (MyException e) {
    // code C
}

问题:违背异常设计初衷,性能差,代码难读。应使用正常控制流(if/while 等)。

7. 常见异常与错误

7.1 检查型异常(Checked Exceptions)

  • IOException:IO 操作失败(文件、网络、序列化等)

7.2 运行时异常(RuntimeExceptions)

异常 常见原因 建议
ArrayIndexOutOfBoundsException 数组越界 使用增强 for 循环或边界检查
ClassCastException 类型转换错误 instanceof 防御性检查
IllegalArgumentException 参数非法 方法入口校验
IllegalStateException 对象状态不合法 状态机管理
NullPointerException 访问 null 对象 使用 Optional 或判空
NumberFormatException 字符串转数字失败 预校验或 try-catch

7.3 错误(Errors)

  • StackOverflowError:递归过深 → 检查递归终止条件
  • NoClassDefFoundError:类加载失败 → 检查 classpath 或静态块异常
  • OutOfMemoryError:内存不足 → 分析堆 dump,排查内存泄漏

8. 总结

异常处理不是“能跑就行”,而是体现代码健壮性和可维护性的关键。核心原则:

推荐做法

  • 使用 try-with-resources 管理资源
  • 合理使用 checked/unchecked 异常
  • 包装异常时保留 cause
  • 使用日志记录异常上下文

避免踩坑

  • 不要吞噬异常
  • 不要在 finally 中 return/throw
  • 不要用异常做流程控制

所有示例代码均可在 GitHub 获取:https://github.com/eugenp/tutorials/tree/master/core-java-modules/core-java-exceptions


原始标题:Exception Handling in Java