聊一聊Java中的异常
# 什么是异常?
关于Java的异常,我们认为符合大致分为以下几种情况:
- 程序逻辑运行结果不符合预期。
- 程序执行时抛出各种exception。
- 因为各种原因导致服务崩溃。
Java
异常类体系结构如下图所示:
# Exception 和 Error 有什么区别?
Exception
:程序本身可以处理的异常,可以通过 catch
来进行捕获。Exception
又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
- 受检异常:受检异常的方法在调用时需要抛出的潜在风险进行处理,要么捕获,要么向上抛。如果受检查异常没有被
catch
或者throws
关键字处理的话,就没办法通过编译。除了RuntimeException
及其子类以外,其他的Exception
类及其子类都属于受检查异常 。 - 不受检异常:非受检异常即运行时才可能知晓的异常,该异常可由开发人员结合业务场景确定是否捕获。Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。
RuntimeException
及其子类都统称为非受检查异常。
Error
:Error
属于程序无法处理的错误 ,不建议通过catch
捕获 。例如 Java 虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
# throw与throws的区别
它们唯一的区别就在定义的位置:
throws
放在函数上,throw
放在函数内。throws
可以跟多个错误类,throw
只能跟单个异常对象
# Throwable 类常用方法有哪些?
String getMessage()
: 返回异常发生时的简要描述String toString()
: 返回异常发生时的详细信息String getLocalizedMessage()
: 返回异常对象的本地化信息。使用Throwable
的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()
返回的结果相同void printStackTrace()
: 在控制台上打印Throwable
对象封装的异常信息
# Throwable 类常用方法有哪些?
String getMessage()
: 返回异常发生时的简要描述String toString()
: 返回异常发生时的详细信息String getLocalizedMessage()
: 返回异常对象的本地化信息。使用Throwable
的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()
返回的结果相同void printStackTrace()
: 在控制台上打印Throwable
对象封装的异常信息
# try-catch-finally 如何使用?
try
块:用于捕获异常。其后可接零个或多个catch
块,如果没有catch
块,则必须跟一个finally
块。catch
块:用于处理 try 捕获到的异常。finally
块:无论是否捕获或处理异常,finally
块里的语句都会被执行。当在try
块或catch
块中遇到return
语句时,finally
语句块将在方法返回之前被执行。
代码示例:
try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
} finally {
System.out.println("Finally");
}
2
3
4
5
6
7
8
输出:
Try to do something
Catch Exception -> RuntimeException
Finally
2
3
注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。
# finally 中的代码一定会执行吗?
就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。
try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
// 终止当前正在运行的Java虚拟机
System.exit(1);
} finally {
System.out.println("Finally");
}
2
3
4
5
6
7
8
9
10
输出:
Try to do something
Catch Exception -> RuntimeException
2
另外,在以下 2 种特殊情况下,finally
块的代码也不会被执行:
- 程序所在的线程死亡。
- 关闭 CPU。
# 如何使用 try-with-resources
适用范围(资源的定义): 任何实现 java.lang.AutoCloseable
或者 java.io.Closeable
的对象
关闭资源和 finally 块的执行顺序: 在 try-with-resources
语句中,任何 catch 或 finally 块在声明的资源关闭后运行
Java 中类似于InputStream
、OutputStream
、Scanner
、PrintWriter
等的资源都需要我们调用close()
方法来手动关闭,一般情况下我们都是通过try-catch-finally
语句来实现这个需求:
Scanner scanner = null;
try {
scanner = new Scanner(new File("D://read.txt"));
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (scanner != null) {
scanner.close();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
使用 Java 7 之后的 try-with-resources
语句改造上面的代码:
try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}
2
3
4
5
6
7
当然多个资源需要关闭的时候,使用 try-with-resources
实现起来也非常简单,如果还是用try-catch-finally
可能会带来很多问题。通过使用分号分隔,可以在try-with-resources
块中声明多个资源。
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
2
3
4
5
6
7
8
9
10
# 异常使用有哪些需要注意的地方?
- 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
- 抛出的异常信息一定要有意义。
- 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出
NumberFormatException
而不是其父类IllegalArgumentException
。 - 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)
# 多异常捕获处理技巧
对于多异常需要捕获处理时,我们建议符合以下三大原则:
- 有几个异常就处理几个异常,如果无法处理就抛出。
- 父类
exception
放在最下方。 - 多异常建议使用
|
进行归类整理。
# 不要用异常控制流程
不要使用try块语句控制业务执行流程,原因如下:
try-catch
阻止JVM
试图进行的优化,所以当我们要使用try
块时,使用的粒度尽可能要小一些。- 现代标准遍历模式并不会导致冗余检查,所以我们无需为了避免越界检查而使用
try
块解决问题。
错误示例,不仅可读性差,而且性能不佳。
public static void stackPopByCatch() {
long start = System.currentTimeMillis();
try {
//插入1000w个元素
Stack stack = new Stack();
for (int i = 0; i < 1000_0000; i++) {
stack.push(i);
}
//使用pop抛出的异常判断出栈是否结束
while (true) {
try {
stack.pop();
} catch (Exception e) {
System.out.println("出栈结束");
break;
}
}
} catch (Exception e) {
}
long end = System.currentTimeMillis();
System.out.println("使用try进行异常捕获,执行时间:" + (end - start));
start = System.currentTimeMillis();
Stack stack2 = new Stack();
for (int i = 0; i < 1000_0000; i++) {
stack2.push(i);
}
//使用for循环控制代码流程
int size = stack2.size();
for (int i = 0; i < size; i++) {
stack2.pop();
}
end = System.currentTimeMillis();
System.out.println("使用逻辑进行出栈操作,执行时间:" + (end - start));
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
输出结果,可以看到用try块控制业务流程性能很差:
出栈结束
使用try进行异常捕获,执行时间:2613
使用逻辑进行出栈操作,执行时间:1481
2
3
# 避免频繁抛出和捕获异常
如下代码所示,可以看到频繁抛出和捕获对象是非常耗时的,所以不建议使用异常来作为处理逻辑,可以和前端协商好错误码从而避免没必要的性能开销
private int testTimes;
public ExceptionTest(int testTimes) {
this.testTimes = testTimes;
}
public void newObject() {
long l = System.currentTimeMillis();
for (int i = 0; i < testTimes; i++) {
new Object();
}
System.out.println("建立对象:" + (System.currentTimeMillis() - l));
}
public void newException() {
long l = System.currentTimeMillis();
for (int i = 0; i < testTimes; i++) {
new Exception();
}
System.out.println("建立异常对象:" + (System.currentTimeMillis() - l));
}
public void catchException() {
long l = System.currentTimeMillis();
for (int i = 0; i < testTimes; i++) {
try {
throw new Exception();
} catch (Exception e) {
}
}
System.out.println("建立、抛出并接住异常对象:" + (System.currentTimeMillis() - l));
}
public void catchObj() {
long l = System.currentTimeMillis();
for (int i = 0; i < testTimes; i++) {
try {
new Object();
} catch (Exception e) {
}
}
System.out.println("建立,普通对象并catch:" + (System.currentTimeMillis() - l));
}
public static void main(String[] args) {
ExceptionTest test = new ExceptionTest(100_0000);
test.newObject();
test.newException();
test.catchException();
test.catchObj();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
输出结果:
输出结果
建立对象:3
建立异常对象:484
建立、抛出并接住异常对象:539
建立,普通对象并catch:3
2
3
4
5
# 尽可能在for循环外捕获异常
try-catch时会捕获并创建异常对象,所以如果在for循环内频繁捕获异常会创建大量的异常对象:
public static void main(String[] args) {
outerTryCatch();
innerTryCatch();
}
//for 外部捕获异常
private static void outerTryCatch() {
long memory = Runtime.getRuntime().freeMemory();
try {
for (int i = 0; i < 30_0000; i++) {
int num = 10 / 0;
System.out.println("Fnum:" + num);
}
} catch (Exception e) {
}
long useMemory = (memory - Runtime.getRuntime().freeMemory()) / 1024 / 1024;
System.out.println("cost memory:" + useMemory + "M");
}
//for 内部捕获异常
private static void innerTryCatch() {
long memory = Runtime.getRuntime().freeMemory();
for (int i = 0; i < 30_0000; i++) {
try {
int num = 10 / 0;
System.out.println("num:" + num);
} catch (Exception e) {
}
}
long useMemory = (memory - Runtime.getRuntime().freeMemory()) / 1024 / 1024;
System.out.println("cost memory:" + useMemory + "M");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
输出结果如下,可以看到for循环内部捕获异常消耗了22M的堆内存。原因很简单,for外部捕获异常时,会直接终止for循环,而在for循环内部捕获异常仅结束本次循环的,所以如果for循环频繁报错,那么在内部捕获异常尽可能创建大量的异常对象。
cost memory:0M
cost memory:22M
2
# 参考文献
Java基础常见面试题总结(下):https://javaguide.cn/java/basis/java-basic-questions-03.html#项目相关(opens new window) (opens new window)
Effective Java中文版(第3版):https://book.douban.com/subject/30412517/(opens new window) (opens new window)
阿里巴巴Java开发手册:https://book.douban.com/subject/27605355/(opens new window) (opens new window)
Java核心技术·卷 I(原书第11版):https://book.douban.com/subject/34898994/(opens new window) (opens new window)
Java 基础 - 异常机制详解:https://www.pdai.tech/md/java/basic/java-basic-x-exception.html#异常是否耗时为什么会耗时