你未必了解的java异常处理知识
本文向你介绍下java异常处理的基础知识以及一些必须掌握的技巧。
异常的基本原理
什么是异常 (what)
为了更好理解异常和异常处理,我们用生活中例子进行类比。想像下我们在网上订购商品,但在运输途中因某些原因导致投递失败,好的公司会及时处理这类问题,重新发送包裹并仍能准时送达。
同样,在java中代码执行我们的指令时,可能会遇到错误。好的异常处理可以处理错误并优雅地重新路由程序,给用户带来积极的体验。
为什么使用异常(why)
我们通常在理想的环境中写代码:文件系统总是包含文件,网络总是好的,jvm总是有足够的内存。通常我们称之为“快乐路径”(程序主逻辑)。
但是在生产环境中,文件系统可能会崩溃,网络会断开或延时,jvm会耗尽内存。我们代码的好坏取决于它如何处理“非快乐路径”(分支逻辑)。
我们必须处理这些条件,因为这些会对程序产生负面影响并形成异常:
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 <-- players.dat file doesn't exist
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 java.nio.file.Files.readAllLines(Unknown Source)
at Exceptions.getPlayers(Exceptions.java:12) <-- Exception arises in getPlayers() method, on line 12
at Exceptions.main(Exceptions.java:19) <-- getPlayers() is called by main(), on line 19
如果不处理异常,好的程序也会受其影响完全停止运行。我们需要确保程序遇到错误时有相应的处理措施。另外异常还有一个好处,那就是堆栈跟踪本身。因为根据堆栈跟踪,我们可以在不需要额外调试器情况下精确定位有问题的代码。
Exception 类继承结构
---> Throwable <---
| (checked) |
| |
| |
---> Exception Error
| (checked) (unchecked)
|
RuntimeException
(unchecked)
主要有三类异常:
* 检查异常
* 非检查异常 / 运行时异常
* 错误
运行时异常和非检查异常指的是同一件事,经常可以互换使用。
检查异常
检查异常是java编译器需要我们必须去处理的异常。我们必须自己处理异常或声明异常给调用栈,稍后我们会详细说明两种方法。
依照官网文档说法,当能够合理预期方法调用者能够恢复异常时,应该使用检查异常。典型的检查异常类有:IOException 和 ServletException。
非检查异常
非检查异常是java编译器不需要去处理的异常。简单地说,如果定义异常从RuntimeException类继承,则为非检查异常,否则为检查异常。虽然这听起来很简单,但官方文档告诉我们这两个概念都有很好使用场景,比如区分情景错误(检查异常)和用法错误(非检查异常)。
典型的非检查异常类有:NullPointerException, IllegalArgumentException, SecurityException。
引用官方文档的一条底线原则:如果客户端可以合理地期望从异常中恢复,那么将其设置为检查异常。如果客户端无法从异常恢复,则将其设置为未检查异常。
错误
错误描述严重的,通常不可恢复的条件,如库不兼容,无限递归或内存泄漏等。错误类不继承自RuntimeException类,也是非检查异常。在大多数情况下,处理、实例化或扩展错误对我们来说都很奇怪,通常它们一直向上传播。
典型的错误类有StackOverflowError 和 OutOfMemoryError。
异常处理
java API中有很多地方可能会引起错误,其中一些被标记异常,通过方法签名或在javadoc中:
/**
* @exception FileNotFoundException …
*/
public Scanner(String fileName) throws FileNotFoundException {
// …
}
如前所述,当我们调用这些“高风险”方法时,必须处理检查异常,并且可以处理非检查异常。Java为我们提供了几种方法:
throws
最简单的方式处理异常是重新抛异常:
public int getPlayerScore(String playerFile) throws FileNotFoundException {
Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
}
因为FileNotFoundException是检查异常,这些最简单方式可以满足编译器,这意味着调用者必须也要处理。
parseInt方法也抛出NumberFormatException,因为属于非检查异常可以不处理。
try–catch
如果我们想自己处理异常,可以使用try–catch块。也可以通过重新抛出异常方式进行处理:
public int getPlayerScore(String playerFile) {
try {
Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException noFile) {
throw new IllegalArgumentException("File not found");
}
}
或者执行恢复步骤:
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;
}
}
finally
有时我会遇到无论是否有一次发生,我们都需要执行一些代码,这时就需要finally关键字。
到目前为止,在我们的示例中,隐藏着一个令人讨厌的bug,即Java默认不会将文件句柄返回操作系统。当然,无论我们是否能够读取文件,都确要保进行了适当的清理!
我们首先使用懒惰方式处理:
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();
}
}
}
这里finally块指定了无论是否读取文件都要执行的代码。即使FileNotFoundException 异常向上抛给调用栈,java也会在这之前执行finally块内容。
我们也可以自己处理异常,并确保资源被关闭:
public int getPlayerScore(String playerFile) {
Scanner contents;
try {
contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException noFile ) {
logger.warn("File not found, resetting score.");
return 0;
} finally {
try {
if (contents != null) {
contents.close();
}
} catch (IOException io) {
logger.error("Couldn't close the reader!", io);
}
}
}
因为close方法有风险,所有也需要捕获该异常。这看起来非常复杂,但需要每个部分正确地去处理潜在的异常。
try-with-resources
幸运的是java7帮我们简化上面的语法,如果资源继承自AutoClosable:
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;
}
}
当我们把AutoClosable引用资源房子try声明块中,那么我们不需要手动自己关闭资源。当日我们仍然可以使用finally块处理其他清理工作。
多个try块
有时代码可能会抛出多个异常,可以使用多个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类不需要捕获,因为我们已经捕获了IOException,java将捕获所有其子类。
如果我们需要区别处理FileNotFoundException异常,而不是通用的IOException:
public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile)) ) {
return Integer.parseInt(contents.nextLine());
} 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;
} catch (NumberFormatException e) {
logger.warn("Player file was corrupted!", e);
return 0;
}
}
java支持独立处理子类异常,但需要放在catch列表的较高位置。
联合catch块
java7引入在同一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;
}
}
抛出异常
如果我们不想处理异常或生成异常给别人处理,需要属性throw关键字。下面是我们定义的检查异常:
public class TimeoutException extends Exception {
public TimeoutException(String message) {
super(message);
}
}
有一个方法可能会需要较长时间去执行:
public List<Player> loadAllPlayers(String playersFile) {
// ... potentially long operation
}
抛出检查异常
如方法的返回值,可以在任何地方throw。当然我们指明遇到错误时抛出异常:
public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
while ( !tooLong ) {
// ... potentially long operation
}
throw new TimeoutException("This operation took too long");
}
因为TimeoutException 是检查异常,我们必须使用throw关键字进行方法签名,为了让调用者进行处理。
抛出非检查异常
如果我们处理如验证输入,可以使用非检查异常代替:
public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
if(!isFilenameValid(playersFile)) {
throw new IllegalArgumentException("Filename isn't valid!");
}
// ...
}
因为IllegalArgumentException 是非检查异常,无需签名方法,但最好增加签名。签名方法也是一种文档说明方式。
包装并重新抛出
我们可以选择重新抛出我们遇到的异常:
public List<Player> loadAllPlayers(String playersFile) throws IOException {
try {
// ...
} catch (IOException io) {
throw io;
}
}
或在包装后重新抛出:
public List<Player> loadAllPlayers(String playersFile)
throws PlayerLoadException {
try {
// ...
} catch (IOException io) {
throw new PlayerLoadException(io);
}
}
这种方式可以整合多个异常为一个。
重新抛出 Throwable 或 Exception 异常
作为一个特例,如果代码中触发非检查异常,我们可以捕获该异常并重新抛出Throwable 或 Exception ,但不需要在方法上进行签名:
public List<Player> loadAllPlayers(String playersFile) {
try {
throw new NullPointerException();
} catch (Throwable t) {
throw t;
}
}
但上面代码不能抛出检查异常,如果抛出检查异常,也必须使用抛出异常增加方法签名。这代理类和方法中很方便。
异常继承
使用throw关键字签名方法,会影响所有子类中重写的方法。假设我们方法抛出一个检查异常:
public class Exceptions {
public List<Player> loadAllPlayers(String playersFile)
throws TimeoutException {
// ...
}
}
子类可以进行减少风险签名:
public class FewerExceptions extends Exceptions {
@Override
public List<Player> loadAllPlayers(String playersFile) {
// overridden
}
}
但不能增加风险进行签名:
public class MoreExceptions extends Exceptions {
@Override
public List<Player> loadAllPlayers(String playersFile) throws MyCheckedException {
// overridden
}
}
因为约定是在编译时通过引用类型决定的,如果创建MoreExceptions 实例并赋值给Exceptions类型:
Exceptions exceptions = new MoreExceptions();
exceptions.loadAllPlayers("file");
jvm仅告诉我们捕获之前申明的TimeoutException,但是实际抛出不同的异常MoreExceptions#loadAllPlayers。
总之,子类只能抛出比父类更小的检查异常,不能更大。
异常处理反模式
吞异常
这个展示一种方式可以满足编译器:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (Exception e) {} // <== catch and swallow
return 0;
}
上面代码称为吞食异常,大多数情况下,这种方式对我们来说没有意义,因为并没有解决问题,并且其他调用者也不能解决问题。
即使有我们坚信永远不会发生的检查异常,也至少增加注释说明我们内部吞食了这个异常。
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
// this will never happen
}
}
另一种吞食异常是简单打印异常至错误流:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
更好的做法是至少输出错误信息到某个地方,为后续诊断提供依据,下面示例使用日志:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
logger.error("Couldn't load the score", e);
return 0;
}
}
这种方式对处理异常很方便,我们需要确保不吞食重要信息,为调用这修复问题。
最后,我们可能通过抛出新的异常却不包括实际异常信息而无意中吞食异常:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
throw new PlayerScoreException();
}
}
这里,我们为提醒调用这注意错误而沾沾自喜,但却没有包括IOException 错误信息。因此我们已经丢失重要信息,调用者或操作人员无法诊断问题。更好的做法为:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
throw new PlayerScoreException(e);
}
}
请注意,将IOException包含到PlayerScoreException异常中的细微差别。
finally块中使用return语句
另一种吞食异常方式是在finally块中使用return语句。因为即使我们在代码中使用throw抛出异常,但因为return突然返回,jvm会删除异常。
public int getPlayerScore(String playerFile) {
int score = 0;
try {
throw new IOException();
} finally {
return score; // <== the IOException is dropped
}
}
在finally块中使用throw
与在finally块中使用return类似,在finally块中抛出异常优先于在catch块中触发的异常。这会擦涂原在catch块中的异常,导致有价值信息丢失:
public int getPlayerScore(String playerFile) {
try {
// ...
} catch ( IOException io ) {
throw new IllegalStateException(io); // <== eaten by the finally
} finally {
throw new OtherException();
}
}
把throw当作goto使用
有些人会落入使用throw作为goto的诱惑:
public void doSomething() {
try {
// bunch of code
throw new MyException();
// second bunch of code
} catch (MyException e) {
// third bunch of code
}
}
奇怪原因是尝试使用异常作为流程控制,而不是错误处理。
场景的Exception 和 Error类
下面列举经常遇到的Exception 和 Error类
检查异常
- IOException – 该异常通常表示网络,文件系统或数据库出现故障。
RuntimeException
- ArrayIndexOutOfBoundsException – 表示尝试访问不存在的数组索引,如我们使用索引5访问长度只有3个数组。
- ClassCastException – 表示尝试执行非法转换,如转换String至List。通常要在执行前进行预防检查。
- IllegalArgumentException – 提供一种通用方式检查方法或构造函数参数有效性。
- IllegalStateException – 表示内部状态是无效的,如对象状态。
- NullPointerException – 表示引用null对象,通常要在使用前进行预防检查或使用Optional。
- NumberFormatException – 表示尝试转换String至数组,但字符串包含非法字符,如转换 “5f3” 为数值。
Error
- StackOverflowError – 表示栈跟踪太大,这有时会发生在大量应用程序中;然而,这通常意味着我们的代码中发生了一些无限递归。
- NoClassDefFoundError – 表示类无法加载,要么是因为类路径不存在,要么是因为静态初始化失败。
- OutOfMemoryError – 表示JVM没有更多的内存可以分配给对象,通常是由于内存泄漏。
总结
本文我们介绍了异常处理的基础知识以及一些好的和不好的实际示例。