自定义异常
Java标准库定义的常用异常包括:
Exception
│
├─ RuntimeException
│ │
│ ├─ NullPointerException
│ │
│ ├─ IndexOutOfBoundsException
│ │
│ ├─ SecurityException
│ │
│ └─ IllegalArgumentException
│ │
│ └─ NumberFormatException
│
├─ IOException
│ │
│ ├─ UnsupportedCharsetException
│ │
│ ├─ FileNotFoundException
│ │
│ └─ SocketException
│
├─ ParseException
│
├─ GeneralSecurityException
│
├─ SQLException
│
└─ TimeoutException
当我们在代码中需要抛出异常时,尽量使用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可以根据父类快速生成子类的构造方法。
小结
抛出异常时,尽量复用JDK已定义的异常类型;
自定义异常体系时,推荐从RuntimeException派生“根异常”,再派生出业务异常;
自定义异常时,应该提供多种构造方法。
NullPointerException
在所有的RuntimeException异常中,Java程序员最熟悉的恐怕就是NullPointerException了。
NullPointerException即空指针异常,俗称NPE。如果一个对象为null,调用其方法或访问其字段就会产生NullPointerException,这个异常通常是由JVM抛出的,例如:
// NullPointerExceptionpublic 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是一种代码逻辑错误,遇到NullPointerException,遵循原则是早暴露,早修复,严禁使用catch来隐藏这种编码错误:
// 错误示例: 捕获NullPointerExceptiontry {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<T>:
public Optional<String> readFromFile(String file) {if (!fileExist(file)) {return Optional.empty();}...}
这样调用方必须通过Optional.isPresent()判断是否有结果。
定位NullPointerException
如果产生了NullPointerException,例如,调用a.b.c.x()时产生了NullPointerException,原因可能是:
- a是null;
- a.b是null;
- a.b.c是null;
确定到底是哪个对象是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 "<local1>.address.city" is null,意思是city字段为null,这样我们就能快速定位问题所在。
这种增强的NullPointerException详细信息是Java 14新增的功能,但默认是关闭的,我们可以给JVM添加一个-XX:+ShowCodeDetailsInExceptionMessages参数启用它:
java -XX:+ShowCodeDetailsInExceptionMessages Main.java
小结
NullPointerException是Java代码常见的逻辑错误,应当早暴露,早修复;
可以启用Java 14的增强异常信息来查看NullPointerException的详细错误信息。
使用断言
断言(Assertion)是一种调试程序的方式。在Java中,使用assert关键字来实现断言。
我们先看一个例子:
public static void main(String[] args) {double x = Math.abs(-123.45);assert x >= 0;System.out.println(x);}
语句assert x >= 0;即为断言,断言条件x >= 0预期为true。如果计算结果为false,则断言失败,抛出AssertionError。
使用assert语句时,还可以添加一个可选的断言消息:
assert x >= 0 : "x must >= 0";
这样,断言失败的时候,AssertionError会带上消息x must >= 0,更加便于调试。
Java断言的特点是:断言失败时会抛出AssertionError,导致程序结束退出。因此,断言不能用于可恢复的程序错误,只应该用于开发和测试阶段。
对于可恢复的程序错误,不应该使用断言。例如:
void sort(int[] arr) {assert arr != null;}
应该抛出异常并在上层捕获:
void sort(int[] arr) {if (arr == null) {throw new IllegalArgumentException("array cannot be null");}}
当我们在程序中使用assert时,例如,一个简单的断言:
// assertpublic class Main {public static void main(String[] args) {int x = -1;assert x > 0;System.out.println(x);}}
断言x必须大于0,实际上x为-1,断言肯定失败。执行上述代码,发现程序并未抛出AssertionError,而是正常打印了x的值。
这是怎么肥四?为什么assert语句不起作用?
这是因为JVM默认关闭断言指令,即遇到assert语句就自动忽略了,不执行。
要执行assert语句,必须给Java虚拟机传递-enableassertions(可简写为-ea)参数启用断言。所以,上述程序必须在命令行下运行才有效果:
$ java -ea Main.javaException in thread "main" java.lang.AssertionErrorat Main.main(Main.java:5)
还可以有选择地对特定地类启用断言,命令行参数是:-ea:com.itranswarp.sample.Main,表示只对com.itranswarp.sample.Main这个类启用断言。
或者对特定地包启用断言,命令行参数是:-ea:com.itranswarp.sample...(注意结尾有3个.),表示对com.itranswarp.sample这个包启动断言。
实际开发中,很少使用断言。更好的方法是编写单元测试,后续我们会讲解JUnit的使用。
小结
断言是一种调试方式,断言失败会抛出AssertionError,只能在开发和测试阶段启用断言;
对可恢复的错误不能使用断言,而应该抛出异常;
断言很少被使用,更好的方法是编写单元测试。
使用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,我们可以直接用。先看一个简单的例子:
// loggingimport java.util.logging.Level;import java.util.logging.Logger;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...");}}
运行上述代码,得到类似如下的输出:
Mar 02, 2019 6:32:13 PM Hello mainINFO: start process...Mar 02, 2019 6:32:13 PM Hello mainWARNING: memory is running out...Mar 02, 2019 6:32:13 PM Hello mainSEVERE: process will be terminated...
对比可见,使用日志最大的好处是,它自动打印了时间、调用类、调用方法等很多有用的信息。
再仔细观察发现,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使用并不是非常广泛。更方便的日志系统我们稍后介绍。
练习
使用logger.severe()打印异常:
import java.io.UnsupportedEncodingException;import java.util.logging.Logger;public class Main {public static void main(String[] args) {Logger logger = Logger.getLogger(Main.class.getName());logger.info("Start process...");try {"".getBytes("invalidCharsetName");} catch (UnsupportedEncodingException e) {// TODO: 使用logger.severe()打印异常}logger.info("Process end.");}}
小结
日志是为了替代System.out.println(),可以定义格式,重定向到文件等;
日志可以存档,便于追踪问题;
日志记录可以按级别分类,便于打开或关闭某些级别;
可以根据配置文件调整日志,无需修改代码;
Java标准库提供了java.util.logging来实现日志功能。