Java异常处理:从基础到最佳实践,提升代码健壮性

引言

异常是在程序执行过程中可能发生的错误事件,会中断其正常流程。Java 提供了一种强大且面向对象的处理异常情况的方式,称为 Java 异常处理。

Java 中的异常可以出现在不同的情况下,比如用户输入错误的数据、硬件故障、网络连接失败或者数据库服务器宕机。在特定异常情况下指定要执行的代码被称为异常处理。

抛出和捕获异常

当执行语句时发生错误,Java 会创建一个异常对象。该异常对象包含许多调试信息,如方法层次结构、异常发生的行号和异常类型。

如果在一个方法中发生了异常,创建异常对象并将其传递给运行时环境的过程被称为“抛出异常”。程序的正常流程停止,Java 运行时环境(JRE)尝试找到处理该异常的处理程序。异常处理器是能够处理异常对象的代码块。

  • 查找异常处理程序的逻辑从发生错误的方法开始。
  • 如果没有找到合适的处理程序,它将移动到调用该方法的方法。
  • 依此类推。

所以如果方法的调用堆栈是 A->B->C,并且在方法 C 中引发了一个异常,那么寻找适当的处理程序将会从 C->B->A 进行移动。

如果找到了合适的异常处理程序,异常对象将被传递给处理程序进行处理。处理程序被称为“捕获异常”。如果没有找到合适的异常处理程序,程序将终止并将异常信息打印到控制台。

Java 异常处理框架仅用于处理运行时错误。编译时错误必须由编写代码的开发人员进行修复,否则程序将无法执行。

Java 异常处理关键字

Java 为异常处理提供了特定的关键字。

  1. throw(抛出) – 我们知道,如果发生错误,会创建一个异常对象,然后 Java 运行时开始处理它们。有时候我们可能想在我们的代码中显式地生成异常。例如,在一个用户认证程序中,如果密码为空,我们应该向客户端抛出异常。使用 throw 关键字将异常抛给运行时处理。
  2. throws(声明) – 当我们在一个方法中抛出异常而不处理时,我们必须在方法签名中使用 throws 关键字,让调用程序知道可能被该方法抛出的异常。调用方法可能会处理这些异常,或者使用 throws 关键字将其传播到其调用方法。我们可以在 throws 子句中提供多个异常,它也可以与 main() 方法一起使用。
  3. try-catch(尝试-捕获) – 我们在代码中使用 try-catch 块来处理异常。try 是块的开始,catch 位于 try 块的末尾来处理异常。我们可以在 try 块中包含多个 catch 块。try-catch 块也可以嵌套。catch 块需要一个参数,参数的类型应该是 Exception 类型。
  4. finally(最终)finally 块是可选的,只能与 try-catch 块一起使用。由于异常会中断执行过程,我们可能有一些未关闭的资源,所以我们可以使用 finally 块。无论是否发生异常,finally 块始终会执行。

一个异常处理示例

异常处理.java

这是文章《Java中的异常处理》的第2部分(共7部分)。

package com.scdev.exceptions;

import java.io.FileNotFoundException;
import java.io.IOException;

public class ExceptionHandling {

	public static void main(String[] args) throws FileNotFoundException, IOException {
		try {
			testException(-5);
			testException(-10);
		} catch(FileNotFoundException e) {
			e.printStackTrace();
		} catch(IOException e) {
			e.printStackTrace();
		} finally {
			System.out.println("Releasing resources");
		}
		testException(15);
	}

	public static void testException(int i) throws FileNotFoundException, IOException {
		if (i < 0) {
			FileNotFoundException myException = new FileNotFoundException("Negative Integer " + i);
			throw myException;
		} else if (i > 10) {
			throw new IOException("Only supported for index 0 to 10");
		}
	}
}
  • testException() 方法使用 throw 关键字抛出异常。该方法的签名使用 throws 关键字来告知调用者它可能抛出的异常类型。
  • main() 方法中,我使用 try-catch 块来处理异常。当我不处理异常时,我通过 main() 方法中的 throws 子句将其传播到运行时。
  • testException(-10) 永远不会被执行,因为它前面的异常已经发生,然后 finally 块会被执行。

打印堆栈跟踪(printStackTrace())是 Exception 类中一个用于调试的有用方法之一。

这段代码将输出以下内容:

输出java.io.FileNotFoundException: Negative Integer -5 at com.Olivia.exceptions.ExceptionHandling.testException(ExceptionHandling.java:24) at com.Olivia.exceptions.ExceptionHandling.main(ExceptionHandling.java:10) Releasing resources Exception in thread "main" java.io.IOException: Only supported for index 0 to 10 at com.Olivia.exceptions.ExceptionHandling.testException(ExceptionHandling.java:27) at com.Olivia.exceptions.ExceptionHandling.main(ExceptionHandling.java:19)

一些需要注意的重要事项:

  • 没有 try 语句,就不能有 catchfinally 子句。
  • 一个 try 语句应该有 catch 块或 finally 块,也可以同时拥有这两个块。
  • 我们不能在 try-catch-finally 块之间编写任何代码。
  • 一个 try 语句可以有多个 catch 块。
  • try-catch 块可以像 if-else 语句一样嵌套。
  • 一个 try-catch 语句只能有一个 finally 块。

Java 异常层次结构

如前所述,当抛出异常时,会创建一个异常对象。Java 异常是层次化的,使用继承来对不同类型的异常进行分类。Throwable 是 Java 异常层次结构的父类,它有两个子对象——ErrorExceptionException 进一步分为已检查异常和运行时异常。

  1. 错误(Error): 错误是超出应用程序范围的特殊情况,无法预料并从中恢复。例如,硬件故障、Java虚拟机(JVM)崩溃或内存溢出错误。这就是为什么我们有一个单独的错误层次结构,我们不应该试图处理这些情况。一些常见的错误有 OutOfMemoryErrorStackOverflowError
  2. 已检查异常(Checked Exception): 已检查异常是我们可以在程序中预见并尝试从中恢复的异常情况。例如,FileNotFoundException。我们应该捕获此异常并为用户提供有用的消息,并且为了调试目的要正确记录它。Exception 是所有已检查异常的父类。如果我们抛出了一个已检查异常,必须在同一个方法中捕获它,或者使用 throws 关键字将其传递给调用者。
  3. 运行时异常(Runtime Exception): 运行时异常是由糟糕的编程造成的。例如,尝试从数组中检索元素。在尝试检索元素之前,我们应该先检查数组的长度,否则可能在运行时抛出 ArrayIndexOutOfBoundExceptionRuntimeException 是所有运行时异常的父类。如果我们在一个方法中抛出任何运行时异常,不需要在方法签名的 throws 子句中指定它们。通过更好的编程可以避免运行时异常。
Java异常层次结构图。Throwable位于图的顶部。该树的一个分支是Error。Error下方是OutOfMemoryError和IOError。该树的另一个分支是Exception。Exception分为IOException和RuntimeException。IOException下方是FileNotFoundException。RuntimeException下方是NullPointerException。

一些有用的异常类方法

Java 异常及其所有子类并未提供任何特定的方法,所有的方法都是在基类 Throwable 中定义的。异常类被创建用于指定不同的异常场景,以便我们可以轻松地识别根本原因,并根据异常类型处理异常。Throwable 类实现了 Serializable 接口,以实现互操作性。

Throwable 类的一些有用方法有:

  1. public String getMessage() – 该方法返回 Throwable 对象的消息字符串,异常在创建时可以提供消息。
  2. public String getLocalizedMessage() – 子类可以重写该方法,以向调用程序提供特定地区的消息。Throwable 类实现该方法时使用 getMessage() 方法返回异常消息。
  3. public synchronized Throwable getCause() – 该方法返回异常的原因,如果原因未知则返回 null
  4. public String toString() – 该方法以字符串格式返回 Throwable 的信息,返回的字符串包含 Throwable 类的名称和本地化信息。
  5. public void printStackTrace() – 该方法将堆栈跟踪信息打印到标准错误流,该方法有多个重载,我们可以传递 PrintStreamPrintWriter 作为参数,将堆栈跟踪信息写入文件或流中。

Java 7 在自动资源管理和异常捕获方面有所改进。

如果你在一个 try 块中捕获了很多异常,你会注意到 catch 块的代码主要包含重复的代码来记录错误。在 Java 7 中,一个改进的 catch 块的特性是我们可以在一个 catch 块中捕获多个异常。以下是使用此特性的 catch 块示例:

catch (IOException | SQLException ex) {
    logger.error(ex);
    throw new MyException(ex.getMessage());
}

有一些限制,比如异常对象是 final 的,我们无法在 catch 块内部修改它,在 Java 7 的 Catch 块改进中可以阅读完整的分析。

大多数情况下,我们使用 finally 块来关闭资源。有时候我们会忘记关闭它们,当资源耗尽时就会出现运行时异常。这些异常很难调试,我们可能需要查看每个使用该资源的地方以确保关闭它。在 Java 7 中,其中一个改进是使用 try-with-resources,在 try 语句中创建资源,并在 try-catch 块内使用它。当执行退出 try-catch 块时,运行环境会自动关闭这些资源。以下是使用这一改进的 try-catch 块的示例。

try (MyResource mr = new MyResource()) {
	System.out.println("MyResource created in try-with-resources");
} catch (Exception e) {
	e.printStackTrace();
}

自定义异常类的示例

在Java中,我们可以使用许多内置的异常类,但有时我们可能需要创建自己的自定义异常类。例如,当需要用特定的消息通知调用者某种类型的异常时,或者为了跟踪自定义字段(如错误代码)时。假设我们编写了一个只处理文本文件的方法,那么当输入其他类型的文件时,我们可以为调用者提供相应的错误代码。

首先,创建一个MyException类:

MyException.java

package com.scdev.exceptions;

public class MyException extends Exception {

	private static final long serialVersionUID = 4664456874499611218L;

	private String errorCode = "Unknown_Exception";

	public MyException(String message, String errorCode) {
		super(message);
		this.errorCode=errorCode;
	}

	public String getErrorCode() {
		return this.errorCode;
	}
}

然后,创建一个CustomExceptionExample类:

CustomExceptionExample.java

package com.scdev.exceptions;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;

public class CustomExceptionExample {

	public static void main(String[] args) throws MyException {
		try {
			processFile("file.txt");
		} catch (MyException e) {
			processErrorCodes(e);
		}
	}

	private static void processErrorCodes(MyException e) throws MyException {
		switch (e.getErrorCode()) {
			case "BAD_FILE_TYPE":
				System.out.println("文件类型错误,请通知用户");
				throw e;
			case "FILE_NOT_FOUND_EXCEPTION":
				System.out.println("文件未找到,请通知用户");
				throw e;
			case "FILE_CLOSE_EXCEPTION":
				System.out.println("文件关闭失败,仅记录日志。");
				break;
			default:
				System.out.println("发生未知异常,记录日志以便进一步调试。" + e.getMessage());
				e.printStackTrace();
		}
	}

	private static void processFile(String file) throws MyException {
		InputStream fis = null;

		try {
			fis = new FileInputStream(file);
		} catch (FileNotFoundException e) {
			throw new MyException(e.getMessage(), "FILE_NOT_FOUND_EXCEPTION");
		} finally {
			try {
				if (fis != null) fis.close();
			} catch (IOException e) {
				throw new MyException(e.getMessage(), "FILE_CLOSE_EXCEPTION");
			}
		}
	}
}

我们可以针对从不同方法中获取的不同类型的错误代码采用分开的处理方法。有些错误代码会被“消耗”掉(即捕获并处理,不再向上抛出),因为我们可能不想通知用户;而另一些错误代码则会被重新抛出,以通知用户问题所在。

在这里,我正在扩展异常,这样每当产生这个异常时,它必须在方法中处理或返回给调用者程序。如果我们扩展RuntimeException,则不需要在throws子句中指定。

这是一个设计决策。使用检查型异常(Checked Exception)的好处在于帮助开发人员了解可能发生的异常,并采取适当的措施来处理它们。

Java异常处理的最佳实践

  • 使用特定异常: 异常层次结构的基类不提供任何有用的信息,这就是为什么Java有如此多的异常类,例如IOException及其子类,如FileNotFoundExceptionEOFException等。我们应该始终抛出和捕获特定的异常类,这样调用者就能轻松了解异常的根本原因并进行处理。这使得调试更容易,并有助于客户端应用程序适当地处理异常。
  • 尽早抛出或快速失败(Fail-Fast): 我们应该尽量尽早抛出异常。考虑上述的processFile()方法,如果我们将null参数传递给这个方法,我们将得到以下异常:
输出
Exception in thread "main" java.lang.NullPointerException at java.io.FileInputStream.<init>(FileInputStream.java:134) at java.io.FileInputStream.<init>(FileInputStream.java:97) at com.Olivia.exceptions.CustomExceptionExample.processFile(CustomExceptionExample.java:42) at com.Olivia.exceptions.CustomExceptionExample.main(CustomExceptionExample.java:12)

在调试过程中,我们将不得不仔细查看堆栈跟踪以确定异常的实际位置。如果我们将我们的实现逻辑更改为如下:尽早检查这些异常。

private static void processFile(String file) throws MyException {
	if (file == null) throw new MyException("文件名不能为空", "NULL_FILE_NAME");

	// ... 进一步处理
}

然后异常堆栈跟踪将指示异常发生的位置,并提供清晰的信息。

输出
com.Olivia.exceptions.MyException: 文件名不能为空
	at com.Olivia.exceptions.CustomExceptionExample.processFile(CustomExceptionExample.java:37)
	at com.Olivia.exceptions.CustomExceptionExample.main(CustomExceptionExample.java:12)
  • 延迟捕获(Catch Late)

    由于Java强制要求处理受检异常(checked exception)或在方法签名中声明它,有时开发者倾向于捕获异常并记录错误。然而,这种做法是有害的,因为调用程序无法收到异常通知。我们只应在能够恰当处理异常时才捕获它们。例如,在上述方法中,我将异常抛回给调用方法进行处理。同一方法可能被其他应用程序使用,这些应用程序可能希望以不同方式处理异常。在实现任何功能时,我们应始终将异常抛回给调用者,让他们决定如何处理。

  • 关闭资源(Closing Resources)

    由于异常会中断程序的执行,我们应该在finally块中关闭所有资源,或者使用Java 7的try-with-resources增强功能,让Java运行时自动关闭资源。

  • 记录异常(Logging Exceptions)

    我们应始终记录异常消息,并在抛出异常时提供清晰的消息,以便调用者能轻松了解异常发生的原因。我们应始终避免使用空的catch块,因为它只会吞噬异常,不提供任何有意义的异常细节用于调试。

  • 单个catch块处理多个异常(Single catch block for multiple exceptions)

    大多数情况下,我们记录异常详情并向用户提供消息。在这种情况下,我们应该使用Java 7的特性,在单个catch块中处理多个异常。这种方法将减少代码量,并使其看起来更整洁。

  • 使用自定义异常(Using Custom Exceptions)

    在设计阶段定义异常处理策略总是更好的选择。与其抛出和捕获多个异常,不如创建带有错误代码的自定义异常,调用程序可以处理这些错误代码。创建一个实用方法来处理不同的错误代码并使用它们也是一个好主意。

  • 命名约定和包结构(Naming Conventions and Packaging)

    当你创建自定义异常时,请确保其名称以“Exception”结尾,这样从名称本身就能清楚地看出它是一个异常类。此外,请确保像Java开发工具包(JDK)中那样对其进行打包。例如,IOException是所有IO操作的基础异常。

  • 明智地使用异常(Use Exceptions Judiciously)

    异常开销较大,有时根本不需要抛出异常,我们可以向调用程序返回一个布尔变量来指示操作是否成功。这在操作是可选且你不希望程序因失败而卡住时很有用。例如,在从第三方Web服务更新数据库中的股票报价时,如果连接失败,我们可能希望避免抛出异常。

  • 文档化抛出的异常(Document the Exceptions Thrown)

    使用Javadoc的@throws标签清晰地指定方法抛出的异常。当你为其他应用程序提供接口时,这非常有帮助。

结论

在本文中,你了解了Java中的异常处理。你学到了throwthrows的用法。你还学到了try(以及try-with-resources)、catchfinally块的用法。

bannerAds