Java异常简介#
Java异常机制可以使程序中异常处理代码和正常业务代码分离,保证程序代码更加优雅,并提高程序健壮性。
其他参考
在有效使用异常的情况下,异常能清晰的回答what、where、why这3个问题:异常类型回答了“什么”被抛出,异常堆栈跟踪回答了“在哪“抛出,异常信息回答了“为什么“会抛出。
Java异常机制用到的几个关键字:try、catch、finally、throw、throws。
try:用于监听。将要被监听的代码(可能抛出异常的代码)放在try语句块之内,当try语句块内发生异常时,异常就被抛出。
catch:用于捕获异常。catch用来捕获try语句块中发生的异常。
finally:finally语句块总是会被执行。它主要用于回收在try块里打开的物理资源,如数据库连接、网络连接和磁盘文件。只有finally块执行完成之后,才会回来执行try或者catch块中的return或者throw语句,如果finally中使用了return或者throw等终止方法的语句,则就不会跳回执行,直接停止。
throw:用于抛出异常。
throws:用在方法签名中,用于声明该方法可能抛出的异常。
在计算机程序运行的过程中,总是会出现各种各样的错误。
有一些错误是用户造成的,比如,希望用户输入一个int类型的年龄,但是用户的输入是abc:
1
2
3
// 假设用户输入了abc:
String s = "abc" ;
int n = Integer.parseInt (s); // NumberFormatException!
copy
程序想要读写某个文件的内容,但是用户已经把它删除了:
1
2
// 用户删除了该文件:
String t = readFile("C:\\abc.txt" ); // FileNotFoundException!
copy
还有一些错误是随机出现,并且永远不可能避免的。比如:
网络突然断了,连接不到远程服务器;
内存耗尽,程序崩溃了;
用户点“打印”,但根本没有打印机;
所以,一个健壮的程序必须处理各种各样的错误。
所谓错误,就是程序调用某个函数的时候,如果失败了,就表示出错。
调用方如何获知调用失败的信息?有两种方法:
方法一:约定返回错误码。
例如,处理一个文件,如果返回0,表示成功,返回其他整数,表示约定的错误码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int code = processFile("C:\\test.txt" );
if (code == 0) {
// ok:
} else {
// error:
switch (code) {
case 1:
// file not found:
case 2:
// no read permission:
default :
// unknown error:
}
}
copy
因为使用int类型的错误码,想要处理就非常麻烦。这种方式常见于底层C函数。
方法二:在语言层面上提供一个异常处理机制。Java内置了一套异常处理机制,总是使用异常来表示错误。
异常是一种class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了:
1
2
3
4
5
6
7
8
9
10
11
12
try {
String s = processFile("C:\\test.txt" );
// ok:
} catch (FileNotFoundException e) {
// file not found:
} catch (SecurityException e) {
// no read permission:
} catch (IOException e) {
// io error:
} catch (Exception e) {
// other error:
}
copy
因为Java的异常是class,它的继承关系如下:
1
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
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Throwable │
└───────────┘
▲
┌─────────┴─────────┐
│ │
┌───────────┐ ┌───────────┐
│ Error │ │ Exception │
└───────────┘ └───────────┘
▲ ▲
┌───────┘ ┌────┴──────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐┌───────────┐
│OutOfMemoryError │... │RuntimeException ││IOException│...
└─────────────────┘ └─────────────────┘└───────────┘
▲
┌───────────┴─────────────┐
│ │
┌─────────────────────┐ ┌─────────────────────────┐
│NullPointerException │ │IllegalArgumentException │...
└─────────────────────┘ └─────────────────────────┘
copy
从继承关系可知:Throwable是异常体系的根,它继承自Object。
Throwable有两个体系:Error和Exception,Error表示严重的错误,程序对此一般无能为力,例如:
OutOfMemoryError:内存耗尽
NoClassDefFoundError:无法加载某个Class
StackOverflowError:栈溢出
而Exception则是运行时的错误,它可以被捕获并处理。
某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:
NumberFormatException:数值类型的格式错误
FileNotFoundException:未找到文件
SocketException:读取网络失败
还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:
NullPointerException:对某个null的对象调用方法或字段
IndexOutOfBoundsException:数组索引越界
Exception又分为两大类:
RuntimeException以及它的子类;
非RuntimeException(包括IOException、ReflectiveOperationException等等)
Java规定:
必须捕获的异常,包括Exception及其子类,但不包括RuntimeException及其子类,这种类型的异常称为Checked Exception。
不需要捕获的异常,包括Error及其子类,RuntimeException及其子类。
注意:编译器对RuntimeException及其子类不做强制捕获要求,不是指应用程序本身不应该捕获并处理RuntimeException。是否需要捕获,具体问题具体分析。
Java将可抛出(Throwable)的结构分为三种类型:
被检查的异常(Checked Exception)
运行时异常(RuntimeException)
错误(Error)
运行时异常
定义:RuntimeException
及其子类都被称为运行时异常。
特点:Java编译器不会检查它。也就是说,当程序中可能出现这类异常时,倘若既没有通过throws声明抛出它,也没有用try-catch语句捕获它,还是会编译通过。例如,除数为零时产生的ArithmeticException
异常,数组越界时产生的 IndexOutOfBoundsException
异常,fail-fail机制产生的ConcurrentModificationException
异常等,都属于运行时异常。虽然Java编译器不会检查运行时异常,但是我们也可以通过throws进行声明抛出,也可以通过try-catch对它进行捕获处理。如果产生运行时异常,则需要通过修改代码来进行避免。例如,若会发生除数为零的情况,则需要通过代码避免该情况的发生。
被检查的异常
定义:Exception
类本身,以及Exception的子类中除了运行时异常
之外的其它子类都属于被检查异常。特点:Java编译器会检查它。此类异常,要么通过throws进行声明抛出,要么通过try-catch进行捕获处理,否则不能通过编译。例如,CloneNotSupportedException
就属于被检查异常。当通过clone()接口去克隆一个对象,而该对象对应的类没有实现Cloneable接口,就会抛出CloneNotSupportedException
异常。被检查异常通常都是可以恢复的。
错误
定义:Error类及其子类。
特点:和运行时异常一样,编译器也不会对错误进行检查。
当资源不足、约束失败、或是其它程序无法继续运行的条件发生时,就产生错误。程序本身无法修复这些错误的。例如,VirtualMachineError
就属于错误。按照Java惯例,我们是不应该是实现任何新的Error子类的。
对于上面的3种结构,我们在抛出异常或错误时,到底该哪一种?
《Effective Java》中给出的建议是:对于可以恢复的条件使用被检查异常,对于程序错误使用运行时异常。
捕获异常#
捕获异常使用try…catch语句,把可能发生异常的代码放到try {…}中,然后使用catch捕获对应的Exception及其子类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
byte [] bs = toGBK("中文" );
System.out .println (Arrays.toString (bs));
}
static byte [] toGBK(String s) {
try {
// 用指定编码转换String为byte[]:
return s.getBytes ("GBK" );
} catch (UnsupportedEncodingException e) {
// 如果系统不支持GBK编码,会捕获到UnsupportedEncodingException:
System.out .println (e); // 打印异常信息
return s.getBytes (); // 尝试使用用默认编码
}
}
}
copy
如果我们不捕获UnsupportedEncodingException
,会出现编译失败的问题:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
byte [] bs = toGBK("中文" );
System.out .println (Arrays.toString (bs));
}
static byte [] toGBK(String s) {
return s.getBytes ("GBK" );
}
}
copy
编译器会报错,错误信息类似:unreported exception UnsupportedEncodingException; must be caught or declared to be thrown
,并且准确地指出需要捕获的语句是return s.getBytes("GBK");
。意思是说,像UnsupportedEncodingException
这样的Checked Exception
,必须被捕获。
这是因为String.getBytes(String)
方法定义是:
1
2
3
public byte [] getBytes(String charsetName) throws UnsupportedEncodingException {
...
}
copy
在方法定义的时候,使用throws Xxx表示该方法可能抛出的异常类型。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错。
在toGBK()方法中,因为调用了String.getBytes(String)
方法,就必须捕获UnsupportedEncodingException
。我们也可以不捕获它,而是在方法定义处用throws表示toGBK()方法可能会抛出UnsupportedEncodingException
,就可以让toGBK()方法通过编译器检查:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
byte [] bs = toGBK("中文" );
System.out .println (Arrays.toString (bs));
}
static byte [] toGBK(String s) throws UnsupportedEncodingException {
return s.getBytes ("GBK" );
}
}
copy
上述代码仍然会得到编译错误,但这一次,编译器提示的不是调用return s.getBytes("GBK");
的问题,而是byte[] bs = toGBK("中文");
。因为在main()方法中,调用toGBK(),没有捕获它声明的可能抛出的UnsupportedEncodingException
。
修复方法是在main()方法中捕获异常并处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
public static void main(String[] args) {
try {
byte [] bs = toGBK("中文" );
System.out .println (Arrays.toString (bs));
} catch (UnsupportedEncodingException e) {
System.out .println (e);
}
}
static byte [] toGBK(String s) throws UnsupportedEncodingException {
// 用指定编码转换String为byte[]:
return s.getBytes ("GBK" );
}
}
copy
可见,只要是方法声明的Checked Exception,不在调用层捕获,也必须在更高的调用层捕获。所有未捕获的异常,最终也必须在main()方法中捕获,不会出现漏写try的情况。这是由编译器保证的。main()方法也是最后捕获Exception的机会。
如果是测试代码,上面的写法就略显麻烦。如果不想写任何try代码,可以直接把main()方法定义为throws Exception:
1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args) throws Exception {
byte [] bs = toGBK("中文" );
System.out .println (Arrays.toString (bs));
}
static byte [] toGBK(String s) throws UnsupportedEncodingException {
// 用指定编码转换String为byte[]:
return s.getBytes ("GBK" );
}
}
copy
因为main()方法声明了可能抛出Exception,也就声明了可能抛出所有的Exception,因此在内部就无需捕获了。代价就是一旦发生异常,程序会立刻退出。
还有一些童鞋喜欢在toGBK()内部“消化”异常。
1
2
3
4
5
6
7
static byte [] toGBK(String s) {
try {
return s.getBytes ("GBK" );
} catch (UnsupportedEncodingException e) {
// 什么也不干
}
return null ;
copy
这种捕获后不处理的方式是非常不好的,即使真的什么也做不了,也要先把异常记录下来:
1
2
3
4
5
6
7
8
static byte [] toGBK(String s) {
try {
return s.getBytes ("GBK" );
} catch (UnsupportedEncodingException e) {
// 先记下来再说:
e.printStackTrace ();
}
return null ;
copy
所有异常都可以调用printStackTrace()方法打印异常栈,这是一个简单有用的快速打印异常的方法。
在Java中,凡是可能抛出异常的语句,都可以用try … catch捕获。把可能发生异常的语句放在try { … }中,然后使用catch捕获对应的Exception及其子类。
可以使用多个catch语句,每个catch分别捕获对应的Exception及其子类。JVM在捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,然后不再继续匹配。
简单地说就是:多个catch语句只有一个能被执行。例如:
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out .println (e);
} catch (NumberFormatException e) {
System.out .println (e);
}
}
copy
存在多个catch的时候,catch的顺序非常重要:子类必须写在前面。例如:
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out .println ("IO error" );
} catch (UnsupportedEncodingException e) { // 永远捕获不到
System.out .println ("Bad encoding" );
}
}
copy
对于上面的代码,UnsupportedEncodingException异常是永远捕获不到的,因为它是IOException的子类。当抛出UnsupportedEncodingException异常时,会被catch (IOException e) { ... }
捕获并执行。因此,正确的写法是把子类放到前面:
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out .println ("Bad encoding" );
} catch (IOException e) {
System.out .println ("IO error" );
}
}
copy
finally语句#
无论是否有异常发生,如果我们都希望执行一些语句,例如清理工作,怎么写?
可以把执行语句写若干遍:正常执行的放到try中,每个catch再写一遍。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {
try {
process1();
process2();
process3();
System.out .println ("END" );
} catch (UnsupportedEncodingException e) {
System.out .println ("Bad encoding" );
System.out .println ("END" );
} catch (IOException e) {
System.out .println ("IO error" );
System.out .println ("END" );
}
}
copy
上述代码无论是否发生异常,都会执行System.out.println("END");
这条语句。
那么如何消除这些重复的代码?Java的try … catch机制还提供了finally语句,finally语句块保证有无错误都会执行。上述代码可以改写如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out .println ("Bad encoding" );
} catch (IOException e) {
System.out .println ("IO error" );
} finally {
System.out .println ("END" );
}
}
copy
注意finally有几个特点:
finally语句不是必须的,可写可不写;
finally总是最后执行。
如果没有发生异常,就正常执行try { … }语句块,然后执行finally。如果发生了异常,就中断执行try { … }语句块,然后跳转执行匹配的catch语句块,最后执行finally。
可见,finally是用来保证一些代码必须执行的。
某些情况下,可以没有catch,只使用try … finally结构。例如:
1
2
3
4
5
6
7
void process(String file) throws IOException {
try {
...
} finally {
System.out .println ("END" );
}
}
copy
因为方法声明了可能抛出的异常,所以可以不写catch。
捕获多种异常#
如果某些异常的处理逻辑相同,但是异常本身不存在继承关系,那么就得编写多条catch子句:
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out .println ("Bad input" );
} catch (NumberFormatException e) {
System.out .println ("Bad input" );
} catch (Exception e) {
System.out .println ("Unknown error" );
}
}
copy
因为处理IOException和NumberFormatException的代码是相同的,所以我们可以把它两用|
合并到一起:
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException | NumberFormatException e) { // IOException或NumberFormatException
System.out .println ("Bad input" );
} catch (Exception e) {
System.out .println ("Unknown error" );
}
}
copy
抛出异常#
当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个try … catch被捕获为止:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace ();
}
}
static void process1() {
process2();
}
static void process2() {
Integer.parseInt (null ); // 会抛出NumberFormatException
}
}
copy
通过printStackTrace()可以打印出方法的调用栈,类似:
1
2
3
4
5
6
java.lang.NumberFormatException: null
at java.base/java.lang.Integer.parseInt(Integer.java:614)
at java.base/java.lang.Integer.parseInt(Integer.java:770)
at Main.process2(Main.java:16)
at Main.process1(Main.java:12)
at Main.main(Main.java:5)
copy
printStackTrace()对于调试错误非常有用,上述信息表示:NumberFormatException是在java.lang.Integer.parseInt方法中被抛出的,从下往上看,调用层次依次是:
main()调用process1();
process1()调用process2();
process2()调用Integer.parseInt(String);
Ineger.parseInt(String)调用Integer.parseInt(String, int)。
查看Integer.java源码可知,抛出异常的方法代码如下:
1
2
3
4
5
6
public static int parseInt(String s, int radix) throws NumberFormatException {
if (s == null ) {
throw new NumberFormatException("null" );
}
...
}
copy
查看Integer.java源码可知,抛出异常的方法代码如下:
1
2
3
4
5
6
public static int parseInt(String s, int radix) throws NumberFormatException {
if (s == null ) {
throw new NumberFormatException("null" );
}
...
}
copy
并且,每层调用均给出了源代码的行号,可直接定位。
当发生错误时,例如,用户输入了非法的字符,我们就可以抛出异常。
如何抛出异常?参考Integer.parseInt()
方法,抛出异常分两步:
创建某个Exception的实例;
用throw语句抛出。
1
2
3
4
5
6
void process2(String s) {
if (s == null ) {
NullPointerException e = new NullPointerException();
throw e;
}
}
copy
实际上,绝大部分抛出异常的代码都会合并写成一行:
1
2
3
4
5
void process2(String s) {
if (s == null ) {
throw new NullPointerException();
}
}
copy
如果一个方法捕获了某个异常后,又在catch子句中抛出新的异常,就相当于把抛出的异常类型“转换”了:
1
2
3
4
5
6
7
8
9
10
11
12
13
void process1(String s) {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}
void process2(String s) {
if (s == null ) {
throw new NullPointerException();
}
}
copy
当process2()抛出NullPointerException后,被process1()捕获,然后抛出IllegalArgumentException()。
如果在main()中捕获IllegalArgumentException,我们看看打印的异常栈:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace ();
}
}
static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}
static void process2() {
throw new NullPointerException();
}
}
copy
打印出的异常栈类似:
1
2
3
java.lang.IllegalArgumentException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)
copy
这说明新的异常丢失了原始异常信息,我们已经看不到原始异常NullPointerException的信息了。
为了能追踪到完整的异常栈,在构造异常的时候,把原始的Exception实例传进去,新的Exception就可以持有原始Exception信息。对上述代码改进如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace ();
}
}
static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException(e);
}
}
static void process2() {
throw new NullPointerException();
}
}
copy
运行上述代码,打印出的异常栈类似:
1
2
3
4
5
6
java.lang .IllegalArgumentException : java.lang .NullPointerException
at Main.process1 (Main.java :15)
at Main.main (Main.java :5)
Caused by: java.lang .NullPointerException
at Main.process2 (Main.java :20)
at Main.process1 (Main.java :13)
copy
注意到Caused by: Xxx,说明捕获的IllegalArgumentException并不是造成问题的根源,根源在于NullPointerException,是在Main.process2()方法抛出的。
在代码中获取原始异常可以使用Throwable.getCause()方法。如果返回null,说明已经是“根异常”了。
有了完整的异常栈的信息,我们才能快速定位并修复代码的问题。
捕获到异常并再次抛出时,一定要留住原始异常,否则很难定位第一案发现场!
如果我们在try或者catch语句块中抛出异常,finally语句是否会执行?例如:
1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
try {
Integer.parseInt ("abc" );
} catch (Exception e) {
System.out .println ("catched" );
throw new RuntimeException(e);
} finally {
System.out .println ("finally" );
}
}
}
copy
上述代码执行结果如下:
1
2
3
4
5
6
catched
finally
Exception in thread "main" java.lang.RuntimeException: java.lang.NumberFormatException: For input string: "abc"
at Main.main(Main.java:8)
Caused by: java.lang.NumberFormatException: For input string: "abc"
at ...
copy
第一行打印了catched,说明进入了catch语句块。第二行打印了finally,说明执行了finally语句块。
因此,在catch中抛出异常,不会影响finally的执行。JVM会先执行finally,然后抛出异常。
异常屏蔽#
如果在执行finally语句时抛出异常,那么,catch语句的异常还能否继续抛出?例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
try {
Integer.parseInt ("abc" );
} catch (Exception e) {
System.out .println ("catched" );
throw new RuntimeException(e);
} finally {
System.out .println ("finally" );
throw new IllegalArgumentException();
}
}
}
copy
执行上述代码,发现异常信息如下:
1
2
3
4
catched
finally
Exception in thread "main" java.lang.IllegalArgumentException
at Main.main(Main.java:11)
copy
这说明finally抛出异常后,原来在catch中准备抛出的异常就“消失”了,因为只能抛出一个异常。没有被抛出的异常称为“被屏蔽”的异常(Suppressed Exception)。
在极少数的情况下,我们需要获知所有的异常。如何保存所有的异常信息?方法是先用origin变量保存原始异常,然后调用Throwable.addSuppressed()
,把原始异常添加进来,最后在finally抛出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) throws Exception {
Exception origin = null ;
try {
System.out .println (Integer.parseInt ("abc" ));
} catch (Exception e) {
origin = e;
throw e;
} finally {
Exception e = new IllegalArgumentException();
if (origin != null ) {
e.addSuppressed (origin);
}
throw e;
}
}
}
copy
当catch和finally都抛出了异常时,虽然catch的异常被屏蔽了,但是,finally抛出的异常仍然包含了它:
1
2
3
4
5
6
7
Exception in thread "main" java.lang .IllegalArgumentException
at Main.main (Main.java :11)
Suppressed: java.lang .NumberFormatException : For input string: "abc"
at java.base /java.lang .NumberFormatException .forInputString (NumberFormatException.java :65)
at java.base /java.lang .Integer .parseInt (Integer.java :652)
at java.base /java.lang .Integer .parseInt (Integer.java :770)
at Main.main (Main.java :6)
copy
通过Throwable.getSuppressed()可以获取所有的Suppressed Exception。
绝大多数情况下,在finally中不要抛出异常。因此,我们通常不需要关心Suppressed Exception。
自定义异常#
Java标准库定义的常用异常包括:
1
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
Exception
│
├─ RuntimeException
│ │
│ ├─ NullPointerException
│ │
│ ├─ IndexOutOfBoundsException
│ │
│ ├─ SecurityException
│ │
│ └─ IllegalArgumentException
│ │
│ └─ NumberFormatException
│
├─ IOException
│ │
│ ├─ UnsupportedCharsetException
│ │
│ ├─ FileNotFoundException
│ │
│ └─ SocketException
│
├─ ParseException
│
├─ GeneralSecurityException
│
├─ SQLException
│
└─ TimeoutException
copy
当我们在代码中需要抛出异常时,尽量使用JDK已定义的异常类型。例如,参数检查不合法,应该抛出IllegalArgumentException
:
1
2
3
4
5
static void process1(int age) {
if (age <= 0) {
throw new IllegalArgumentException();
}
}
copy
在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。
一个常见的做法是自定义一个BaseException作为“根异常”,然后,派生出各种业务类型的异常。
BaseException需要从一个适合的Exception派生,通常建议从RuntimeException派生:
public class BaseException extends RuntimeException {}
其他业务类型的异常就可以从BaseException派生:
public class UserNotFoundException extends BaseException {}
public class LoginFailedException extends BaseException {}
自定义的BaseException应该提供多个构造方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BaseException extends RuntimeException {
public BaseException() {
super ();
}
public BaseException(String message, Throwable cause) {
super (message, cause);
}
public BaseException(String message) {
super (message);
}
public BaseException(Throwable cause) {
super (cause);
}
}
copy
上述构造方法实际上都是原样照抄RuntimeException。这样,抛出异常的时候,就可以选择合适的构造方法。通过IDE可以根据父类快速生成子类的构造方法。
NullPointerException#
在所有的RuntimeException异常中,Java程序员最熟悉的恐怕就是NullPointerException了。
NullPointerException即空指针异常,俗称NPE。如果一个对象为null,调用其方法或访问其字段就会产生NullPointerException,这个异常通常是由JVM抛出的,例如:
1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
String s = null ;
System.out .println (s.toLowerCase ());
}
}
copy
指针这个概念实际上源自C语言,Java语言中并无指针。我们定义的变量实际上是引用,Null Pointer更确切地说是Null Reference,不过两者区别不大。
如果遇到NullPointerException,我们应该如何处理?首先,必须明确,NullPointerException是一种代码逻辑错误,遇到NullPointerException,遵循原则是早暴露,早修复,严禁使用catch来隐藏这种编码错误:
1
2
3
4
5
// 错误示例: 捕获NullPointerException
try {
transferMoney(from, to, amount);
} catch (NullPointerException e) {
}
copy
好的编码习惯可以极大地降低NullPointerException的产生,例如:
成员变量在定义时初始化:
1
2
3
public class Person {
private String name = "";
}
copy
使用空字符串"“而不是默认的null可避免很多NullPointerException,编写业务逻辑时,用空字符串"“表示未填写比null安全得多。
返回空字符串”"、空数组而不是null:
1
2
3
4
5
6
7
public String[] readLinesFromFile(String file) {
if (getFileSize(file) == 0) {
// 返回空数组而不是null:
return new String[0];
}
...
}
copy
这样可以使得调用方无需检查结果是否为null。
如果调用方一定要根据null判断,比如返回null表示文件不存在,那么考虑返回Optional<T>
:
1
2
3
4
5
6
public Optional<String> readFromFile(String file) {
if (!fileExist(file)) {
return Optional.empty ();
}
...
}
copy
这样调用方必须通过Optional.isPresent()判断是否有结果。
定位NullPointerException#
如果产生了NullPointerException,例如,调用a.b.c.x()时产生了NullPointerException,原因可能是:
a是null;
a.b是null;
a.b.c是null;
只能通过这样的日志,才能判断哪个对象是null
1
2
3
System.out.println(a);
System.out.println(a.b);
System.out.println(a.b.c);
copy
从Java 14开始,如果产生了NullPointerException,JVM可以给出详细的信息告诉我们null对象到底是谁。我们来看例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
Person p = new Person();
System.out .println (p.address .city .toLowerCase ());
}
}
class Person {
String[] name = new String[2];
Address address = new Address();
}
class Address {
String city;
String street;
String zipcode;
}
copy
可以在NullPointerException
的详细信息中看到类似... because "<local1>.address.city" is null
,意思是city字段为null,这样我们就能快速定位问题所在。
这种增强的NullPointerException详细信息是Java 14新增的功能,但默认是关闭的,我们可以给JVM添加参数启用它:
java -XX:+ShowCodeDetailsInExceptionMessages Main.java
使用断言#
断言(Assertion)是一种调试程序的方式。在Java中,使用assert关键字来实现断言。
1
2
3
4
5
public static void main(String[] args) {
double x = Math.abs (-123.45 );
assert x >= 0;
System.out .println (x);
}
copy
语句assert x >= 0;即为断言,断言条件x >= 0预期为true。如果计算结果为false,则断言失败,抛出AssertionError。
使用assert语句时,还可以添加一个可选的断言消息:
assert x >= 0 : "x must >= 0";
Java断言的特点是:断言失败时会抛出AssertionError,导致程序结束退出。因此,断言不能用于可恢复的程序错误,只应该用于开发和测试阶段。
对于可恢复的程序错误,不应该使用断言。例如:
1
2
3
void sort(int[] arr) {
assert arr != null;
}
copy
应该抛出异常并在上层捕获:
1
2
3
4
5
void sort(int[] arr) {
if (arr == null) {
throw new IllegalArgumentException("array cannot be null");
}
}
copy
当我们在程序中使用assert时,例如,一个简单的断言:
1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
int x = -1;
assert x > 0;
System.out .println (x);
}
}
copy
断言x必须大于0,实际上x为-1,断言肯定失败。执行上述代码,发现程序并未抛出AssertionError,而是正常打印了x的值。
这是怎么肥四?为什么assert语句不起作用?
这是因为JVM默认关闭断言指令,即遇到assert语句就自动忽略了,不执行。
要执行assert语句,必须给Java虚拟机传递-enableassertions(可简写为-ea)参数启用断言。所以,上述程序必须在命令行下运行才有效果:
1
2
3
$ java -ea Main.java
Exception in thread "main" java.lang.AssertionError
at Main.main(Main.java:5)
copy
还可以有选择地对特定地类启用断言,命令行参数是:-ea:com.itranswarp.sample.Main,表示只对com.itranswarp.sample.Main这个类启用断言。
或者对特定地包启用断言,命令行参数是:-ea:com.itranswarp.sample…(注意结尾有3个.),表示对com.itranswarp.sample这个包启动断言。
实际开发中,很少使用断言。更好的方法是编写单元测试,后续我们会讲解JUnit的使用。
异常和日志#
使用JDK Logging#
在编写程序的过程中,发现程序运行结果与预期不符,怎么办?当然是用System.out.println()
打印出执行过程中的某些变量,观察每一步的结果与代码逻辑是否符合,然后有针对性地修改代码。
代码改好了怎么办?当然是删除没有用的System.out.println()语句了。
如果改代码又改出问题怎么办?再加上System.out.println()。
反复这么搞几次,很快大家就发现使用System.out.println()非常麻烦。
怎么办?
解决方法是使用日志。
那什么是日志?日志就是Logging,它的目的是为了取代System.out.println()。
输出日志,而不是用System.out.println(),有以下几个好处:
可以设置输出样式,避免自己每次都写"ERROR: " + var;
可以设置输出级别,禁止某些级别输出。例如,只输出错误日志;
可以被重定向到文件,这样可以在程序运行结束后查看日志;
可以按包名控制日志级别,只输出某些包打的日志;
可以……
总之就是好处很多啦。
因为Java标准库内置了日志包java.util.logging
,我们可以直接用。先看一个简单的例子:
1
2
3
4
5
6
7
8
9
public class Hello {
public static void main(String[] args) {
Logger logger = Logger.getGlobal ();
logger.info ("start process..." );
logger.warning ("memory is running out..." );
logger.fine ("ignored." );
logger.severe ("process will be terminated..." );
}
}
copy
运行上述代码,得到类似如下的输出:
1
2
3
4
5
6
Mar 02, 2019 6:32:13 PM Hello main
INFO: start process...
Mar 02, 2019 6:32:13 PM Hello main
WARNING: memory is running out...
Mar 02, 2019 6:32:13 PM Hello main
SEVERE: process will be terminated...
copy
对比可见,使用日志最大的好处是,它自动打印了时间、调用类、调用方法等很多有用的信息。
再仔细观察发现,4条日志,只打印了3条,logger.fine()没有打印。这是因为,日志的输出可以设定级别。JDK的Logging定义了7个日志级别,从严重到普通:
SEVERE
WARNING
INFO
CONFIG
FINE
FINER
FINEST
因为默认级别是INFO,因此,INFO级别以下的日志,不会被打印出来。使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出。
使用Java标准库内置的Logging有以下局限:
Logging系统在JVM启动时读取配置文件并完成初始化,一旦开始运行main()方法,就无法修改配置;配置不太方便,需要在JVM启动时传递参数-Djava.util.logging.config.file=<config-file-name>。
因此,Java标准库内置的Logging使用并不是非常广泛。更方便的日志系统我们稍后介绍。
使用Commons Logging#
和Java标准库提供的日志不同,Commons Logging是一个第三方日志库,它是由Apache创建的日志模块。
使用 Commons Logging,需要引入
1
2
3
4
5
<dependency>
<groupId> commons-logging</groupId>
<artifactId> commons-logging</artifactId>
<version> 1.2</version>
</dependency>
copy
Commons Logging的特色是,它可以挂接不同的日志系统,并通过配置文件指定挂接的日志系统。默认情况下,Commons Loggin自动搜索并使用Log4j(Log4j是另一个流行的日志系统),如果没有找到Log4j,再使用JDK Logging。
使用Commons Logging只需要和两个类打交道,并且只有两步:
第一步,通过LogFactory获取Log类的实例; 第二步,使用Log实例的方法打日志。
1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
Log log = LogFactory.getLog (Main.class );
log.info ("start..." );
log.warn ("end." );
}
}
copy
运行结果如下:
1
2
3
4
Mar 02, 2019 7:15:31 PM Main main
INFO: start...
Mar 02, 2019 7:15:31 PM Main main
WARNING: end.
copy
Commons Logging定义了6个日志级别:
fatal
error
waring
info
debug
trace
默认级别是INFO。
使用Commons Logging时,如果在静态方法中引用Log,通常直接定义一个静态类型变量:
1
2
3
4
5
6
7
8
// 在静态方法中引用Log:
public class Main {
static final Log log = LogFactory.getLog (Main.class );
static void foo() {
log.info ("foo" );
}
}
copy
在实例方法中引用Log,通常定义一个实例变量:
1
2
3
4
5
6
7
8
// 在实例方法中引用Log:
public class Person {
protected final Log log = LogFactory.getLog (getClass());
void foo() {
log.info ("foo" );
}
}
copy
注意到实例变量log的获取方式是
LogFactory.getLog(getClass())
,
虽然也可以用
LogFactory.getLog(Person.class)
,
但是前一种方式有个非常大的好处,就是子类可以直接使用该log实例。例如:
1
2
3
4
5
6
// 在子类中使用父类实例化的 log :
public class Student extends Person {
void bar() {
log .info("bar" );
}
}
copy
由于Java类的动态特性,子类获取的log字段实际上相当于LogFactory.getLog(Student.class),但却是从父类继承而来,并且无需改动代码。
此外,Commons Logging的日志方法,例如info(),除了标准的info(String)外,还提供了一个非常有用的重载方法:info(String, Throwable),这使得记录异常更加简单:
1
2
3
4
5
try {
...
} catch (Exception e) {
log.error ("got exception!" , e);
}
copy
使用Log4j#
前面介绍了Commons Logging,可以作为“日志接口”来使用。而真正的“日志实现”可以使用Log4j。
Log4j是一种非常流行的日志框架,最新版本是2.x。
Log4j是一个组件化设计的日志系统,它的架构大致如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
log.info("User signed in.");
│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ Console │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ File │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
└──>│ Appender │───>│ Filter │───>│ Layout │───>│ Socket │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
copy
当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地。例如:
console:输出到屏幕;
file:输出到文件;
socket:通过网络输出到远程计算机;
jdbc:输出到数据库
在输出日志的过程中,通过Filter来过滤哪些log需要被输出,哪些log不需要被输出。例如,仅输出ERROR级别的日志。
最后,通过Layout来格式化日志信息,例如,自动添加日期、时间、方法名称等信息。
上述结构虽然复杂,但我们在实际使用的时候,并不需要关心Log4j的API,而是通过配置文件来配置它。
以XML配置为例,使用Log4j的时候,我们把一个log4j2.xml的文件放到classpath下就可以让Log4j读取配置文件并按照我们的配置来输出日志。下面是一个配置文件的例子:
1
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
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Properties>
<!-- 定义日志格式 -->
<Property name= "log.pattern" > %d{MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36}%n%msg%n%n</Property>
<!-- 定义文件名变量 -->
<Property name= "file.err.filename" > log/err.log</Property>
<Property name= "file.err.pattern" > log/err.%i.log.gz</Property>
</Properties>
<!-- 定义Appender,即目的地 -->
<Appenders>
<!-- 定义输出到屏幕 -->
<Console name= "console" target= "SYSTEM_OUT" >
<!-- 日志格式引用上面定义的log.pattern -->
<PatternLayout pattern= "${log.pattern}" />
</Console>
<!-- 定义输出到文件,文件名引用上面定义的file.err.filename -->
<RollingFile name= "err" bufferedIO= "true" fileName= "${file.err.filename}" filePattern= "${file.err.pattern}" >
<PatternLayout pattern= "${log.pattern}" />
<Policies>
<!-- 根据文件大小自动切割日志 -->
<SizeBasedTriggeringPolicy size= "1 MB" />
</Policies>
<!-- 保留最近10份 -->
<DefaultRolloverStrategy max= "10" />
</RollingFile>
</Appenders>
<Loggers>
<Root level= "info" >
<!-- 对info级别的日志,输出到console -->
<AppenderRef ref= "console" level= "info" />
<!-- 对error级别的日志,输出到err,即上面定义的RollingFile -->
<AppenderRef ref= "err" level= "error" />
</Root>
</Loggers>
</Configuration>
copy
虽然配置Log4j比较繁琐,但一旦配置完成,使用起来就非常方便。对上面的配置文件,凡是INFO级别的日志,会自动输出到屏幕,而ERROR级别的日志,不但会输出到屏幕,还会同时输出到文件。并且,一旦日志文件达到指定大小(1MB),Log4j就会自动切割新的日志文件,并最多保留10份。
使用log4j,需要先引入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>org.apache .logging .log4j </groupId>
<artifactId>log4j-api</artifactId>
<version>2.14 .1 </version>
</dependency>
<dependency>
<groupId>org.apache .logging .log4j </groupId>
<artifactId>log4j-core</artifactId>
<version>2.14 .1 </version>
</dependency>
<dependency>
<groupId>org.apache .logging .log4j </groupId>
<artifactId>log4j-jcl</artifactId>
<version>2.14 .1 </version>
</dependency>
copy
因为Commons Logging会自动发现并使用Log4j,要打印日志,只需要按Commons Logging的写法写,不需要改动任何代码,就可以得到Log4j的日志输出,类似:
1
2
03-03 12:09:45.880 [main] INFO com.itranswarp.learnjava.Main
Start process...
copy
使用SLF4J和Logback#
SLF4J类似于Commons Logging,也是一个日志接口,而Logback类似于Log4j,是一个日志的实现。
为什么有了Commons Logging和Log4j,又会蹦出来SLF4J和Logback?这是因为Java有着非常悠久的开源历史,不但OpenJDK本身是开源的,而且我们用到的第三方库,几乎全部都是开源的。开源生态丰富的一个特定就是,同一个功能,可以找到若干种互相竞争的开源库。
因为对Commons Logging的接口不满意,有人就搞了SLF4J。因为对Log4j的性能不满意,有人就搞了Logback。
我们先来看看SLF4J对Commons Logging的接口有何改进。在Commons Logging中,我们要打印日志,有时候得这么写:
1
2
3
int score = 99;
p.setScore(score);
log.info("Set score " + score + " for Person " + p.getName() + " ok.");
copy
拼字符串是一个非常麻烦的事情,所以SLF4J的日志接口改进成这样了:
1
2
3
int score = 99;
p.setScore(score);
logger.info("Set score {} for Person {} ok.", score, p.getName());
copy
我们靠猜也能猜出来,SLF4J的日志接口传入的是一个带占位符的字符串,用后面的变量自动替换占位符,所以看起来更加自然。
如何使用SLF4J?它的接口实际上和Commons Logging几乎一模一样:
1
2
3
4
5
6
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class Main {
final Logger logger = LoggerFactory.getLogger (getClass());
}
copy
对比一下Commons Logging和SLF4J的接口:
Commons Logging
SLF4J
org.apache.commons.logging.Log
org.slf4j.Logger
org.apache.commons.logging.LogFactory
org.slf4j.LoggerFactory
不同之处就是Log变成了Logger,LogFactory变成了LoggerFactory。
要使用SLF4J和Logback,先引入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>org.slf4j </groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7 .32 </version>
</dependency>
<dependency>
<groupId>ch.qos .logback </groupId>
<artifactId>logback-classic</artifactId>
<version>1.2 .5 </version>
</dependency>
<dependency>
<groupId>ch.qos .logback </groupId>
<artifactId>logback-core</artifactId>
<version>1.2 .5 </version>
</dependency>
copy
然后使用SLF4J的Logger和LoggerFactory即可。和Log4j类似,我们仍然需要一个Logback的配置文件,把logback.xml放到classpath下,配置如下:
1
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
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name= "CONSOLE" class= "ch.qos.logback.core.ConsoleAppender" >
<encoder>
<pattern> %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name= "FILE" class= "ch.qos.logback.core.rolling.RollingFileAppender" >
<encoder>
<pattern> %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset> utf-8</charset>
</encoder>
<file> log/output.log</file>
<rollingPolicy class= "ch.qos.logback.core.rolling.FixedWindowRollingPolicy" >
<fileNamePattern> log/output.log.%i</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class= "ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy" >
<MaxFileSize> 1MB</MaxFileSize>
</triggeringPolicy>
</appender>
<root level= "INFO" >
<appender-ref ref= "CONSOLE" />
<appender-ref ref= "FILE" />
</root>
</configuration>
copy
运行即可获得类似如下的输出:
13:15:25.328 [main] INFO com.itranswarp.learnjava.Main - Start process...
异常简单示例#
try和catch基本用法#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo1 {
public static void main(String[] args) {
try {
int i = 10/0;
System.out .println ("i=" +i);
} catch (ArithmeticException e) {
System.out .println ("Caught Exception" );
System.out .println ("e.getMessage(): " + e.getMessage ());
System.out .println ("e.toString(): " + e.toString ());
System.out .println ("e.printStackTrace():" );
e.printStackTrace ();
}
}
}
copy
运行结果:
1
2
3
4
5
6
Caught Exception
e.getMessage (): / by zero
e.toString (): java.lang .ArithmeticException : / by zero
e.printStackTrace ():
java.lang .ArithmeticException : / by zero
at Demo1.main (Demo1.java :6)
copy
结果说明:在try语句块中有除数为0的操作,该操作会抛出java.lang.ArithmeticException异常。
通过catch,对该异常进行捕获。观察结果我们发现,并没有执行System.out.println(“i="+i)。
这说明try语句块发生异常之后,try语句块中的剩余内容就不会再被执行了。
finally的基本用法#
在示例一的基础上,添加finally语句。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Demo2 {
public static void main(String[] args) {
try {
int i = 10/0;
System.out .println ("i=" +i);
} catch (ArithmeticException e) {
System.out .println ("Caught Exception" );
System.out .println ("e.getMessage(): " + e.getMessage ());
System.out .println ("e.toString(): " + e.toString ());
System.out .println ("e.printStackTrace():" );
e.printStackTrace ();
} finally {
System.out .println ("run finally" );
}
}
}
copy
运行结果:
1
2
3
4
5
6
7
Caught Exception
e.getMessage(): / by zero
e.toString(): java.lang.ArithmeticException: / by zero
e.printStackTrace():
java.lang.ArithmeticException: / by zero
at Demo2.main(Demo2.java:6)
run finally
copy
结果说明:最终执行了finally语句块。
throws和throw的基本用法#
throws是用于声明抛出的异常,而throw是用于抛出异常。
1
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
class MyException extends Exception {
public MyException() {}
public MyException(String msg) {
super (msg);
}
}
public class Demo3 {
public static void main(String[] args) {
try {
test();
} catch (MyException e) {
System.out .println ("Catch My Exception" );
e.printStackTrace ();
}
}
public static void test() throws MyException{
try {
int i = 10/0;
System.out .println ("i=" +i);
} catch (ArithmeticException e) {
throw new MyException("This is MyException" );
}
}
}
copy
运行结果:
1
2
3
4
Catch My Exception
MyException: This is MyException
at Demo3.test(Demo3.java:24)
at Demo3.main(Demo3.java:13)
copy
结果说明:
MyException是继承于Exception的子类。test()的try语句块中产生ArithmeticException异常(除数为0),并在catch中捕获该异常;接着抛出MyException异常。main()方法对test()中抛出的MyException进行捕获处理。
try-catch和循环#
try-catch 应该放在循环体外,还是放在循环体内?下面从性能和业务场景分析这两个方面来分析这个问题。
性能分析#
我们使用 Oracle 官方提供的 JMH(Java Microbenchmark Harness,JAVA 微基准测试套件)来进行测试。
首先在 pom.xml 文件中添加 JMH 框架,配置如下:
1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.openjdk.jmh/jmh-core -->
<dependency>
<groupId> org.openjdk.jmh</groupId>
<artifactId> jmh-core</artifactId>
<version> {version}</version>
</dependency>
copy
完整测试代码如下:
1
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
48
49
50
51
52
53
54
55
56
57
58
59
60
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
/**
* try - catch 性能测试
*/
@BenchmarkMode(Mode.AverageTime ) // 测试完成时间
@OutputTimeUnit(TimeUnit.NANOSECONDS )
@Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS ) // 预热 1 轮,每次 1s
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS ) // 测试 5 轮,每次 3s
@Fork(1) // fork 1 个线程
@State(Scope.Benchmark )
@Threads(100)
public class TryCatchPerformanceTest {
private static final int forSize = 1000; // 循环次数
public static void main(String[] args) throws RunnerException {
// 启动基准测试
Options opt = new OptionsBuilder()
.include (TryCatchPerformanceTest.class .getSimpleName ()) // 要导入的测试类
.build ();
new Runner(opt).run (); // 执行测试
}
@Benchmark
public int innerForeach() {
int count = 0;
for (int i = 0; i < forSize; i++) {
try {
if (i == forSize) {
throw new Exception("new Exception" );
}
count++;
} catch (Exception e) {
e.printStackTrace ();
}
}
return count;
}
@Benchmark
public int outerForeach() {
int count = 0;
try {
for (int i = 0; i < forSize; i++) {
if (i == forSize) {
throw new Exception("new Exception" );
}
count++;
}
} catch (Exception e) {
e.printStackTrace ();
}
return count;
}
}
copy
从结果可以看出,在没有发生异常的情况下,除去误差值,我们得到的结论是:try-catch 无论是在 for 循环内还是 for 循环外,它们的性能相同,几乎没有任何差别。
try-catch的本质#
要理解 try-catch 的性能问题,必须从它的字节码开始分析,只有这样我能才能知道 try-catch 的本质到底是什么,以及它是如何执行的。
此时我们写一个最简单的 try-catch 代码:
1
2
3
4
5
6
7
8
9
10
public class AppTest {
public static void main(String[] args) {
try {
int count = 0;
throw new Exception("new Exception" );
} catch (Exception e) {
e.printStackTrace ();
}
}
}
copy
然后使用 javac 生成字节码之后,再使用 javap -c AppTest 的命令来查看字节码文件:
1
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
➜ javap -c AppTest
警告: 二进制文件AppTest包含com.example .AppTest
Compiled from "AppTest.java"
public class com.example .AppTest {
public com.example .AppTest ();
Code:
0: aload_0
1: invokespecial # 1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang .String []);
Code:
0: iconst_0
1: istore_1
2: new # 2 // class java/lang/Exception
5: dup
6: ldc # 3 // String new Exception
8: invokespecial # 4 // Method java/lang/Exception."<init>":(Ljava/lang/String;)V
11: athrow
12: astore_1
13: aload_1
14: invokevirtual # 5 // Method java/lang/Exception.printStackTrace:()V
17: return
Exception table:
from to target type
0 12 12 Class java/lang/Exception
}
copy
从以上字节码中可以看到有一个异常表:
1
2
3
Exception table:
from to target type
0 12 12 Class java/lang/Exception
copy
参数说明:
from:表示 try-catch 的开始地址;
to:表示 try-catch 的结束地址;
target:表示异常的处理起始位;
type:表示异常类名称。
从字节码指令可以看出,当代码运行时出错时,会先判断出错数据是否在 from 到to 的范围内,如果是则从 target 标志位往下执行,如果没有出错,直接 goto 到return。也就是说,如果代码不出错的话,性能几乎是不受影响的,和正常的代码的执行逻辑是一样的。
业务情况分析#
虽然 try-catch 在循环体内还是循环体外的性能是类似的,但是它们所代码的业务含义却完全不同,例如以下代码:
1
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
public class AppTest {
public static void main(String[] args) {
System.out .println ("循环内的执行结果:" + innerForeach());
System.out .println ("循环外的执行结果:" + outerForeach());
}
// 方法一
public static int innerForeach() {
int count = 0;
for (int i = 0; i < 6; i++) {
try {
if (i == 3) {
throw new Exception("new Exception" );
}
count++;
} catch (Exception e) {
e.printStackTrace ();
}
}
return count;
}
// 方法二
public static int outerForeach() {
int count = 0;
try {
for (int i = 0; i < 6; i++) {
if (i == 3) {
throw new Exception("new Exception" );
}
count++;
}
} catch (Exception e) {
e.printStackTrace ();
}
return count;
}
}
copy
以上程序的执行结果为:
1
2
3
4
5
6
7
8
java.lang.Exception: new Exception
at com.example.AppTest.innerForeach(AppTest.java:15)
at com.example.AppTest.main(AppTest.java:5)
java.lang.Exception: new Exception
at com.example.AppTest.outerForeach(AppTest.java:31)
at com.example.AppTest.main(AppTest.java:6)
循环内的执行结果:5
循环外的执行结果:3
copy
可以看出在循环体内的 try-catch 在发生异常之后,可以继续执行循环;而循环外的 try-catch 在发生异常之后会终止循环。
因此我们在决定 try-catch 究竟是应该放在循环内还是循环外,不取决于性能(因为性能几乎相同),而是应该取决于具体的业务场景。
例如我们需要处理一批数据,而无论这组数据中有哪一个数据有问题,都不能影响其他组的正常执行,此时我们可以把 try-catch 放置在循环体内;而当我们需要计算一组数据的合计值时,只要有一组数据有误,我们就需要终止执行,并抛出异常,此时我们需要将 try-catch 放置在循环体外来执行。
总结
我们测试了 try-catch 放在循环体内和循环体外的性能,发现二者在循环很多次的情况下性能几乎是一致的。然后我们通过字节码分析,发现只有当发生异常时,才会对比异常表进行异常处理,而正常情况下则可以忽略 try-catch 的执行。但在循环体内还是循环体外使用 try-catch,对于程序的执行结果来说是完全不同的,因此我们应该从实际的业务出发,来决定到 try-catch 应该存放的位置,而非性能考虑。
《Effective Java》中关于处理异常的建议#
下面的内容对应原书中‘第8章 异常’部分的第39-47条。
只针对不正常的情况才使用异常#
建议:异常只应该被用于不正常的条件,它们永远不应该被用于正常的控制流。
通过比较下面的两份代码进行说明。
代码1
1
2
3
4
5
6
7
8
try {
int i=0;
while (true ) {
arr[i]=0;
i++;
}
} catch (IndexOutOfBoundsException e) {
}
copy
代码2
1
2
3
for (int i=0; i<arr.length ; i++) {
arr[i]=0;
}
copy
两份代码的作用都是遍历arr数组,并设置数组中每一个元素的值为0。代码1的是通过异常来终止,看起来非常难懂,代码2是通过数组边界来终止。我们应该避免使用代码1这种方式,主要原因有三点:
异常机制的设计初衷是用于不正常的情况,所以很少会会JVM实现试图对它们的性能进行优化。所以,创建、抛出和捕获异常的开销是很昂贵的。
把代码放在try-catch中返回阻止了JVM实现本来可能要执行的某些特定的优化。
对数组进行遍历的标准模式并不会导致冗余的检查,有些现代的JVM实现会将它们优化掉。
实际上,基于异常的模式比标准模式要慢得多。测试代码如下:
1
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
public class Advice1 {
private static int [] arr = new int []{1, 2, 3, 4, 5};
private static int SIZE = 10000;
public static void main(String[] args) {
long s1 = System.currentTimeMillis ();
for (int i=0; i<SIZE; i++)
endByRange(arr);
long e1 = System.currentTimeMillis ();
System.out .println ("endByRange time:" +(e1-s1)+"ms" );
long s2 = System.currentTimeMillis ();
for (int i=0; i<SIZE; i++)
endByException(arr);
long e2 = System.currentTimeMillis ();
System.out .println ("endByException time:" +(e2-s2)+"ms" );
}
// 遍历arr数组: 通过异常的方式
private static void endByException(int [] arr) {
try {
int i=0;
while (true ) {
arr[i]=0;
i++;
//System.out.println("endByRange: arr["+i+"]="+arr[i]);
}
} catch (IndexOutOfBoundsException e) {
}
}
// 遍历arr数组: 通过边界的方式
private static void endByRange(int [] arr) {
for (int i=0; i<arr.length ; i++) {
arr[i]=0;
//System.out.println("endByException: arr["+i+"]="+arr[i]);
}
}
}
copy
运行结果:
1
2
endByRange time:0ms
endByException time:25ms
copy
结果说明:通过异常遍历的速度比普通方式遍历数组慢很多。
对于可恢复的条件使用被检查的异常,对于程序错误使用运行时异常#
运行时异常 – RuntimeException类及其子类都被称为运行时异常。
被检查的异常 – Exception类本身,以及Exception的子类中除了"运行时异常"之外的其它子类都属于被检查异常。
它们的区别是:Java编译器会对"被检查的异常"进行检查,而对"运行时异常"不会检查。
也就是说,对于被检查的异常,要么通过throws进行声明抛出,要么通过try-catch进行捕获处理,否则不能通过编译。而对于运行时异常,倘若既"没有通过throws声明抛出它”,也"没有用try-catch语句捕获它”,还是会编译通过。虽说Java编译器不会检查运行时异常,但是,我们同样可以通过throws对该异常进行说明,或通过try-catch进行捕获。
ArithmeticException(例如,除数为0),IndexOutOfBoundsException(例如,数组越界)等都属于运行时异常。对于这种异常,我们应该通过修改代码进行避免它的产生。而对于被检查的异常,则可以通过处理让程序恢复运行。例如,假设因为一个用户没有存储足够数量的钱,所以他在企图在一个收费电话上进行呼叫就会失败;于是就将一个被检查异常抛出。
避免不必要的使用被检查的异常#
“被检查的异常"是Java语言的一个很好的特性。与返回代码不同,“被检查的异常"会强迫程序员处理例外的条件,大大提高了程序的可靠性。
但是,过分使用被检查异常会使API用起来非常不方便。如果一个方法抛出一个或多个被检查的异常,那么调用该方法的代码则必须在一个或多个catch语句块中处理这些异常,或者必须通过throws声明抛出这些异常。无论是通过catch处理,还是通过throws声明抛出,都给程序员添加了不可忽略的负担。
适用于"被检查的异常"必须同时满足两个条件:第一,即使正确使用API并不能阻止异常条件的发生。第二,一旦产生了异常,使用API的程序员可以采取有用的动作对程序进行处理。
尽量使用标准的异常#
代码重用是值得提倡的,这是一条通用规则,异常也不例外。重用现有的异常有几个好处:
它使得你的API更加易于学习和使用,因为它与程序员原来已经熟悉的习惯用法是一致的。
对于用到这些API的程序而言,它们的可读性更好,因为它们不会充斥着程序员不熟悉的异常。
异常类越少,意味着内存占用越小,并且转载这些类的时间开销也越小。
Java标准异常中有几个是经常被使用的异常。如下表格:
异常
使用场景
IllegalArgumentException
非法参数
IllegalStateException
非法参数状态
NullPointerException
在null被禁止的情况下参数值为null(空指针)
IndexOutOfBoundsException
数组下标越界
ConcurrentModificationException
在禁止并发修改的情况下,对象检测到并发修改
UnsupportedOperationException
对象不支持客户请求的方法
虽然它们是Java平台库迄今为止最常被重用的异常,但是,在许可的条件下,其它的异常也可以被重用。例如,如果你要实现诸如复数或者矩阵之类的算术对象,那么重用ArithmeticException和NumberFormatException将是非常合适的。
如果一个异常满足你的需要,则不要犹豫,使用就可以,不过你一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是名字的基础上。
最后,一定要清楚,选择重用哪一种异常并没有必须遵循的规则。例如,考虑纸牌对象的情形,假设有一个用于发牌操作的方法,它的参数(handSize)是发一手牌的纸牌张数。假设调用者在这个参数中传递的值大于整副牌的剩余张数。那么这种情形既可以被解释为IllegalArgumentException(handSize的值太大),也可以被解释为IllegalStateException(相对客户的请求而言,纸牌对象的纸牌太少)。
抛出的异常要适合于相应的抽象#
如果一个方法抛出的异常与它执行的任务没有明显的关联关系,这种情形会让人不知所措。当一个方法传递一个由低层抽象抛出的异常时,往往会发生这种情况。这种情况发生时,不仅让人困惑,而且也"污染"了高层API。
为了避免这个问题,高层实现应该捕获低层的异常,同时抛出一个可以按照高层抽象进行介绍的异常。这种做法被称为"异常转译(exception translation)"。
例如,在Java的集合框架AbstractSequentialList的get()方法如下(基于JDK1.7.0_40):
1
2
3
4
5
6
7
public E get(int index) {
try {
return listIterator(index).next ();
} catch (NoSuchElementException exc) {
throw new IndexOutOfBoundsException("Index: " +index);
}
}
copy
listIterator(index)会返回ListIterator对象,
调用该对象的next()方法可能会抛出 NoSuchElementException 异常。
而在get()方法中,抛出NoSuchElementException异常会让人感到困惑。
所以,get()对NoSuchElementException进行了捕获,并抛出了IndexOutOfBoundsException异常。即,相当于将NoSuchElementException转译成了IndexOutOfBoundsException异常。
每个方法抛出的异常都要有文档#
要单独的声明被检查的异常,并且利用Javadoc的@throws标记,准确地记录下每个异常被抛出的条件。如果一个类中的许多方法处于同样的原因而抛出同一个异常,那么在该类的文档注释中对这个异常做文档,而不是为每个方法单独做文档,这是可以接受的。
在细节消息中包含失败 – 捕获消息#
简而言之,当我们自定义异常或者抛出异常时,应该包含失败相关的信息。
当一个程序由于一个未被捕获的异常而失败的时候,系统会自动打印出该异常的栈轨迹。
在栈轨迹中包含该异常的字符串表示。典型情况下它包含该异常类的类名,以及紧随其后的细节消息。
努力使失败保持原子性#
当一个对象抛出一个异常之后,我们总期望这个对象仍然保持在一种定义良好的可用状态之中。
对于被检查的异常而言,这尤为重要,因为调用者通常期望从被检查的异常中恢复过来。
一般而言,一个失败的方法调用应该保持使对象保持在"它在被调用之前的状态”。具有这种属性的方法被称为具有"失败原子性(failure atomic)"。
可以理解为,失败了还保持着原子性。对象保持"失败原子性"的方式有几种:
设计一个非可变对象。
对于在可变对象上执行操作的方法,获得"失败原子性"的最常见方法是,在执行操作之前检查参数的有效性。如下(Stack.java中的pop方法):
1
2
3
4
5
6
7
public Object pop() {
if (size==0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null ;
return result;
}
copy
与上一种方法类似,可以对计算处理过程调整顺序,使得任何可能会失败的计算部分都发生在对象状态被修改之前。
编写一段恢复代码,由它来解释操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。
在对象的一份临时拷贝上执行操作,当操作完成之后再把临时拷贝中的结果复制给原来的对象。
虽然"保持对象的失败原子性"是期望目标,但它并不总是可以做得到。例如,如果多个线程企图在没有适当的同步机制的情况下,并发的访问一个对象,那么该对象就有可能被留在不一致的状态中。
即使在可以实现"失败原子性"的场合,它也不是总被期望的。对于某些操作,它会显著的增加开销或者复杂性。
总的规则是:作为方法规范的一部分,任何一个异常都不应该改变对象调用该方法之前的状态,如果这条规则被违反,则API文档中应该清楚的指明对象将会处于什么样的状态。
不要忽略异常#
当一个API的设计者声明一个方法会抛出某个异常的时候,他们正在试图说明某些事情。所以,请不要忽略它!忽略异常的代码如下:
1
2
3
4
try {
...
} catch (SomeException e) {
}
copy
空的catch块会使异常达不到应有的目的,异常的目的是强迫你处理不正常的条件。忽略一个异常,就如同忽略一个火警信号一样,若把火警信号器关闭了,那么当真正的火灾发生时,就没有人看到火警信号了。所以,至少catch块应该包含一条说明,用来解释为什么忽略这个异常是合适的。
《Java Puzzles》中关于异常的几个问题#
优柔寡断#
下面的程序打印什么?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Indecisive {
public static void main(String[] args) {
System.out .println (decision());
}
private static boolean decision() {
try {
return true ;
} finally {
return false ;
}
}
}
copy
运行结果:
false
结果说明:
在一个 try-finally 语句中,finally 语句块总是在控制权离开 try 语句块时执行的。
无论 try 语句块是正常结束,还是意外结束。
一条语句或一个语句块在它抛出了一个异常,或者对某个封闭型语句执行了一个 break 或 continue,或是象这个程序一样在方法中执行了一个return 时,将发生意外结束。
它们之所以被称为意外结束,是因为它们阻止程序去按顺序执行下面的语句。
当 try 语句块和 finally 语句块都意外结束时,try 语句块中引发意外结束的原因将被丢弃,
而整个 try-finally 语句意外结束的原因将于 finally 语句块意外结束的原因相同。
在这个程序中,在 try 语句块中的 return 语句所引发的意外结束将被丢弃, try-finally 语句意外结束是由 finally 语句块中的 return 而造成的。
简单地讲,程序尝试着 (try) (return) 返回 true,但是它最终 (finally) 返回(return)的是 false。
对于那些在 try 语句块中执行 break、continue 或 return 语句,只是为了使其行为被 finally 语句块所否决掉的程序,要理解其行为是特别困难的。
总之,每一个 finally 语句块都应该正常结束,除非抛出的是不受检查的异常。
千万不要用一个 return、break、continue 或 throw 来退出一个 finally 语句块,并且千万不要允许将一个受检查的异常传播到一个 finally 语句块之外去。对于语言设计者,也许应该要求 finally 语句块在未出现不受检查的异常时必须正常结束。朝着这个目标,try-finally 结构将要求 finally 语句块可以正常结束。
return、break 或 continue 语句把控制权传递到 finally 语句块之外应该是被禁止的,任何可以引发将被检查异常传播到 finally 语句块之外的语句也同样应该是被禁止的。
不可思议#
下面的三个程序每一个都会打印些什么?
第一个程序
1
2
3
4
5
6
7
8
9
10
public class Arcane1 {
public static void main(String[] args) {
try {
System.out .println ("Hello world" );
} catch (IOException e) {
System.out .println ("I've never seen println fail!" );
}
}
}
copy
第二个程序
1
2
3
4
5
6
7
8
9
public class Arcane2 {
public static void main(String[] args) {
try {
// If you have nothing nice to say, say nothing
} catch (Exception e) {
System.out .println ("This can't happen" );
}
}
}
copy
第三个程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface Type1 {
void f() throws CloneNotSupportedException;
}
interface Type2 {
void f() throws InterruptedException;
}
interface Type3 extends Type1, Type2 {
}
public class Arcane3 implements Type3 {
public void f() {
System.out .println ("Hello world" );
}
public static void main(String[] args) {
Type3 t3 = new Arcane3();
t3.f ();
}
}
copy
运行结果:
结果说明:
Arcane1展示了被检查异常的一个基本原则。
它看起来应该是可以编译的:try 子句执行 I/O,并且 catch 子句捕获 IOException 异常。但是这个程序不能编译,因为 System.out.println
方法没有声明会抛出任何被检查异常,而IOException 却正是一个被检查异常。
语言规范中描述道:如果一个 catch 子句要捕获一个类型为 E 的被检查异常,而其相对应的 try 子句不能抛出E 的某种子类型的异常,那么这就是一个编译期错误。
基于同样的理由,第二个程序Arcane2。看起来应该是不可以编译的,但是它却可以。
它之所以可以编译,是因为它唯一的 catch 子句检查了 Exception。
尽管在这一点上十分含混不清,但是捕获 Exception 或 Throwble 的 catch 子句是合法的,不管与其相对应的 try 子句的内容为何。尽管 Arcane2 是一个合法的程序,但是 catch 子句的内容永远的不会被执行,这个程序什么都不会打印。
第三个程序Arcane3,看起来它也不能编译。
方法 f 在 Type1 接口中声明要抛出被检查异常 CloneNotSupportedException,并且在 Type2 接口中声明要抛出被检查异常 InterruptedException。Type3 接口继承了 Type1 和 Type2,因此,看起来在静态类型为 Type3 的对象上调用方法 f 时, 有潜在可能会抛出这些异常。一个方法必须要么捕获其方法体可以抛出的所有被检查异常,要么声明它将抛出这些异常。Arcane3 的 main 方法在静态类型为 Type3 的对象上调用了方法 f,但它对 CloneNotSupportedException 和 InterruptedExceptioin 并没有作这些处理。
那么,为什么这个程序可以编译呢?
上述分析的缺陷在于对“Type3.f 可以抛出在 Type1.f 上声明的异常和在 Type2.f 上声明的异常”所做的假设。这并不正确,因为每一个接口都限制了方法 f 可以抛出的被检查异常集合。一个方法可以抛出的被检查异常集合是它所适用的所有类型声明要抛出的被检查异常集合的交集,而不是合集。
因此,静态类型为 Type3 的对象上的 f 方法根本就不能抛出任何被检查异常。因此,Arcane3可以毫无错误地通过编译,并且打印 Hello world。
不受欢迎的宾客#
下面的程序会打印出什么呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class UnwelcomeGuest {
public static final long GUEST_USER_ID = -1;
private static final long USER_ID;
static {
try {
USER_ID = getUserIdFromEnvironment();
} catch (IdUnavailableException e) {
USER_ID = GUEST_USER_ID;
System.out .println ("Logging in as guest" );
}
}
private static long getUserIdFromEnvironment()
throws IdUnavailableException {
throw new IdUnavailableException();
}
public static void main(String[] args) {
System.out .println ("User ID: " + USER_ID);
}
}
class IdUnavailableException extends Exception {
}
copy
运行结果:
1
2
3
4
UnwelcomeGuest.java:10 : variable USER_ID might already have been assigned
USER_ID = GUEST_USER_ID;
^
1 error
copy
结果说明:
该程序看起来很直观。对 getUserIdFromEnvironment 的调用将抛出一个异常,
从而使程序将 GUEST_USER_ID(-1L)赋值给 USER_ID, 并打印 Loggin in as guest。
然后 main 方法执行,使程序打印 User ID: -1。表象再次欺骗了我们,该程序并不能编译。
如果你尝试着去编译它, 你将看到和一条错误信息。
问题出在哪里了?USER_ID 域是一个空 final(blank final),它是一个在声明中没有进行初始化操作的 final 域。
很明显,只有在对 USER_ID 赋值失败时,才会在 try 语句块中抛出异常,
因此,在 catch 语句块中赋值是相当安全的。不管怎样执行静态初始化操作语句块,
只会对 USER_ID 赋值一次,这正是空 final 所要求的。为什么编译器不知道这些呢?
要确定一个程序是否可以不止一次地对一个空 final 进行赋值是一个很困难的问题。事实上,这是不可能的。
这等价于经典的停机问题,它通常被认为是不可能解决的。为了能够编写出一个编译器,语言规范在这一点上采用了保守的方式。在程序中,一个空 final 域只有在它是明确未赋过值的地方才可以被赋值。规范长篇大论,对此术语提供了一个准确的但保守的定义。 因为它是保守的,所以编译器必须拒绝某些可以证明是安全的程序。这个谜题就展示了这样的一个程序。幸运的是, 你不必为了编写 Java 程序而去学习那些骇人的用于明确赋值的细节。通常明确赋值规则不会有任何妨碍。如果碰巧你编写了一个真的可能会对一个空final 赋值超过一次的程序,编译器会帮你指出的。只有在极少的情况下,就像本谜题一样, 你才会编写出一个安全的程序, 但是它并不满足规范的形式化要求。编译器的抱怨就好像是你编写了一个不安全的程序一样,而且你必须修改你的程序以满足它。
解决这类问题的最好方式就是将这个烦人的域从空 final 类型改变为普通的final 类型,用一个静态域的初始化操作替换掉静态的初始化语句块。实现这一点的最佳方式是重构静态语句块中的代码为一个助手方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class UnwelcomeGuest {
public static final long GUEST_USER_ID = -1;
private static final long USER_ID = getUserIdOrGuest();
private static long getUserIdOrGuest() {
try {
return getUserIdFromEnvironment();
} catch (IdUnavailableException e) {
System.out .println ("Logging in as guest" );
return GUEST_USER_ID;
}
}
private static long getUserIdFromEnvironment()
throws IdUnavailableException {
throw new IdUnavailableException();
}
public static void main(String[] args) {
System.out .println ("User ID: " + USER_ID);
}
}
class IdUnavailableException extends Exception {
}
copy
程序的这个版本很显然是正确的,而且比最初的版本根据可读性,因为它为了域值的计算而增加了一个描述性的名字, 而最初的版本只有一个匿名的静态初始化操作语句块。将这样的修改作用于程序,它就可以如我们的期望来运行了。总之,大多数程序员都不需要学习明确赋值规则的细节。该规则的作为通常都是正确的。如果你必须重构一个程序,以消除由明确赋值规则所引发的错误,那么你应该考虑添加一个新方法。这样做除了可以解决明确赋值问题,还可以使程序的可读性提高。
您好,再见!#
下面的程序将会打印出什么呢?
1
2
3
4
5
6
7
8
9
10
public class HelloGoodbye {
public static void main(String[] args) {
try {
System.out .println ("Hello world" );
System.exit (0);
} finally {
System.out .println ("Goodbye world" );
}
}
}
copy
运行结果:
Hello world
结果说明:
这个程序包含两个 println 语句: 一个在 try 语句块中, 另一个在相应的 finally语句块中。try 语句块执行它的 println 语句,并且通过调用 System.exit 来提前结束执行。
在此时,你可能希望控制权会转交给 finally 语句块。然而,如果你运行该程序,
就会发现它永远不会说再见:它只打印了 Hello world。这是否违背了"Indecisive示例” 中所解释的原则呢? 不论 try 语句块的执行是正常地还是意外地结束, finally 语句块确实都会执行。然而在这个程序中,try 语句块根本就没有结束其执行过程。System.exit 方法将停止当前线程和所有其他当场死亡的线程。
finally 子句的出现并不能给予线程继续去执行的特殊权限。
当 System.exit 被调用时,虚拟机在关闭前要执行两项清理工作。首先,它执行所有的关闭挂钩操作,这些挂钩已经注册到了 Runtime.addShutdownHook 上。这对于释放 VM 之外的资源将很有帮助。务必要为那些必须在 VM 退出之前发生的行为关闭挂钩。下面的程序版本示范了这种技术,
它可以如我们所期望地打印出 Hello world 和 Goodbye world:
1
2
3
4
5
6
7
8
9
10
11
12
public class HelloGoodbye1 {
public static void main(String[] args) {
System.out .println ("Hello world" );
Runtime.getRuntime ().addShutdownHook (
new Thread() {
public void run() {
System.out .println ("Goodbye world" );
}
});
System.exit (0);
}
}
copy
VM 执行在 System.exit 被调用时执行的第二个清理任务与终结器有关。
如果System.runFinalizerOnExit 或它的魔鬼双胞胎 Runtime.runFinalizersOnExit被调用了,
那么 VM 将在所有还未终结的对象上面调用终结器。这些方法很久以前就已经过时了,而且其原因也很合理。
无论什么原因,永远不要调用System.runFinalizersOnExit 和 Runtime.runFinalizersOnExit:
它们属于 Java类库中最危险的方法之一[ThreadStop]。调用这些方法导致的结果是,
终结器会在那些其他线程正在并发操作的对象上面运行, 从而导致不确定的行为或导致死锁。
总之,System.exit 将立即停止所有的程序线程,它并不会使 finally 语句块得到调用,但是它在停止 VM之前会执行关闭挂钩操作。当 VM 被关闭时,请使用关闭挂钩来终止外部资源。通过调用 System.halt 可以在不执行关闭挂钩的情况下停止 VM,但是这个方法很少使用。
不情愿的构造器#
下面的程序将打印出什么呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Reluctant {
private Reluctant internalInstance = new Reluctant();
public Reluctant() throws Exception {
throw new Exception("I'm not coming out" );
}
public static void main(String[] args) {
try {
Reluctant b = new Reluctant();
System.out .println ("Surprise!" );
} catch (Exception ex) {
System.out .println ("I told you so" );
}
}
}
copy
运行结果:
1
2
3
Exception in thread "main" java.lang.StackOverflowError
at Reluctant.<init>(Reluctant.java:3)
...
copy
结果说明:
main 方法调用了 Reluctant 构造器,它将抛出一个异常。你可能期望 catch 子句能够捕获这个异常,并且打印 I told you so。凑近仔细看看这个程序就会发现,Reluctant 实例还包含第二个内部实例,它的构造器也会抛出一个异常。无论抛出哪一个异常,看起来 main 中的 catch 子句都应该捕获它,因此预测该程序将打印 I told you 应该是一个安全的赌注。但是当你尝试着去运行它时,就会发现它压根没有去做这类的事情:它抛出了 StackOverflowError 异常,为什么呢?
与大多数抛出 StackOverflowError 异常的程序一样,本程序也包含了一个无限递归。
当你调用一个构造器时,实例变量的初始化操作将先于构造器的程序体而运行[JLS 12.5]。在本谜题中, internalInstance 变量的初始化操作递归调用了构造器,而该构造器通过再次调用 Reluctant 构造器而初始化该变量自己的 internalInstance 域,如此无限递归下去。这些递归调用在构造器程序体获得执行机会之前就会抛出 StackOverflowError 异常,因为 StackOverflowError 是 Error 的子类型而不是 Exception 的子类型,所以 catch 子句无法捕获它。对于一个对象包含与它自己类型相同的实例的情况,并不少见。例如,链接列表节点、树节点和图节点都属于这种情况。你必须非常小心地初始化这样的包含实例,以避免 StackOverflowError 异常。
至于本谜题名义上的题目:声明将抛出异常的构造器,你需要注意,构造器必须声明其实例初始化操作会抛出的所有被检查异常。
域和流#
下面的方法将一个文件拷贝到另一个文件,并且被设计为要关闭它所创建的每一个流,即使它碰到 I/O 错误也要如此。遗憾的是,它并非总是能够做到这一点。为什么不能呢,你如何才能订正它呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void copy(String src, String dest) throws IOException {
InputStream in = null ;
OutputStream out = null ;
try {
in = new FileInputStream(src);
out = new FileOutputStream(dest);
byte [] buf = new byte [1024];
int n;
while ((n = in.read (buf)) > 0)
out.write (buf, 0, n);
} finally {
if (in != null ) in.close ();
if (out != null ) out.close ();
}
}
copy
谜题分析:
这个程序看起来已经面面俱到了。其流域(in 和 out)被初始化为 null,并且新的流一旦被创建,它们马上就被设置为这些流域的新值。对于这些域所引用的流,如果不为空,则 finally 语句块会将其关闭。即便在拷贝操作引发了一个 IOException 的情况下,finally 语句块也会在方法返回之前执行。出什么错了呢?
问题在 finally 语句块自身中。close 方法也可能会抛出 IOException 异常。如果这正好发生在 in.close 被调用之时,那么这个异常就会阻止 out.close 被调用,从而使输出流仍保持在开放状态。请注意,该程序违反了"优柔寡断" 的建议:对 close 的调用可能会导致 finally 语句块意外结束。遗憾的是,编译器并不能帮助你发现此问题,因为 close 方法抛出的异常与 read 和 write 抛出的异常类型相同,而其外围方法(copy)声明将传播该异常。解决方式是将每一个 close 都包装在一个嵌套的 try 语句块中。
下面的 finally 语句块的版本可以保证在两个流上都会调用 close:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
try {
// 和之前一样
} finally {
if (in != null ) {
try {
in.close ();
} catch (IOException ex) {
// There is nothing we can do if close fails
}
}
if (out != null ) {
try {
out.close ();
} catch (IOException ex) {
// There is nothing we can do if close fails
}
}
}
copy
总之,当你在 finally 语句块中调用 close 方法时,要用一个嵌套的 try-catch 语句来保护它,
以防止 IOException 的传播。更一般地讲,对于任何在 finally 语句块中可能会抛出的被检查异常都要进行处理,而不是任其传播。
异常为循环而抛#
下面的程序会打印出什么呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Loop {
public static void main(String[] args) {
int [][] tests = { { 6, 5, 4, 3, 2, 1 }, { 1, 2 },
{ 1, 2, 3 }, { 1, 2, 3, 4 }, { 1 } };
int successCount = 0;
try {
int i = 0;
while (true ) {
if (thirdElementIsThree(tests[i++]))
successCount ++;
}
} catch (ArrayIndexOutOfBoundsException e) {
// No more tests to process
}
System.out .println (successCount);
}
private static boolean thirdElementIsThree(int [] a) {
return a.length >= 3 & a[2] == 3;
}
}
copy
运行结果:
0
结果说明:
该程序主要说明了两个问题。
不应该使用异常作为终止循环的手段!
该程序用 thirdElementIsThree 方法测试了 tests 数组中的每一个元素。遍历这个数组的循环显然是非传统的循环:它不是在循环变量等于数组长度的时候终止,而是在它试图访问一个并不在数组中的元素时终止。尽管它是非传统的,但是这个循环应该可以工作。
如果传递给 thirdElementIsThree 的参数具有 3 个或更多的元素,并且其第三个元素等于 3,那么该方法将返回 true。对于 tests中的 5 个元素来说,有 2 个将返回 true,因此看起来该程序应该打印 2。如果你运行它,就会发现它打印的时 0。肯定是哪里出了问题,你能确定吗? 事实上,这个程序犯了两个错误。第一个错误是该程序使用了一种可怕的循环惯用法,该惯用法依赖的是对数组的访问会抛出异常。
这种惯用法不仅难以阅读, 而且运行速度还非常地慢。
不要使用异常来进行循环控制;应该只为异常条件而使用异常。为了纠正这个错误,可以将整个 try-finally 语句块替换为循环遍历数组的标准惯用法:
1
2
3
for (int i = 0; i < test.length; i++)
if (thirdElementIsThree(tests[i]))
successCount++;
copy
如果你使用的是 5.0 或者是更新的版本,那么你可以用 for 循环结构来代替:
1
2
3
for (int [] test : tests)
if (thirdElementIsThree(test))
successCount++;
copy
主要比较"&操作符" 和 “&&操作符"的区别。注意示例中的操作符是&,这是按位进行"与"操作。