当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个try ... catch
被捕获为止:
// exception
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
}
}
通过printStackTrace()
可以打印出方法的调用栈,类似:
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)
printStackTrace()
对于调试错误非常有用,上述信息表示:NumberFormatException
是在java.lang.Integer.parseInt
方法中被抛出的,从下往上看,调用层次依次是:
查看Integer.java
源码可知,抛出异常的方法代码如下:
public static int parseInt(String s, int radix) throws NumberFormatException {
if (s == null) {
throw new NumberFormatException("null");
}
...
}
并且,每层调用均给出了源代码的行号,可直接定位。
当发生错误时,例如,用户输入了非法的字符,我们就可以抛出异常。
如何抛出异常?参考Integer.parseInt()
方法,抛出异常分两步:
Exception
的实例;throw
语句抛出。下面是一个例子:
void process2(String s) {
if (s==null) {
NullPointerException e = new NullPointerException();
throw e;
}
}
实际上,绝大部分抛出异常的代码都会合并写成一行:
void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}
如果一个方法捕获了某个异常后,又在catch
子句中抛出新的异常,就相当于把抛出的异常类型“转换”了:
void process1(String s) {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}
void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}
当process2()
抛出NullPointerException
后,被process1()
捕获,然后抛出IllegalArgumentException()
。
如果在main()
中捕获IllegalArgumentException
,我们看看打印的异常栈:
// exception
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();
}
}
打印出的异常栈类似:
java.lang.IllegalArgumentException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)
这说明新的异常丢失了原始异常信息,我们已经看不到原始异常NullPointerException
的信息了。
为了能追踪到完整的异常栈,在构造异常的时候,把原始的Exception实例
传进去,新的Exception
就可以持有原始Exception
信息。对上述代码改进如下:
// exception
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();
}
}
运行上述代码,打印出的异常栈类似:
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)
注意到Caused by: Xxx
,说明捕获的IllegalArgumentException
并不是造成问题的根源,根源在于NullPointerException
,是在Main.process2()
方法抛出的。
在代码中获取原始异常可以使用Throwable.getCause()
方法。如果返回null
,说明已经是“根异常”了。
有了完整的异常栈的信息,我们才能快速定位并修复代码的问题。
捕获到异常并再次抛出时,一定要留住原始异常,否则很难定位第一案发现场!
如果我们在try
或者catch
语句块中抛出异常,finally
语句是否会执行?例如:
// exception
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");
}
}
}
上述代码执行结果如下:
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 ...
第一行打印了catched
,说明进入了catch
语句块。第二行打印了finally
,说明执行了finally
语句块。
因此,在catch
中抛出异常,不会影响finally
的执行。JVM
会先执行finally
,然后抛出异常。
如果在执行finally
语句时抛出异常,那么,catch
语句的异常还能否继续抛出?例如:
// exception
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();
}
}
}
执行上述代码,发现异常信息如下:
catched
finally
Exception in thread "main" java.lang.IllegalArgumentException
at Main.main(Main.java:11)
这说明finally
抛出异常后,原来在catch
中准备抛出的异常就“消失”了,因为只能抛出一个异常。没有被抛出的异常称为“被屏蔽”的异常(Suppressed Exception
)。
在极少数的情况下,我们需要获知所有的异常。如何保存所有的异常信息?
方法是先用origin变量
保存原始异常,然后调用Throwable.addSuppressed()
,把原始异常添加进来,最后在finally
抛出:
// exception
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;
}
}
}
当catch
和finally
都抛出了异常时,虽然catch
的异常被屏蔽了,但是,finally
抛出的异常仍然包含了它:
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)
通过Throwable.getSuppressed()
可以获取所有的Suppressed Exception
。
绝大多数情况下,在finally
中不要抛出异常。因此,我们通常不需要关心Suppressed Exception
。
异常打印的详细的栈信息是找出问题的关键,最关键的Caused by: xxx
不能省略。
Java标准库定义的常用异常包括:
当我们在代码中需要抛出异常时,尽量使用JDK已定义的异常类型。例如,参数检查不合法,应该抛出IllegalArgumentException
:
static void process1(int age) {
if (age <= 0) {
throw new IllegalArgumentException();
}
}
在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。
一个常见的做法是自定义一个BaseException
作为“根异常”,然后,派生出各种业务类型的异常。
BaseException
需要从一个适合的Exception
派生,通常建议从RuntimeException
派生:
public class BaseException extends RuntimeException {
}
其他业务类型的异常就可以从BaseException
派生:
public class UserNotFoundException extends BaseException {
}
public class LoginFailedException extends BaseException {
}
...
自定义的BaseException
应该提供多个构造方法:
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);
}
}
上述构造方法实际上都是原样照抄RuntimeException
。这样,抛出异常的时候,就可以选择合适的构造方法。通过IDE可以根据父类快速生成子类的构造方法。
在所有的RuntimeException
异常中,Java程序员最熟悉的恐怕就是NullPointerException
了。
NullPointerException
即空指针异常,俗称NPE。如果一个对象为null
,调用其方法或访问其字段就会产生NullPointerException
,这个异常通常是由JVM抛出的,例如:
// NullPointerException
public class Main {
public static void main(String[] args) {
String s = null;
System.out.println(s.toLowerCase());
}
}
指针这个概念实际上源自C语言,Java语言中并无指针。我们定义的变量实际上是引用,Null Pointer
更确切地说是Null Reference
,不过两者区别不大。
如果遇到NullPointerException
,我们应该如何处理?首先,必须明确,NullPointerException
是一种代码逻辑错误,遇到NullPointerException
,遵循原则是早暴露,早修复,严禁使用catch
来隐藏这种编码错误:
// 错误示例: 捕获NullPointerException
try {
transferMoney(from, to, amount);
} catch (NullPointerException e) {
}
好的编码习惯可以极大地降低NullPointerException
的产生,例如:
成员变量在定义时初始化:
public class Person {
private String name = "";
}
使用空字符串""而不是默认的null
可避免很多NullPointerException
,编写业务逻辑时,用空字符串""
表示未填写比null
安全得多。
返回空字符串""
、空数组而不是null
:
public String[] readLinesFromFile(String file) {
if (getFileSize(file) == 0) {
// 返回空数组而不是null:
return new String[0];
}
...
}
这样可以使得调用方无需检查结果是否为null
。
如果调用方一定要根据null
判断,比如返回null
表示文件不存在,那么考虑返回Optional
:
public Optional<String> readFromFile(String file) {
if (!fileExist(file)) {
return Optional.empty();
}
...
}
这样调用方必须通过Optional.isPresent()
判断是否有结果。
如果产生了NullPointerException
,例如,调用a.b.c.x()
时产生了NullPointerException
,原因可能是:
确定到底是哪个对象是null
以前只能打印这样的日志:
System.out.println(a);
System.out.println(a.b);
System.out.println(a.b.c);
从Java 14开始,如果产生了NullPointerException
,JVM可以给出详细的信息告诉我们null
对象到底是谁。我们来看例子:
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;
}
可以在NullPointerException
的详细信息中看到类似... because "
,意思是city
字段为null
,这样我们就能快速定位问题所在。
这种增强的NullPointerException
详细信息是Java 14新增的功能,但默认是关闭的,我们可以给JVM添加一个-XX:+ShowCodeDetailsInExceptionMessages
参数启用它:
java -XX:+ShowCodeDetailsInExceptionMessages Main.java