throw、throws 与异常传播 | JavaSE

throw、throws 与异常传播

一、学习目标

完成本章后,你应该能够:

  • 能够区分 throwthrows 的语法位置和职责。
  • 能够使用 throw 主动创建并抛出异常对象。
  • 能够使用 throws 声明方法可能向调用者传播的异常。
  • 能够解释异常从当前方法向调用者逐层传播的过程。
  • 能够理解 Checked Exception 与 RuntimeException 在 throws 上的编译期差异。
  • 能够分析多层方法调用中的异常传播路径。
  • 能够判断一个异常应该“当前层处理”还是“继续向上层传播”。
  • 能够避免 throws Exceptionthrows Throwable 等过度宽泛的异常声明。

二、核心知识

2.1 从“捕获异常”到“传播异常”

上一章学习了:

try {
    // 可能发生异常的代码
} catch (Exception e) {
    // 当前层处理异常
}

这代表:

异常发生
    ↓
当前方法捕获
    ↓
当前方法负责处理

但实际开发中,并不是每一个方法都知道:

发生异常以后到底应该怎么办。

例如:

Controller
    ↓
UserService
    ↓
UserRepository
    ↓
读取文件 / 数据库 / 网络

假设最底层读取数据失败。

Repository 可能知道:

读取失败了

但是它并不知道最终应该:

给用户显示什么
返回什么状态码
是否允许重试
是否结束当前业务

这些决策往往应该由更上层完成。

因此 Java 还支持:

当前方法不处理
        ↓
把异常继续交给调用者

这就是:

异常传播(Exception Propagation)


2.2 throws 是什么

throws 写在:

方法声明

上。

基本格式:

public static void method() throws 异常类型 {
}

例如:

public static void readData() throws Exception {
}

它表达的意思是:

当前方法可能产生这种异常,并允许异常继续向当前方法的调用者传播。

例如:

public static void methodA() throws Exception {
    methodB();
}

如果:

methodB()

发生异常,而 methodA() 没有捕获,

就可能继续向:

methodA 的调用者

传播。


2.3 throws 写在哪里

完整方法结构:

修饰符 返回值类型 方法名(参数列表)
        throws 异常类型 {
    方法体
}

例如:

public static void parseDate()
        throws java.text.ParseException {

}

所以不要写成:

throws public static void parseDate() {
}

也不要写进方法体:

public static void parseDate() {
    throws ParseException;
}

throws 属于:

方法声明的一部分

2.4 throws 可以声明多个异常

例如:

public static void execute()
        throws IOException, ParseException {

}

多个异常之间:

使用逗号分隔

表示:

当前方法可能向调用者传播这些异常。


2.5 Checked Exception 与 throws

对于 Checked Exception,Java 会进行编译期异常检查。

例如:

import java.text.ParseException;
import java.text.SimpleDateFormat;

public class ThrowsDemo {
    public static void main(String[] args)
            throws ParseException {

        SimpleDateFormat sdf =
                new SimpleDateFormat("yyyy/MM/dd");

        sdf.parse("2026/09/02");
    }
}

parse() 可能抛出:

ParseException

它属于 Checked Exception。

如果当前方法不准备:

try-catch

那么可以:

throws ParseException

继续向调用者声明传播。

因此对于 Checked Exception,调用者主要面临:

方案一:当前层捕获
try-catch

方案二:当前层不处理
throws

2.6 RuntimeException 是否必须写 throws

不必须。

例如:

public static int divide(int a, int b) {
    return a / b;
}

这里可能产生:

ArithmeticException

但是:

public static int divide(int a, int b)
        throws ArithmeticException {
    return a / b;
}

虽然也可以写,

却不是编译器强制要求。

因为:

ArithmeticException
    ↓
RuntimeException
    ↓
Unchecked Exception

所以要建立一个非常重要的认识:

throws 并不等于 Checked Exception 专属语法。

RuntimeException 也可以出现在 throws 声明中,只是通常不需要为了通过编译而声明。


2.7 throw 是什么

throwthrows 只有一个字母 s 的区别,但职责完全不同。

throw 表示:

主动抛出一个具体的异常对象。

基本语法:

throw 异常对象;

例如:

throw new IllegalArgumentException();

或者:

throw new IllegalArgumentException(
        "年龄不能小于 0"
);

这里:

new IllegalArgumentException(...)

负责:

创建异常对象

而:

throw

负责:

把这个异常对象抛出去

2.8 为什么需要主动 throw

异常不一定只能由 JVM 或 JDK 自动产生。

例如:

public static void setAge(int age) {
    if (age < 0 || age > 150) {
        // 怎么处理?
    }
}

从 Java 语法角度看:

age = -100

完全是一个合法整数。

JVM 不知道:

-100 岁

在你的业务中是不合法的。

因此程序员可以主动判断:

if (age < 0 || age > 150) {
    throw new IllegalArgumentException(
            "年龄必须在 0~150 之间"
    );
}

此时:

Java 语言没有出错
JVM 也没有自动发现问题
        ↓
业务规则发现非法状态
        ↓
程序员主动创建异常
        ↓
throw 抛出异常

这就是 throw 的重要意义。


2.9 throw 与 throws 的核心区别

| 对比项 | throw | throws | | ------------------------ | -------------------------- | --------------------- | | 类型 | 语句 | 方法声明的一部分 | | 位置 | 方法体内部 | 方法声明处 | | 后面是什么 | 异常对象 | 异常类型 | | 作用 | 真正抛出异常 | 声明可能传播异常 | | 典型写法 | throw new XxxException() | throws XxxException | | 是否真正产生异常传播动作 | 是 | 自身只是声明 |

最重要的一句话:

throw:我现在抛出一个异常对象

throws:我声明这个方法可能把异常交给调用者

2.10 throw 后面的代码会怎样

例如:

public static void checkAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException(
                "年龄不能小于 0"
        );

        // System.out.println("继续执行");
    }
}

一旦:

throw

执行,

当前正常执行路径就会被中断。

因此不能理解成:

throw 异常
    ↓
然后继续执行下一句

而应该理解成:

throw
    ↓
当前方法发生异常完成
    ↓
开始寻找能够处理异常的地方

2.11 什么叫异常传播

考虑三个方法:

public static void main(String[] args) {
    methodA();
}

public static void methodA() {
    methodB();
}

public static void methodB() {
    int result = 10 / 0;
}

调用链:

main()
  ↓
methodA()
  ↓
methodB()

methodB() 中:

10 / 0

产生:

ArithmeticException

如果 methodB() 没处理:

methodB
   ↓
methodA

如果 methodA() 也没处理:

methodA
   ↓
main

如果 main() 依然没有处理:

main
   ↓
当前线程的默认未捕获异常处理流程
   ↓
线程异常终止

这就是异常沿:

方法调用栈

向上传播。


2.12 一个完整传播案例

public class PropagationDemo {

    public static void main(String[] args) {
        try {
            level1();
            System.out.println("success");
        } catch (ArithmeticException e) {
            System.out.println("failed");
        }

        System.out.println("end");
    }

    public static void level1() {
        level2();
    }

    public static void level2() {
        int result = 10 / 0;
        System.out.println(result);
    }
}

调用:

main
 ↓
level1
 ↓
level2

异常:

ArithmeticException

发生在:

level2

level2() 没有处理。

于是:

level2
 ↓
level1

level1() 也没有处理:

level1
 ↓
main

最终:

catch (ArithmeticException e)

能够接住。

因此执行:

failed
end

而:

success

不会输出。


三、使用方法

3.1 使用 throw 做参数校验

public class UserService {

    public static void register(String username) {
        if (username == null ||
                username.isBlank()) {

            throw new IllegalArgumentException(
                    "用户名不能为空"
            );
        }

        System.out.println(
                "注册用户:" + username
        );
    }
}

调用:

register("");

程序主动发现:

用户名不符合规则

于是抛出异常。


3.2 使用 throws 传播 Checked Exception

例如:

import java.text.ParseException;
import java.text.SimpleDateFormat;

public class ParseService {

    public static void parseDate(String text)
            throws ParseException {

        SimpleDateFormat sdf =
                new SimpleDateFormat("yyyy-MM-dd");

        sdf.parse(text);
    }
}

这里:

ParseService

不决定失败后应该怎么向用户反馈。

它只声明:

日期解析可能失败

由调用者决定处理策略。


3.3 最外层捕获

import java.text.ParseException;

public class App {

    public static void main(String[] args) {
        try {
            ParseService.parseDate(
                    "2026-error"
            );
        } catch (ParseException e) {
            System.out.println(
                    "请输入正确日期"
            );
        }
    }
}

形成:

ParseService
    ↓
异常向上传播
    ↓
App / 边界层
    ↓
统一处理

3.4 不要为了省事全部 throws Exception

原始入门资料使用过:

public static void method()
        throws Exception {
}

这有利于初学阶段理解:

异常可以继续向上传播

但是工程开发中不应该无脑这样写。

例如:

public User loadUser()
        throws Exception {
}

调用者只能知道:

“可能发生某种 Exception”

却无法知道到底是:

文件异常
SQL 异常
解析异常
业务异常

更清晰的方式通常是:

throws IOException

或者:

throws ParseException

让方法的失败契约更加明确。


3.5 当前层到底捕获还是继续抛

可以问自己一个问题:

当前方法真的知道这个异常应该如何正确处理吗?

如果答案是:

知道

例如:

用户输入格式错误
→ 当前 UI 可以提示重新输入

那么可以:

当前层捕获

如果答案是:

不知道

例如底层 Repository:

只知道读取失败
不知道最终怎么回复用户

则更合理的方式可能是:

继续向上层传播

四、原理与进阶

4.1 异常传播本质上沿调用栈向上查找处理器

普通方法调用:

main
 ↓
A
 ↓
B
 ↓
C

当 C 正常完成:

C 返回
 ↑
B
 ↑
A
 ↑
main

如果 C 抛异常:

C
 ↑ 异常
B
 ↑ 异常
A
 ↑ 异常
main

Java 会沿调用关系寻找:

能够捕获该异常类型的 catch

找到以后:

进入 catch

找不到:

异常继续向上传播

4.2 throws 本身不会创建异常

这是非常容易混淆的问题。

例如:

public static void test()
        throws IOException {
}

并不代表调用这个方法:

一定产生 IOException

throws 只是声明:

这个方法存在向外传播 IOException 的可能

真正抛异常的动作来自:

方法内部执行的代码

或者:

throw new IOException();

4.3 throw 的对象必须属于 Throwable 体系

官方补充:

throw 后面的表达式必须产生能够作为 Throwable 抛出的对象。

正确:

throw new RuntimeException();

错误思路:

// throw "error";
// throw 100;

因为:

String
int

都不是:

Throwable

体系中的异常对象。


4.4 Checked Exception 为什么影响方法签名

如果一个方法内部可能抛出 Checked Exception,又没有在方法内部捕获:

编译器必须知道
调用者需要面对这种失败可能

因此异常被写入:

throws XxxException

成为方法声明的一部分。

这意味着异常并不只是:

运行失败后的调试信息

也是:

API 契约的一部分

4.5 RuntimeException 为什么通常不要求 throws

例如:

public static void method(String value) {
    value.length();
}

理论上可能:

NullPointerException

但如果所有可能的 RuntimeException 都强制写进方法签名:

throws NullPointerException,
       IllegalArgumentException,
       ...

方法声明会极其臃肿。

因此 Java 不要求 unchecked exception 必须声明。


五、实践应用

5.1 三层调用模型

真实项目可以抽象为:

Controller
   ↓
Service
   ↓
Repository

Repository:

负责数据访问

Service:

负责业务逻辑

Controller:

负责系统边界与用户响应

如果底层发生问题:

Repository 异常
      ↓
Service
      ↓
Controller
      ↓
统一转换为用户可以理解的响应

这正是后续 Spring 项目中:

全局异常处理

的基础思想。


5.2 输入错误与程序错误不要混为一谈

例如:

Integer.parseInt(input);

用户输入:

abc

可能产生:

NumberFormatException

系统边界可以捕获并告诉用户:

请输入整数

但是:

User user = null;
user.getName();

如果是程序员本身的对象初始化错误,

单纯:

catch (NullPointerException e)

然后继续程序,

通常不是最好的解决方案。

应该优先修复:

为什么 user 会是 null

六、常见问题

6.1 throw 和 throws 有什么区别?

最简单的记忆方式:

throw
→ 抛对象
→ 方法体内

throws
→ 声明异常类型
→ 方法声明上

6.2 throws 会不会真正抛出异常?

throws 本身不会创建和抛出异常。

它负责声明:

异常可能继续传播

6.3 RuntimeException 能写 throws 吗?

可以。

但通常不是为了满足编译器要求。


6.4 Checked Exception 能不能不写 throws?

如果已经:

当前方法 try-catch 处理

则不需要继续声明。

如果没有捕获,并且这种 Checked Exception 可以从方法体传播出去,则必须符合 Java 的异常检查规则。


6.5 为什么不推荐到处 throws Exception?

因为范围过大,会削弱:

API 的异常语义
调用者的判断能力
编译器能够提供的帮助

6.6 throw 后还能继续执行下面代码吗?

当前执行路径不能像普通语句一样继续。

throw 会导致当前语句异常完成,并进入异常传播流程。


6.7 底层异常是不是全部应该抛到 main?

不是。

“向上抛”是处理策略之一,不是固定规则。

真正原则是:

在能够做出正确恢复、转换或响应决策的层级处理异常。


6.8 main 方法 throws Exception 好不好?

学习简单 Demo 时可以看到:

public static void main(String[] args)
        throws Exception {
}

这能够快速让 Checked Exception 示例通过编译。

但它并没有真正设计:

异常发生以后应该怎么办

因此企业应用不能把它当成完整异常处理方案。


七、练习与验收

7.1 知识问答

  1. throwthrows 分别写在哪里?
  2. throw 后面是什么?
  3. throws 后面是什么?
  4. throws 是否会真正创建异常对象?
  5. 为什么需要主动 throw
  6. RuntimeException 是否必须声明 throws
  7. Checked Exception 为什么会影响方法声明?
  8. 一个方法可以声明多个异常吗?
  9. 什么叫异常传播?
  10. 异常通常沿什么结构向上传播?
  11. 如果所有调用层都不处理异常,最终可能发生什么?
  12. 为什么不推荐所有方法都 throws Exception
  13. 什么场景应该当前层处理异常?
  14. 什么场景更适合继续向上传播?
  15. throw new IllegalArgumentException(...) 完成了哪两个动作?

7.2 代码阅读

阅读:

public class ExceptionRead {

    public static void main(String[] args) {
        try {
            show();
            System.out.println("success");
        } catch (Exception e) {
            System.out.println("failed");
        }

        System.out.println("end");
    }

    public static void show() {
        int x = 10 / 0;
        System.out.println(x);
    }
}

不运行回答:

  1. show() 中产生什么异常?
  2. show() 是否自己处理异常?
  3. 异常怎样到达 main()
  4. success 是否输出?
  5. failed 是否输出?
  6. end 是否输出?
  7. 删除 try-catch 后执行流程如何变化?

阅读:

public static void register(String username) {
    if (username == null) {
        throw new IllegalArgumentException(
                "用户名不能为空"
        );
    }

    System.out.println("register success");
}

回答:

  1. throw 后面是什么对象?
  2. 为什么 Java 不会自动认为 username == null 是当前业务异常?
  3. 如果 username 为 null,最后一行是否执行?
  4. 这个异常是否属于 Checked Exception?

7.3 手写代码

任务一:参数校验

编写:

checkAge(int age)

规则:

0 ≤ age ≤ 150

否则主动抛出:

IllegalArgumentException

要求附带清晰异常消息。


任务二:日期解析传播

编写:

parseDate(String text)

调用日期解析 API。

要求:

  • 当前方法不使用 try-catch
  • 使用准确的异常类型进行 throws 声明。
  • main 中捕获并处理。

任务三:三层调用链

设计:

main
 ↓
service
 ↓
repository

由 repository 主动制造一个异常。

要求:

  • repository 不捕获。
  • service 不捕获。
  • main 统一捕获。
  • 画出异常传播路径。

7.4 Debug

下面代码存在设计问题:

public static void register(String username)
        throws Exception {

    if (username == null ||
            username.isBlank()) {

        throw new Exception(
                "用户名不合法"
        );
    }
}

分析:

  1. throws Exception 范围是否过大?
  2. new Exception 表达的业务语义是否足够明确?
  3. 调用者能够知道这是“用户名非法”问题吗?
  4. 下一章学习自定义异常以后应该如何优化?

7.5 综合训练

设计三层注册业务:

App
 ↓
UserService
 ↓
UserValidator

要求:

UserValidator 检查:

  • 用户名不能为空。
  • 用户名长度必须为 4~20。
  • 年龄必须为 0~150。

当前阶段允许使用:

IllegalArgumentException

要求:

  1. 校验失败时使用 throw
  2. Service 不负责最终用户提示。
  3. App 作为边界层集中处理。
  4. 画出正常调用流。
  5. 画出异常传播流。
  6. 分析下一章为什么值得引入自定义业务异常。

7.6 本章验收

  • [ ] 能闭卷写出 throw new XxxException(...)
  • [ ] 能闭卷写出 throws XxxException
  • [ ] 能准确区分 throwthrows
  • [ ] 能解释 Checked Exception 与 RuntimeException 对 throws 的影响。
  • [ ] 能解释异常沿调用栈向上传播的过程。
  • [ ] 能独立画出三层方法调用中的异常传播图。
  • [ ] 能判断当前层应该捕获还是继续传播。
  • [ ] 能发现 throws Exception 过度宽泛的问题。
  • [ ] 能使用主动抛异常表达参数或业务校验失败。