大家好,我是栗筝i,这篇文章是我的 “栗筝i 的 Java 技术栈” 专栏的第 009 篇文章,在 “栗筝i 的 Java 技术栈” 这个专栏中我会持续为大家更新 Java 技术相关全套技术栈内容。专栏的主要目标是已经有一定 Java 开发经验,并希望进一步完善自己对整个 Java 技术体系来充实自己的技术栈的同学。与此同时,本专栏的所有文章,也都会准备充足的代码示例和完善的知识点梳理,因此也十分适合零基础的小白和要准备工作面试的同学学习。当然,我也会在必要的时候进行相关技术深度的技术解读,相信即使是拥有多年 Java 开发经验的从业者和大佬们也会有所收获并找到乐趣。
–
世界上存在永远不会出错的程序吗?也许这只会出现在程序员的梦中。随着编程语言和软件的诞生,异常情况就如影随形地纠缠着我们,只有正确处理好意外情况,才能保证程序的可靠性。
1、Java 异常概述
1.1、异常机制简介
Java 异常是 Java 编程语言中的一种重要机制,用于处理程序执行中的错误情况。异常处理可以使程序在遇到错误时能够优雅地恢复或进行相应的处理,而不是直接崩溃。
在 Java 程序运行过程中,可能会出现各种错误,如文件错误信息、网络连接问题、无效的数组下标或未赋值的对象引用等。如果错误导致某些操作未完成,程序应该:
- 回到安全状态,让用户可以执行其他命令;
- 或者让用户保存所有操作结果,并适当地结束程序。
实现这些并不简单,因为检测或触发错误条件的代码通常与能够恢复数据到安全状态或保存用户操作结果并正常退出程序的代码相距甚远。异常处理的目标就是将控制权从错误发生处转移到能处理该错误的处理器。
Java 语言在设计之初就提供了相对完善的异常处理机制,这也是 Java 得以大行其道的原因之一,因为这种机制大大降低了编写和维护可靠程序的门槛。如今,异常处理机制已经成为现代编程语言的标配。
1.2、异常的分类
Java 中的异常分为两大类:检查型异常(Checked Exceptions)和非检查型异常(Unchecked Exceptions)。
- 检查型异常:这类异常必须在程序中进行捕获或声明抛出。它们通常是程序可以预料的错误情况,例如文件未找到、网络连接错误等。例如,
IOException
和SQLException
都是检查型异常; - 非检查型异常:这类异常包括运行时异常(Runtime Exception)和错误(Error)。运行时异常通常由程序逻辑错误引起,如数组越界 (
ArrayIndexOutOfBoundsException
)、空指针异常 (NullPointerException
) 等。错误则通常表示系统级的严重问题,如OutOfMemoryError
。
1.2.1、编译时异常
Java 认为 Checked 异常都是可以再编译阶段被处理的异常,所以它强制程序处理所有的 Checked 异常,而 Runtime 异常无须处理,Java 必须显式处理 Checked 异常,如果程序没有处理,则在编译时会发生错误,无法通过编译。
比如,我下面的代码:
import java.io.BufferedReader;
import java.io.FileReader;
public class FileReadingExample {
public static void main(String[] args) {
// 打开一个文件并读取内容
BufferedReader reader = new BufferedReader(new FileReader("example.txt"));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
在这个代码中 FileReader
、BufferedReader
及其方法 readLine()
极有可能因为以下问题抛出 IOException 异常(或是 IOException 的子异常,如 FileNotFoundException、InterruptedIOException 等):
- 文件访问问题(如文件不存在、文件权限不足);
- 文件状态问题(如文件已被其他程序锁、读取过程中外部设备被移除);
- 资源限制(如文件描述符耗尽、内存或其他系统资源不足);
- I/O 操作错误(如硬盘读取错误或网络文件系统错误、线程在读取数据时被中断)等问题
这时,这段代码就会在编译时报错:
java: 未报告的异常错误java.io.FileNotFoundException; 必须对其进行捕获或声明以便抛出
java: 未报告的异常错误java.io.IOException; 必须对其进行捕获或声明以便抛出
所以,此时对其进行捕获并处理的方式就可以达到,想要的效果:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class FileReadingExample {
public static void main(String[] args) {
// 尝试打开一个文件并读取内容
try (BufferedReader reader = new BufferedReader(new FileReader("example.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
// 处理文件读取中可能出现的异常
System.err.println("无法读取文件: " + e.getMessage());
}
}
}
运行程序,打印我们需要的内容:
无法读取文件: example.txt (No such file or directory)
Checked 异常体现了 Java 设计哲学:没有完善处理的代码根本不会被执行,体现了 Java 的严谨性。
1.2.2、运行时异常
Java 对待运行时异常的方式不同于检查型异常,不要求在编译时显式处理这些异常,以维护代码的清晰性和灵活性,并激励开发者主动避免逻辑错误。
运行时异常通常反映编程错误,如逻辑失误或不当操作,且默认由 Java 运行时系统传递;当然,如果没有被捕获,这些异常最终可能导致程序崩溃。
我们依旧来看一个例子:
public class ArrayExample {
public static void main(String[] args) {
int[] numbers = {1, 2, 3};
// 这里故意使用一个超出数组长度的索引
int value = numbers[5];
System.out.println("数组元素: " + value);
}
}
在这个例子中,我们将尝试访问数组中不存在的元素,这将抛出一个 ArrayIndexOutOfBoundsException
,它是 RuntimeException
的一个实例,由于它是一个运行时异常,所以他不会在编译期间被抛出,而是在运行时报错:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 5
at com.lizhengi.exceptions.arrayExample.ArrayExample.main(ArrayExample.java:13)
同样的,我们继续对其进行捕获并处理的方式就可以达到,想要的效果:
public class ArrayExample {
public static void main(String[] args) {
int[] numbers = {1, 2, 3};
// 尝试访问一个不存在的数组元素
try {
// 这里故意使用一个超出数组长度的索引
int value = numbers[5];
System.out.println("数组元素: " + value);
} catch (ArrayIndexOutOfBoundsException e) {
// 捕获并处理数组越界异常
System.err.println("数组访问越界: " + e.getMessage());
}
}
}
运行程序,打印我们需要的内容:
数组访问越界: 5
与检查型异常相比,运行时异常反映了可以由程序员通过更好的编程习惯避免的错误。Java 允许你不处理运行时异常,但捕获和处理这些异常可以使你的应用更加稳健和用户友好。运行时异常体现了 Java 设计的灵活性,允许开发者决定如何处理这些常见的编程错误。
1.2.3、错误
Error 是指在正常情况下,不大可能出现的情况,绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如 OutOfMemoryError 之类,都是 Error 的子类。
Exception 和 Error 都是继承了 Throwable 类,在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。
Exception 和 Error 体现了 Java 平台设计者对不同异常情况的分类。Exception 是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。
以下是一个可能导致 OutOfMemoryError
的示例,这种错误发生在 JVM 无法分配更多内存给对象时:
public class MemoryErrorExample {
public static void main(String[] args) {
// 尝试创建一个非常大的数组,大小足以耗尽 JVM 的堆内存
int[] largeArray = new int[Integer.MAX_VALUE];
}
}
不出意外的,报错:
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit at com.lizhengi.exceptions.memoryErrorExample.MemoryErrorExample.main(MemoryErrorExample.java:10)
捕获并处理:
public class MemoryErrorExample {
public static void main(String[] args) {
try {
// 尝试创建一个非常大的数组,大小足以耗尽 JVM 的堆内存
int[] largeArray = new int[Integer.MAX_VALUE];
} catch (OutOfMemoryError e) {
// 捕获并处理 OutOfMemoryError
System.err.println("内存不足,无法分配数组: " + e.getMessage());
}
}
}
运行,查看打印结果:
内存不足,无法分配数组: Requested array size exceeds VM limit
在实际应用中,捕获如 OutOfMemoryError
这样的严重错误通常不是一个好的做法。因为即便捕获了错误,程序的状态也可能已经不稳定,继续执行可能导致更难预料和解决的问题。
更合理的错误处理策略是尽量避免这种错误的发生,例如通过优化内存使用、正确配置 JVM 参数等。
错误如 OutOfMemoryError
、StackOverflowError
和其他 JVM 错误,通常标志着一些你无法简单通过 try-catch
解决的严重问题。处理这些问题往往需要从系统设计和配置层面进行考虑和调整。
2、Java 异常的处理
Java 异常机制主要依赖于 try
、catch
、finally
、throw
、throws
五个关键字。
2.1、异常的捕获
2.1.1、try-catch-finally
在 Java 中,异常的捕获主要通过 try-catch
语句或 try...catch...finally
语句实现。这个机制允许程序在遇到错误时优雅地处理,而不是崩溃。
try (ResourceType r = new ResourceType()) { // 自动管理资源
// 需要被检测的代码块,这里编写可能会产生异常的代码。
} catch (ThrowType e) { // 抛出了什么异常,在就定义什么异常类型。
// 这是异常的处理语句,用来捕获并处理 try 代码块中抛出的异常。
} finally {
// 这部分代码无论异常是否发生,都会被执行。
// 由于异常会导致程序跳转,可能会有一些语句无法执行。
// finally 代码块就是为了解决这个问题的它里面的代码都是一定会被执行的。
}
其中:
try
块用于包围可能会抛出异常的代码。你需要将那些可能因为错误或其他原因导致运行时问题的代码放入这个块中;catch
块跟随在try
块后面,用于捕获try
块中抛出的特定类型的异常。每个catch
块只能捕获一种类型的异常,但一个try
可以有多个catch
块,分别处理不同类型的异常;finally
块是可选的,总是在try
和catch
块之后执行,无论是否发生了异常。这个块通常用于执行清理类型的重要操作,比如关闭文件流、释放资源等,这些操作必须执行,不论操作过程中是否发生了异常。
2.1.2、try-with-resources
Java 在 Java 7 中引入了 try()
这个特性,try()
语句也被称为 try-with-resources
语句,用于自动管理资源,确保在代码块执行完成后,每个资源都被正确关闭。这种语法特别适用于那些需要显式关闭的资源,如文件、数据库连接等
以下示例展示了如何使用 try-with-resources
来自动关闭文件流:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class FileReadExample {
public static void main(String[] args) {
// 使用 try-with-resources 语句自动关闭 BufferedReader
try (BufferedReader reader = new BufferedReader(new FileReader("example.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
// 文件不存在或其他 I/O 错误的处理
System.err.println("无法读取文件: " + e.getMessage());
} finally {
// 这里可以执行任何结束操作,但通常不需要,因为 BufferedReader 已自动关闭
System.out.println("操作完成");
}
}
}
2.2、异常的抛出
在 Java 中,异常声明是通过throws
关键字在方法签名中实现的,它用来指定一个方法可能抛出的异常。这是异常处理的一个重要方面,因为它向方法的调用者明确了可能需要处理的风险。下
方法签名中的异常声明示例:
public void readFile(String path) throws IOException {
// 代码逻辑
}
在这个例子中,readFile
方法声明了可能抛出IOException
。如果调用readFile
方法的代码没有处理这个异常(即没有包含在try-catch
块中或者也声明throws IOException
),编译器将报错。
Ps;在 Java 中,对于非受检查异常(unchecked exceptions),也就是运行时异常(RuntimeException)和错误(Error),一般不需要在方法的声明中使用throws
关键字来声明,也不强制要求使用try-catch
块来捕获它们。
2.3、异常的声明
在 Java 中,异常的抛出是一种处理程序中错误和异常情况的机制。通过使用 throw
关键字,程序可以在遇到错误或不正常的情况时主动抛出一个异常对象。这样做不仅有助于将错误处理逻辑从常规业务逻辑中分离出来,还使错误的传播和处理更为明确和可控。
使用throw
的示例:
public void checkAge(int age) {
if (age < 18) {
throw new IllegalArgumentException("年龄不足18岁");
}
}
在这个例子中,如果年龄小于18岁,将抛出一个 IllegalArgumentException
。这是一个运行时异常,因此不需要在方法签名中声明
Ps:当一个方法抛出异常,而没有在该方法内部处理(即没有捕获它),这个异常会被传递给调用该方法的上级方法。如果上级方法也没有处理,异常继续传递,这一过程会持续进行,直到找到相应的处理代码或导致程序终止。
2.4、自定义异常
在 Java 中,自定义异常是开发者根据特定应用的需求定义的异常类。这些异常通常用于处理特定的错误情况,当标准的 Java 异常类无法精确表达错误的性质或者需要更详细的错误信息时,自定义异常就显得尤为重要。自定义异常使得错误处理更加细致和清晰,也方便统一管理错误类型和错误处理策略。
下面是关于自定义异常的创建和使用的一些基本概念和步骤:
2.4.1、创建自定义异常
创建自定义异常:
- 继承异常类:自定义异常通常通过继承
Exception
类(对于受检查异常)或RuntimeException
类(对于非受检查异常)来创建。选择继承哪一个基类取决于你希望异常是否需要被显式捕获; - 添加构造函数:自定义异常类通常包含几种构造函数形式,如默认构造函数、带消息的构造函数、带消息和原因的构造函数、带消息、原因、抑制和堆栈跟踪可写性的构造函数等。
public class UserNotFoundException extends Exception {
public UserNotFoundException() {
super();
}
public UserNotFoundException(String message) {
super(message);
}
public UserNotFoundException(String message, Throwable cause) {
super(message, cause);
}
public UserNotFoundException(Throwable cause) {
super(cause);
}
}
在这个例子中,UserNotFoundException
是一个受检查异常,用于处理当用户未找到时的情况。它提供了多种构造方法,使得抛出异常时可以选择性地包含更多信息。
2.4.2、使用自定义异常
自定义异常的使用与标准异常的使用类似。你可以在适当的代码块中用 throw
关键字抛出这些异常,也可以在方法签名中使用 throws
声明它们,引导方法的调用者必须处理这些异常。
public void findUser(String username) throws UserNotFoundException {
if (username == null) {
throw new UserNotFoundException("Username cannot be null");
}
// 更多逻辑代码
}
在这个示例中,如果用户名为null
,则抛出UserNotFoundException
。这使得异常处理更具体,调用者可以根据异常类型采取相应的错误处理策略。
2.4.3、自定义异常优点
自定义异常的优点:
- 提高程序的可读性和维护性:通过自定义异常,代码的读者可以更快地理解异常的原因和上下文。
- 改善错误管理:允许创建更细粒度的错误处理策略,特别是在大型应用中。
- 促进模块化设计:自定义异常可以作为模块间通信的一部分,帮助模块独立处理内部错误。
自定义异常是一个强大的工具,使开发者能够更好地控制和管理程序中的异常情况,对于提升应用的健壮性和可靠性非常有帮助。
3、异常链
3.1、什么是异常链接
异常链(Exception Chaining)是一种面向对象编程技术,指将捕获的异常包装进一个新的异常中并重新抛出的异常处理方式。原异常被保存为新异常的一个属性(比如 cause
)。这个想法是指一个方法应该抛出定义在相同的抽象层次上的异常,但不会丢弃更低层次的信息。通过异常链,可以将原始异常信息传递给调用者,便于定位和排查问题。
3.2、Java 中的异常链
在 Java 中,异常链通常是通过使用 Throwable
类的 initCause
方法来建立的。Throwable
类是所有错误和异常类的超类,它提供了getCause
方法来获取异常链中的下一个异常(即原始异常)。Java 7 及更高版本引入了 Throwable
的子类 Exception
和 Error
的构造器,这些构造器接受一个 Throwable
参数作为 cause
,这简化了创建异常链的过程。
下面是一个实际的 Java 代码示例,展示了如何使用异常链来处理和抛出异常。在这个例子中,我们假设有一个读取用户信息的操作,它可能会抛出一个IOException
,而这个异常需要被转换为一个更具体的自定义异常UserReadException
,从而保留原始异常的信息。
3.2.1、示例:定义自定义异常
首先,我们定义一个自定义的受检异常UserReadException
,它用于封装底层的IOException
:
public class UserReadException extends Exception {
public UserReadException(String message, Throwable cause) {
super(message, cause);
}
}
3.2.2、示例:实现文件读取和异常处理
然后,我们实现一个方法readUserFromFile
,该方法尝试从一个文件中读取用户数据。如果遇到IOException
,我们将它封装到UserReadException
中并抛出:
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;
public class UserService {
public String readUserFromFile(String filePath) throws UserReadException {
try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
return reader.readLine(); // 假设第一行有用户数据
} catch (IOException e) {
throw new UserReadException("Failed to read user data from file", e);
}
}
public static void main(String[] args) {
UserService userService = new UserService();
try {
String userData = userService.readUserFromFile("path/to/userfile.txt");
System.out.println("User data: " + userData);
} catch (UserReadException e) {
System.err.println("Error reading user data: " + e.getMessage());
e.printStackTrace(); // 打印异常链,查看根源
}
}
}
说明:
- 异常链的使用:在
readUserFromFile
方法中,当捕获到IOException
时,我们不仅仅是简单地处理它,而是将它作为原因封装到UserReadException
中,并将这个新异常抛出。这样做可以保留IOException
的所有信息,包括堆栈跟踪; - 异常的捕获和处理:在
main
方法中,我们调用readUserFromFile
方法,并捕获可能抛出的UserReadException
。如果异常被捕获,我们将打印错误消息并调用e.printStackTrace()
,这将输出完整的异常链,包括原始的IOException
,这对于调试是非常有用的。
3.2.3、示例:异常的结果分析
我们运行上述程序,可以看到运行打印结果:
Error reading user data: Failed to read user data from file
com.lizhengi.exceptions.exceptionChainin.UserReadException: Failed to read user data from file
at com.lizhengi.exceptions.exceptionChainin.UserService.readUserFromFile(UserService.java:14)
at com.lizhengi.exceptions.exceptionChainin.UserService.main(UserService.java:21)
Caused by: java.io.FileNotFoundException: path/to/userfile.txt (No such file or directory)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.<init>(FileInputStream.java:138)
at java.io.FileInputStream.<init>(FileInputStream.java:93)
at java.io.FileReader.<init>(FileReader.java:58)
at com.lizhengi.exceptions.exceptionChainin.UserService.readUserFromFile(UserService.java:10)
... 1 more
异常链的体现位于堆栈跟踪信息中。下面是如何理解这个异常链:
顶层异常:
com.lizhengi.exceptions.exceptionChainin.UserReadException: Failed to read user data from file
这是我们自定义的 UserReadException
,表示在读取用户数据时发生了错误。它是通过 UserService.readUserFromFile
方法中捕获到的底层异常并抛出的。
根源异常:
Caused by: java.io.FileNotFoundException: path/to/userfile.txt (No such file or directory)
这是实际导致问题的根本原因,即 FileNotFoundException
,表明无法找到指定的文件。这是由Java系统方法抛出的,说明尝试打开不存在的文件。
3.3、异常链中的 Caused by
关键字 Caused by
用于连接两个异常,说明一个异常是由另一个异常引起的。在这里, UserReadException
是由 FileNotFoundException
引起的。
这样的链接有助于我们理解错误的流程:首先是尝试读取一个不存在的文件,然后因为这个操作失败了,我们的应用程序决定抛出一个更具体的业务逻辑异常 UserReadException
。
4、拓展:Java 断言处理
4.1、断言的概念
断言(assert)也就是所谓的 Assertion,是 JDK 1.4后加入的新功能
断言是一种调试程序的方式,断言可以在调试情况当错误排查,用于检查前条件,是我们的代码更加接近"契约式编程"
简单来说,断言就是为了检测我们程序自己疏忽写出来的 bug,当断言报错我就知道这里是我们写错了
4.2、断言与异常的关系
断言与异常本身其实没有太大关系。
- 断言:主要用作开发和调试工具,以帮助开发者在开发阶段确保代码的正确性。断言是一种开发者用来测试自己的假设的工具;
- 异常:作为程序设计的一部分,异常处理允许开发者在运行时对预期内和预期外的事件做出响应。它们是向程序的用户报告错误和处理运行时问题的标准方式。
Ps:之所以放在这里讲呢,还是因为断言的内容不太够单独开一篇文章的,而考虑到二者都是错误处理策略的一部分,所以就放在这里作为一个拓展内容。
4.3、Java 断言的介绍
当然,在 Java 中,assert
语句也可以用于处理程序不打算通过捕获异常来处理的错误。
它是 Java 中的一条语句,包含一个布尔表达式。当该布尔表达式为真时,程序被认为是正确的;当布尔表达式为假时,系统会抛出一个 AssertionError
错误。
需要注意的是,断言默认是禁用的。在开发过程中,可以开启断言功能,这有助于纠正错误,增加代码的可维护性。
使用断言,可以用一条 assert
语句来代替 if
和 throw
语句。例如,以下两段代码是等价的:
if (!condition) {
throw new AssertionError();
}
assert condition;
在上述代码中,如果 condition
为假,那么 assert
语句会抛出一个 AssertionError
错误。
4.4、Java 断言的形式
在 Java 中,assert
语句有两种形式:
-
assert condition;
:这种形式只包含一个布尔表达式condition
。如果condition
的计算结果为false
,那么会抛出一个AssertionError
异常; -
assert condition : expression;
:这种形式除了包含一个布尔表达式condition
,还包含一个表达式expression
。如果condition
的计算结果为false
,那么会抛出一个AssertionError
异常,并且expression
的值会被传入AssertionError
异常的构造器,转换成一个消息字符串。
例如:
int x = 5;
assert x > 0 : "x is negative";
在上述代码中,如果 x
不大于 0,那么会抛出一个 AssertionError
异常,异常的消息字符串为 “x is negative”。
4.5、Java 断言的场景
在 Java 中,assert
语句可以用于以下几种场景:
4.5.1、变量值明确
变量值明确:当你非常确定一个变量的值应该是某个具体的值时,可以使用断言来验证这一点。
int x = 5;
assert x == 5;
4.5.2、不可能到达的代码
不可能到达的代码:如果你确定某段代码是不可能被执行到的,可以在那里使用断言。例如,在没有 default
分支的 switch
语句中,可以在 default
分支中添加断言。
switch (choice) {
case 1:
// ...
break;
case 2:
// ...
break;
default:
assert false;
}
4.5.3、前置条件
前置条件:前置条件是在方法执行前必须为真的一条语句。如果前置条件不满足,那么方法就不应该被调用。可以使用断言来检查前置条件。
public void method(int x) {
assert x > 0;
// ...
}
4.5.4、后置条件
后置条件:后置条件是在方法执行完毕后应该为真的一条语句。如果前置条件满足且方法已经完全执行完毕,那么后置条件就应该为真。可以使用断言来检查后置条件。
public int method(int x) {
int result = x * 2;
assert result > x;
return result;
}
在实际编程中,推荐使用断言来检查那些在逻辑上认为不可能发生但如果发生则表示严重错误的条件(例如,检查不应该被修改的数据是否被意外改变)。而对于那些可能因外部因素引起并且程序需要恰当处理的情况,则应该使用异常。