有关Java 异常的分享
一、异常的本质:什么是 Java 异常?
1.1 异常的定义
在 Java 中,异常(Exception)是程序运行过程中出现的非正常情况,它中断了正常的指令流。简单来说,就是程序在执行过程中,遇到了无法按预期流程继续执行的问题,比如除数为 0、数组下标越界、空指针访问、文件不存在、网络连接中断等。
Java 通过 “异常对象” 来封装错误信息,这个对象包含了错误的类型、错误的描述信息、以及错误发生时的调用栈轨迹(StackTrace)。当异常发生时,JVM 会创建一个异常对象,并寻找对应的异常处理器来处理它,这一过程被称为 “抛出异常”;如果找不到处理器,程序将终止运行并打印异常信息。
1.2 异常与错误的区别:Error vs Exception
- Error(错误):表示 JVM 层面的严重错误,是程序无法处理的问题,比如 OutOfMemoryError(内存溢出错误)、StackOverflowError(栈溢出错误)、NoClassDefFoundError(类定义未找到错误)等。这类错误通常由 JVM 环境问题导致,程序本身无法修复,一旦发生,程序只能终止运行。
- Exception(异常):表示程序运行时出现的、可以被捕获和处理的非正常情况,分为受检异常(Checked Exception)和非受检异常(Unchecked Exception)。这类问题可以通过代码逻辑修复或捕获处理,比如 IOException(IO 异常)、NullPointerException(空指针异常)、ArithmeticException(算术异常)等。
简单来说:Error 是程序的 “绝症”,无法治愈;Exception 是程序的 “感冒发烧”,可以通过处理恢复正常运行。
二、异常的分类:受检异常与非受检异常
2.1 非受检异常(Unchecked Exception)
非受检异常也被称为运行时异常,继承自 java.lang.RuntimeException,编译器不会强制要求开发者处理这类异常,程序运行时才会抛出。常见的非受检异常包括:
- NullPointerException(空指针异常):访问 null 对象的属性或方法时抛出
- ArrayIndexOutOfBoundsException(数组下标越界异常):访问数组超出有效下标时抛出
- ArithmeticException(算术异常):除数为 0、整数溢出等算术错误时抛出
- ClassCastException(类型转换异常):对象类型转换失败时抛出
- IllegalArgumentException(非法参数异常):传入不合法的方法参数时抛出
这类异常的特点是:通常由代码逻辑错误导致,是开发者可以避免的。比如空指针异常,本质上是开发者没有对对象进行 null 判断;数组下标越界,是因为循环或下标计算错误。因此编译器不强制处理,而是鼓励开发者通过优化代码逻辑来避免这类异常的发生。
2.2 受检异常(Checked Exception)
受检异常继承自 java.lang.Exception,但不继承 RuntimeException,编译器会强制要求开发者处理这类异常,要么用 try-catch 捕获,要么用 throws 声明抛出,否则代码无法通过编译。常见的受检异常包括:
- IOException(IO 异常):文件读写、网络通信等 IO 操作失败时抛出
- SQLException(SQL 异常):数据库操作失败时抛出
- ParseException(解析异常):字符串解析为日期、数字等格式失败时抛出
- InterruptedException(中断异常):线程被中断时抛出
这类异常的特点是:通常由外部环境问题导致,是程序无法完全避免的。比如读取文件时文件不存在、网络请求时连接中断,这类问题不受代码逻辑控制,因此编译器强制要求开发者处理,保证程序不会因为这类问题直接崩溃。
2.3 两类异常的对比与使用场景
| 维度 | 非受检异常(RuntimeException) | 受检异常(Checked Exception) |
|---|---|---|
| 继承关系 | 继承自 RuntimeException | 继承自 Exception,不继承 RuntimeException |
| 编译器处理 | 不强制要求处理 | 强制要求捕获或声明抛出 |
| 产生原因 | 代码逻辑错误导致,可避免 | 外部环境问题导致,不可完全避免 |
| 常见例子 | NullPointerException、ArithmeticException | IOException、SQLException |
| 使用场景 | 用于表示编程错误,鼓励修复代码 | 用于表示外部错误,必须处理恢复 |
三、异常的处理机制:try-catch-finally 与 throw/throws
3.1 try-catch-finally:异常处理的核心结构
try-catch-finally 是 Java 中捕获和处理异常的基本结构,它们的分工非常明确:
- try 块:用于包裹可能抛出异常的代码。JVM 会监控 try 块内的代码,如果没有异常发生,代码正常执行,跳过所有 catch 块;如果发生异常,JVM 会立刻终止 try 块内后续代码的执行,跳转到对应的 catch 块。
- catch 块:用于捕获 try 块中抛出的异常,并进行处理。一个 try 块可以对应多个 catch 块,按异常类型从上到下匹配,一旦匹配成功,执行对应的 catch 块代码,后续 catch 块不再执行。
- finally 块:用于存放无论是否发生异常,都一定会执行的代码,比如资源释放(关闭文件流、数据库连接、网络连接等)。
3.1.1 try-catch-finally 的执行流程
以一段简单的代码为例,分析执行流程:
public class ExceptionDemo {
public static void main(String[] args) {
try {
System.out.println("try块:执行可能抛出异常的代码");
int a = 10 / 0; // 除数为0,抛出ArithmeticException
System.out.println("try块:异常发生后,这行代码不会执行");
} catch (ArithmeticException e) {
System.out.println("catch块:捕获到算术异常,处理异常");
e.printStackTrace(); // 打印异常栈轨迹
} finally {
System.out.println("finally块:无论是否发生异常,都会执行");
}
System.out.println("程序继续执行");
}
}
执行流程如下:
- 进入 try 块,打印第一行输出;
- 执行
int a = 10 / 0;,抛出 ArithmeticException,try 块后续代码不再执行; - JVM 匹配到 catch 块(ArithmeticException 与抛出的异常类型匹配),进入 catch 块,打印异常处理信息,并调用
e.printStackTrace()打印异常栈轨迹; - 执行 finally 块,打印对应信息;
- 异常处理完成,程序继续执行后续代码,打印 “程序继续执行”。
3.1.2 finally 块的 “一定会执行” 误区
很多资料会说 “finally 块一定会执行”,但这是一个常见的误区。本周学习中,我也验证了 finally 块不执行的特殊情况:
- 在 try 块或 catch 块中调用
System.exit(0)直接终止 JVM,finally 块不会执行; - 程序运行时发生严重错误(如 OutOfMemoryError、JVM 崩溃),finally 块不会执行;
- 线程被中断或终止,finally 块可能不会执行。
因此,更准确的说法是:在正常的程序流程下,finally 块一定会执行;只有当 JVM 被终止或发生严重错误时,finally 块才不会执行。
3.1.3 finally 块中 return 的陷阱
另一个容易踩坑的点是:如果 try 块和 finally 块中都有 return 语句,最终返回的是 finally 块中的 return 值。比如:
public class FinallyReturnDemo {
public static int test() {
try {
return 1;
} finally {
return 2;
}
}
public static void main(String[] args) {
System.out.println(test()); // 输出:2
}
}
这是因为 try 块中的 return 语句会先计算返回值(此时返回值为 1),但不会立即返回,而是执行 finally 块;如果 finally 块中有 return 语句,会直接覆盖 try 块的返回值,因此最终返回 2。这一特性会导致程序逻辑混乱,因此强烈不建议在 finally 块中使用 return 语句。
3.2 throw 与 throws:抛出异常的两种方式
3.2.1 throws 关键字:声明方法可能抛出的异常
throws 关键字用于方法声明上,表示该方法可能会抛出指定类型的异常,让调用者知道需要处理这些异常。throws 的语法格式为:
修饰符 返回值类型 方法名(参数列表) throws 异常类型1, 异常类型2, ... {
// 方法体
}
throws 的特点:
- 只能用在方法声明上;
- 可以声明多个异常类型,用逗号分隔;
- 声明的异常类型可以是受检异常或非受检异常,但非受检异常通常不需要声明;
- 子类重写父类方法时,throws 声明的异常不能比父类更宽泛(即不能声明父类没有声明的受检异常,或声明比父类异常更宽泛的类型)。
比如,一个读取文件的方法,可能会抛出 IOException,就可以用 throws 声明:
public void readFile(String path) throws IOException {
FileInputStream fis = new FileInputStream(path);
// 读取文件操作
}
此时,调用 readFile 方法的代码,要么用 try-catch 捕获 IOException,要么继续用 throws 声明抛出。
3.2.2 throw 关键字:手动抛出异常对象
throw 关键字用于方法内部,用于手动抛出一个具体的异常对象,语法格式为:
throw new 异常类型(异常信息);
throw 的特点:
- 只能用在方法内部;
- 每次只能抛出一个异常对象;
- 抛出的异常对象可以是系统定义的异常,也可以是自定义异常;
- 如果抛出的是受检异常,必须在方法声明上用 throws 声明,否则编译器会报错;如果抛出的是非受检异常,则不需要声明。
比如,一个方法需要校验传入的参数是否为正数,如果为负数则抛出 IllegalArgumentException:
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("年龄不能为负数:" + age);
}
this.age = age;
}
这里抛出的 IllegalArgumentException 是非受检异常,因此不需要在方法声明上用 throws 声明。
3.2.3 throw 与 throws 的区别对比
| 维度 | throw | throws |
|---|---|---|
| 位置 | 方法内部 | 方法声明上 |
| 作用 | 手动抛出具体的异常对象 | 声明方法可能抛出的异常类型 |
| 数量 | 一次只能抛出一个异常对象 | 可以声明多个异常类型 |
| 异常类型 | 抛出的是异常对象 | 声明的是异常类型 |
| 受检异常处理 | 抛出受检异常时,必须配合 throws | 声明受检异常时,调用者必须处理 |
| 示例 | throw new IOException ("文件不存在"); | public void read() throws IOException |
简单来说:throws 是方法的 “异常说明”,告诉调用者 “我可能会抛出这些异常”;throw 是方法的 “异常动作”,真正抛出一个异常对象。
四、自定义异常:构建符合业务场景的错误体系
4.1 自定义异常的意义
系统提供的异常只能表示通用的错误情况,比如 IOException 表示 IO 操作错误,IllegalArgumentException 表示参数非法,但无法直接表达业务场景中的错误,比如 “用户不存在”“密码错误”“库存不足”“订单状态异常” 等。此时,自定义异常就派上了用场:
- 清晰传递业务错误信息,让调用者快速定位问题;
- 区分业务错误与系统错误,方便错误处理与日志排查;
- 构建统一的业务错误体系,让代码更易维护和扩展。
4.2 自定义异常的实现方式
自定义异常通常分为自定义受检异常和自定义非受检异常,实现方式如下:
- 自定义非受检异常:继承 RuntimeException,无需强制处理;
- 自定义受检异常:继承 Exception,强制调用者处理。
以 “用户不存在” 的业务异常为例,实现一个自定义非受检异常:
public class UserNotExistException extends RuntimeException {
// 无参构造方法
public UserNotExistException() {
super();
}
// 带错误信息的构造方法
public UserNotExistException(String message) {
super(message);
}
// 带错误信息和原因的构造方法
public UserNotExistException(String message, Throwable cause) {
super(message, cause);
}
// 带原因的构造方法
public UserNotExistException(Throwable cause) {
super(cause);
}
}
在业务方法中,当查询用户不存在时,就可以抛出这个自定义异常:
public User getUserById(Long userId) {
User user = userDao.selectById(userId);
if (user == null) {
throw new UserNotExistException("用户不存在,ID:" + userId);
}
return user;
}
调用者捕获这个异常后,就可以快速知道是 “用户不存在” 的业务错误,而不是通用的空指针异常,方便后续处理。
五、异常处理的常见误区与最佳实践
5.1 常见误区
误区 1:盲目捕获所有异常,使用 catch (Exception e)
很多人会直接用catch(Exception e)捕获所有异常,这是非常糟糕的做法:
- 无法区分业务错误和系统错误,不利于排查问题;
- 可能会捕获不该捕获的错误(比如 OutOfMemoryError),导致程序隐藏严重问题;
- 无法针对不同异常进行针对性处理,降低了程序的健壮性。
误区 2:捕获异常后不处理,只打印空日志或不打印日志
try {
// 业务代码
} catch (Exception e) {
// 什么都不做,或者只打印e.getMessage()
}
这种做法会导致异常被 “吃掉”,程序无法感知错误,也无法排查问题,后续出现问题时很难定位根源。
误区 3:在 finally 块中抛出异常
如果 finally 块中抛出异常,会覆盖 try 块或 catch 块中的异常信息,导致原始异常丢失,不利于问题排查。比如:
try {
throw new IOException("文件读取失败");
} catch (IOException e) {
throw new RuntimeException("处理异常失败", e);
} finally {
throw new Exception("finally块中的异常");
}
此时,finally 块中的异常会覆盖 catch 块中抛出的异常,最终只能看到 finally 块的异常信息,无法知道原始的文件读取错误。
误区 4:用异常控制正常业务流程
比如用空指针异常来判断对象是否为 null,或者用异常来处理 “用户不存在” 这种可以通过条件判断避免的业务场景,这是非常低效的做法:
- 异常的创建和抛出会消耗大量系统资源,频繁抛出异常会严重影响程序性能;
- 用异常控制业务流程会让代码逻辑混乱,可读性差,难以维护。
5.2 最佳实践
实践 1:优先捕获具体异常,避免捕获 Exception
尽量捕获具体的异常类型,比如 IOException、SQLException,而不是直接捕获 Exception。如果需要捕获多个异常,可以用多个 catch 块,或者 Java 7 + 提供的多异常捕获语法:
try {
// 业务代码
} catch (IOException | SQLException e) {
// 处理IO异常和SQL异常
e.printStackTrace();
}
实践 2:捕获异常后必须处理,或向上抛出
捕获异常后,要么进行针对性处理(比如重试、降级、记录日志、返回友好提示),要么向上抛出给调用者处理,不能 “吃掉” 异常。同时,要打印完整的异常栈轨迹,方便排查问题:
try {
// 读取文件
} catch (IOException e) {
// 记录错误日志,包含异常栈轨迹
logger.error("读取文件失败,路径:{}", path, e);
// 抛出自定义业务异常,让上层处理
throw new FileOperationException("文件读取失败", e);
}
实践 3:在 finally 块中释放资源,避免资源泄漏
IO 流、数据库连接、网络连接等资源需要手动关闭,应该放在 finally 块中,确保无论是否发生异常,资源都能被释放:
FileInputStream fis = null;
try {
fis = new FileInputStream("test.txt");
// 读取文件
} catch (IOException e) {
logger.error("读取文件失败", e);
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
logger.error("关闭文件流失败", e);
}
}
}
Java 7 + 还提供了 try-with-resources 语法,可以自动实现资源的关闭,无需手动写 finally 块:
try (FileInputStream fis = new FileInputStream("test.txt")) {
// 读取文件
} catch (IOException e) {
logger.error("读取文件失败", e);
}
try-with-resources 会自动调用资源的 close () 方法,无需手动处理,更简洁安全。
实践 4:避免在 finally 块中抛出异常或使用 return
finally 块的核心作用是释放资源,不应该包含业务逻辑,更不应该抛出异常或使用 return 语句,避免影响 try 块和 catch 块的正常执行,也避免覆盖原始异常信息。
实践 5:自定义异常要语义清晰,包含足够的错误信息
自定义异常要能准确表达业务错误场景,异常信息要包含关键的错误数据(比如用户 ID、订单 ID、错误参数等),方便后续排查问题。同时,要合理使用异常链,传递原始异常的原因,帮助定位问题根源:
public void updateUser(Long userId, String name) {
try {
userDao.updateName(userId, name);
} catch (SQLException e) {
throw new UserOperationException("更新用户姓名失败,用户ID:" + userId, e);
}
}
这里将 SQLException 作为原因传递给自定义异常,后续排查时可以通过异常链看到原始的 SQL 错误信息。
结语
Java 异常体系是编程中非常重要的一部分,它不仅是处理错误的机制,更是构建健壮系统的基础。希望通过本文的分享,能帮助大家厘清异常的核心概念,掌握正确的处理方式,避免常见的误区,在后续的编程中写出更健壮、更易维护的代码。
更多推荐


所有评论(0)