Java的异常
一个健壮的程序必须处理各种各样的错误。所谓错误就是程序调用某个函数的时候失败了。
调用方获取调用失败信息:约定返回错误码;在语言层面上提供一个异常处理机制。
Java内置了一套异常处理机制,总是使用异常来表示错误。异常是一种class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获。
Throwable
是异常体系的根,它继承自Object
。Throwable
有两个体系:Error
和Exception
,Error
表示严重的错误,程序对此一般无能为力。
OutOfMemoryError
:内存耗尽NoClassDefFoundError
:无法加载某个ClassStackOverflowError
:栈溢出
Exception是运行时的错误,可以被捕获并处理。某些异常是应用程序 逻辑处理的一部分,应该捕获并处理:
NumberFormatException
:数值类型的格式错误FileNotFoundException
:未找到文件SocketException
:读取网络失败
还有一些异常是程序逻辑编写不对造成的,应该修复程序本身:
NullPointerException
:对某个null
的对象调用方法或字段IndexOutOfBoundsException
:数组索引越界
Exception又分为两大类:
RuntimeException以及它的子类;
非RuntimeException(包括IOException、ReflectiveOperation等等)
Java规定:
-
必须捕获的异常,包括
Exception
及其子类,但不包括RuntimeException
及其子类,这种类型的异常称为Checked Exception。 -
不需要捕获的异常,包括
Error
及其子类,RuntimeException
及其子类
捕获异常
捕获异常使用try...catch语句,把可能发生异常的代码放到try{...}中,然后使用catch捕获对应的Exception及其子类。
在方法定义的时候,使用throws Xxx
表示该方法可能抛出的异常类型。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错。
所有异常都可以调用printStackTrace()方法打印异常栈,这是一个简单有用的快速打印异常的方法。
捕获异常
在Java中,凡是可能抛出异常的语句,都可以用try ... catch
捕获。把可能发生异常的语句放在try { ... }
中,然后使用catch
捕获对应的Exception
及其子类。
多catch语句
可以使用多个catch语句,每个catch分别捕获对应的Exception及其子类。JVM在捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,然后不再继续匹配。
存在多个catch的时候,catch的顺序非常重要:子类必须写在前面。
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");
}
}
对于上面的代码,UnsupportedEncodingException异常是永远捕获不到的,因为它是IOException的子类。
finally语句
Java的try ... catch
机制还提供了finally
语句,保证有无错误都会执行。finally的特点:不是必须的,可写可不写;总是最后执行。
某些情况下,可以没有catch,只使用try...finally结构。
void process(String file) throws IOException {
try {
...
} finally {
System.out.println("END");
}
}
因为方法声明了可能抛出的异常,所以可以不写catch。
捕获多种异常
如果某些异常的处理逻辑相同,但是异常本身不存在继承关系,那么就得编写多条catch子句。如果两个异常的处理代码是相同的,可以把它用|合并到一起。
抛出异常
异常的传播
当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个try...catch被捕获为止。
通过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)
上述信息表示:NumberFormatException
是在java.lang.Integer.parseInt
方法中被抛出的,从下往上看,调用层次依次是:
main()
调用process1()
;process1()
调用process2()
;process2()
调用Integer.parseInt(String)
;Integer.parseInt(String)
调用Integer.parseInt(String, int)
抛出异常
抛出异常分为两步:创建某个Exception的实例;用throw语句抛出。
void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}
如果一个方法捕获了某个异常后,又在catch子句中抛出新的异常,就相当于把抛出的异常类型“转换”了。捕获到异常并再次抛出时,一定要留住原始异常,否则很难定位第一案发现场。
在catch中抛出异常,不会影响finally的执行。JVM会先执行finally,然后抛出异常。
异常屏蔽
finally抛出异常后,原来catch中准备抛出的异常就“消失”了,因为只能抛出一个异常。没有被抛出的异常称为“被屏蔽”的异常。
自定义异常
Java标准库定义的常用异常包括:
当我们在代码中需要抛出异常时,尽量使用JDK已定义的异常类型。
在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。一个常见的做法是自定义一个BaseException作为“根异常”,然后,派生出各种业务类型的异常。BaseExcrption需要从一个适合的Exception派生,通常建议从RuntimeException派生。自定义的BaseException应该提供多个构造方法。
NullPointerException
NullPointerException即空指针异常,如果一个对象为null,调用其方法或访问其字段就会产生空指针异常,这个异常通常是由JVM抛出的。
处理NullPointerException
NullPointerException是一种代码逻辑错误。好的代码习惯可以极大地降低NullPointerException的产生:成员变量在定义式初始化;返回空字符串、空数组而不是null。如果调用方一定要根据null判断,那么考虑返回Optional<T>:
public Optional<String> readFromFile(String file) {
if (!fileExist(file)) {
return Optional.empty();
}
...
}
这样调用方必须通过Optioanl.isPresent()判断是否有结果。
定位NullPointerException
调用a.b.c.x()
时产生了NullPointerException
,原因可能是:
a
是null
;a.b
是null
;a.b.c
是null
;
从Java 14开始,如果产生了空指针异常,JVM可以给出详细的信息告诉我们null对象到底是谁。
这种增强的NullPointerException
详细信息是Java 14新增的功能,但默认是关闭的,我们可以给JVM添加一个-XX:+ShowCodeDetailsInExceptionMessages
参数启用它。
使用断言
断言是一种调试程序的方式,在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。
Java断言的特点是:断言失败时会抛出AssertionError,导致程序结束退出。因此,断言不能用于可恢复的程序错误,只应用于开发和测试阶段。对于可恢复的程序错误,不应该使用断言。
JVM默认是关闭断言指令的,遇到assert语句就自动忽略了,不执行。要执行assert语句,必须给Java虚拟机传递-enableassertions参数启用断言。还可以有选择地对特定地类启用断言,命令行参数是:-ea:com.itranswarp.sample.Main
,表示只对com.itranswarp.sample.Main
这个类启用断言。或者对特定的包启用断言,命令行参数是:-ea:com.itranswarp.sample...
(注意结尾有3个.
),表示对com.itranswarp.sample
这个包启动断言。
使用JDK Logging
输出日志的好处:
可以设置输出样式,避免自己每次都写“ERROR:”+var;
可以设置输出级别,进制某些级别输出;
可以被重定向到文件,这样可以在程序运行结束后查看日志;
可以按包名控制日志级别,只输出某些包打的日志;
...
Java标准库内置了日志包java.util.logging,我们可以直接用。
JDK的Logging定义了7个日志级别,从严重到普通:
- SEVERE
- WARNING
- INFO
- CONFIG
- FINE
- FINER
- FINEST
因为默认级别是INFO,因此,INFO级别以下的日志,不会被打印出来。
使用Java标准库内置Logging有以下局限:
Loggering系统在JVM启动时读取配置文件并完成初始化,一旦开始运行main()方法,就无法修改配置;配置不太方便,需要在JVM启动时传递参数-Djava.util.logging.config.file=<config-file-name>。因此,Java标准库内置的Logging使用并不是非常广泛。
使用Commons Logging
Commons Logging是一个第三方日志库,是由Apache创建的日志模块。它的特色是可以挂接不同的日志系统,并通过配置文件指定挂接的日志系统。默认情况下,Commons Logging自动搜索并使用Log4j(Log4j是另一个流行的日志系统),如果没有找到Log4j,再使用JDK Logging。
使用Commons Logging只需要和两个类打交道,并且只有两步:第一步,通过LogFactory获取Log类的实例;第二步,使用Log实例的方法打日志。
Commons Logging是一个第三方提供的库,必须先把它下载下来,下载后解压,找到commons-logging-1.2.jar这个文件,再把Java源码Main.java放到一个目录下。编译的时候要指定classpath,不然编译器找不到我们引用的包。
使用Commons Logging时,如果在静态方法中引用Log,通常直接定义一个静态类型变量;在实例方法中引用Log,通常定义一个实例变量。
使用Log4j
Commons Logging可以作为“日志接口”来使用,真正的“日志实现”可以使用Log4j。
Log4j是一种非常流行的日志框架,最新版本是2.x。
Log4j是一个组件化设计的日志系统,架构大致如下:
当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地。在输出日志的过程中,通过Filter来过滤哪些log需要被输出,哪些log不需要被输出。
在开发阶段,始终使用Commons Logging接口来写入日志,并且开发阶段无需引入Log4j。如果需要把日志写入文件,只需要把正确的配置文件和Log4j相关的jar包放入classpath,就可以自动把日志切换成Log4j写入,无需修改任何代码。
使用SLF4J和Logback
SLF4J类似于Commons Logging,是一个日志接口,而Logback类似于Log4j,是一个日志的实现。
SLF4J的日志接口传入的是一个带占位符的字符串,用后面的变量自动替换占位符,看起来更加自然。
对比一下Commons Logging和SLF4J的接口:
不同之处就是Log变成了Logger,LogFactory变成了LoggerFactory。