Java的异常

异常概念

异常的概念,就是程序由于一些其他原因导致了它并没有按照我们想要的状态去运行。程序出现异常是很常见的一种情况,我们需要对可能出现的异常进行提前处理,保证程序即使遇到了异常,也能顺利地运行下去。

对于访问文件不存在,用户输入错误,这些都属于异常,是程序可以自我拯救的,并不是非常严重的不同寻常。我们可以通过合理的异常处理机制来将这些不和谐的东西压制下来,让程序仍然快乐的运行。

对于堆栈溢出,内存耗尽,无法加载类,这些属于错误,是程序自己也无能为力的,是非常严重的不同寻常。对于这些,我们只能让程序自我结束生命,毕竟它也很绝望啊。

Java 的编译器对于 Exception 中的 非 RuntimeException 是要求必须捕获它们的,而 RuntimeException 和 Error 则不做强制的要求。当然不做强制要求并不代表着就不需要对这些进行捕获处理,而是要根据实际的情况进行判断。

异常的实现方法

上面的异常说的是程序设计中的一个概念,那么再 Java 里面,真正的异常是什么呢?想到 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
                     ┌───────────┐
│ Object │
└───────────┘


┌───────────┐
│ Throwable │
└───────────┘

┌─────────┴─────────┐
│ │
┌───────────┐ ┌───────────┐
│ Error │ │ Exception │
└───────────┘ └───────────┘
▲ ▲
┌───────┘ ┌────┴──────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐┌───────────┐
│OutOfMemoryError │... │RuntimeException ││IOException│...
└─────────────────┘ └─────────────────┘└───────────┘

┌───────────┴─────────────┐
│ │
┌─────────────────────┐ ┌─────────────────────────┐
│NullPointerException │ │IllegalArgumentException │...
└─────────────────────┘ └─────────────────────────┘

遇到的异常的时候,程序会将遇到的各种关于异常的信息,在实例化的时候保存到对应类的实例里面,最后向上抛出。这就是 Java 中,异常真正的形态,一个类。

捕获异常

try-catch 语句

Java 中使用 try-catch 语句来捕获可能出现的异常,try 代码块里面写可能会抛出异常的语句,而 catch 括号里面写可能抛出的异常类型,可以在 try 语句后面叠加多个 catch 来应对可能抛出不同类型的异常。然后 catch 的代码块里面写当括号里的异常类型匹配到了之后,应该执行的语句:

下面的代码表示,当 try 代码块里的语句没有向上抛出异常的时候,就打印 Seccess! ,如果向上抛出了 NoSuchAlgorithmException 异常,那么就会被 catch 捕获,然后异常信息保存在 Info 里面。

1
2
3
4
5
6
7
8
SecureRandom SR=null;
try {
SR=SecureRandom.getInstanceStrong();
System.out.println("Seccess!");
} catch (NoSuchAlgorithmException Inof) {
SR=new SecureRandom();
System.out.println("Failed!");
}

如果我们删掉 try-catch 语句,那么编译的时候就会报错,说明这是要必须捕获的可能异常。

1
2
unreported exception NoSuchAlgorithmException; must be caught or declared to be thrown 
SecureRandom SR=SecureRandom.getInstanceStrong();

从方法 getInstanceStrong() 的方法签名也可以看出来,它可能会抛出的异常类型:

1
public static SecureRandom getInstanceStrong() throws NoSuchAlgorithmException 

异常传递

异常捕获不一定要紧挨着可能抛出异常语句的上一级进行捕获,可以在更高的调用层进行捕获,但是前提是每一级都要讲异常进行向上传递,也就是加上 throws XXXException ,将捕获异常这个烫手山芋也抛出来,从而交给其他调用它的方法进行捕获。

比如下面的代码中,最底层抛出异常的 SecureRandom.getInstanceStrong() 方法将异常抛给了调用它的 One() ,然后 One() 向上抛给了调用自己的 Two() ,Two() 再向上抛给了调用自己的 main() ,最终会被 main() 里面的 catch 语句所捕获,结束异常的层层传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

public class test {
public static void main(String[] args) {
try {
SecureRandom SR=Two();
System.out.println("Success!");
} catch (NoSuchAlgorithmException Info) {
System.out.println("Failed!");
}
}

private static SecureRandom One() throws NoSuchAlgorithmException {
SecureRandom SR=SecureRandom.getInstanceStrong();
return SR;
}
private static SecureRandom Two() throws NoSuchAlgorithmException {
return One();
}
}

多异常捕获

当然 Java 是支持多个 catch 语句进行捕获的,因为有很多语句的话,可能会抛出千奇百怪的异常,所以可以在 try 语句后面叠加多个 catch 语句,针对不同的异常进行捕获处理。

需要注意的是写 catch 的顺序,因为一个异常被抛出来之后,catch 语句是从上到下进行依次判断是否满足捕获的条件的,一旦异常被成功满足条件的 catch 捕获,那么就会结束运行。

所以就需要注意 catch 语句的顺序,由于上面的异常存在继承,那么就不能将父类的 catch 异常放在子类的前面,因为这样即使异常为子类的类型,但是会提前被父类的 catch 捕获,原理和多态相同,所以一定要注意 catch 语句的顺序。

finally 语句

对于无论有没有异常的情况发生,都想要执行的语句(比如一些清理工作),那么就可以使用 finally 语句,finally 语句只能写在 try-catch 语句的最后面,里面的内容无论发生什么,最后都会被执行。

1
2
3
4
5
6
7
try {
...
} catch(XXXException Info) {
...
} finally {
System.out.println("Accomplish!");
}

这样无论如何运行这个代码,最终一定会输出字符串 Accomplish! ,也就是 finally 里面的语句被执行。

or 异常并列

对于那些非同一继承关系的异常,却拥有相同的处理语句的情况,就需要异常并列了,比如下面的代码:

1
2
3
4
5
6
7
try {
Expresion1;
} catch(XXException Info) {
Expresion2;
} catch(XXXXException Info) {
Expresion2;
}

发现无论是 XXException 还是 XXXXException 异常对应的处理语句都是 Expresion2,那么这样重复写两个 catch 虽说还能接受,但是如果是数十种异常对应处理语句相同的话,那么如果能将他们合并,就会让代码精简很多,因此就要用到异常并列,也就是 | 符号。

1
2
3
4
5
try {
Expresion1;
} catch(XXException | XXXXException Info) {
Expresion2;
}

好耶,成功精简代码!

抛出异常

查看异常信息

对于一个被截获的异常信息,我们要好好查看一下到底是哪里掉链子了,就可以使用 printStackTrace() 方法,这个方法是 Throwable 类里面的方法,所有的可抛出异常都继承于这个类,可以打印错误的传递栈。

1
2
3
4
5
6
7
8
9
10
java.lang.IllegalArgumentException
at test.Num(test.java:11)
at test.main(test.java:6)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:64)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:564)
at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:415)
at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:192)
at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:132)

上面就打印出了异常抛出的调用栈,除了前两条是我自己搞得调用能看懂,其他的暂时还不太清楚。

异常抛出

异常抛出,其实很简单,只需要创建一个异常的实例,然后使用 throw 语句将它抛出来即可。

1
2
3
4
NullPointerException e=new NullPointerException();
throw e;
//或者写成下面的样子
throw new NullPointerException();

如果我们在 catch 语句里面捕获一个异常之后,再次抛出一个新的异常(等价于中途进行异常的更换),那么查看新的异常的调用栈的时候,只能查到新建新异常的实例的地方,然而这里并不是真正的问题发源地,只是中途的异常更换。比如,下面代码:

1
2
3
4
5
try {
...
} catch(NullPointerException e) {
throw new IllegalArgumentException();
}

为了能更换异常的同时保留完整的调用栈,来确保能追查到问题发源地,只需要将捕获的异常作为参数,创建新异常实例的时候,传递给它的构造方法即可。

1
2
3
4
5
try {
...
} catch(NullPointerException e) {
throw new IllegalArgumentException(e); //只有这里不同
}

抛出异常的顺序

我们需要弄清楚在抛出异常的时候,整个程序的执行机制,那就是对于当前的方法,一旦有一处代码抛出了异常,后面的程序就会停止执行,最后将抛出的异常交给调用这个方法的地方,等待被捕获。也就是说,整个方法即使有多处地方会抛出异常,但是第一次抛出异常的时候,整个方法就停止执行了,只会专注处理抛出的异常。

1
2
throw new Exception();
System.out.println("Reachable!");

比如上面的代码在抛出异常语句的后面加上其他的语句之后,就会发现编译会出错,出现 test.java:20: error: unreachable statement ,意为无法达到的语句,说明抛出异常之后,后面的语句就不会执行了。那么就可以说,一个方法在一次调用中只会抛出一个异常。

由于 try 语句中抛出的异常必须被 catch 捕获,所以我们暂且不提它,但语句 finally 是接在 try-catch 后面的,无论如何它都会被执行。当 catch 和 finally 的语句块里面都抛出异常的时候,这个方法抛给调用方法的异常到底是谁呢,是 catch 还是 finally?

Java 遇到这种情况执行语句的顺序为:先执行 try 中的语句,抛出了异常之后被 catch 语句捕获,然后执行 catch 语句中的代码,catch 语句块中代码抛出异常,JVM 会保留这个异常,然后忽略抛出异常之后的代码转去执行 finally 里面的语句,当 finally 语句的代码抛出异常之后 JVM 会将这个异常覆盖原来 catch 语句块中抛出的异常,停止执行后面的代码,并结束 try-catch-finally 语句,最后将 JVM 中保留的异常进行向上传递。

所以说,上面的问题答案就是,抛出的异常为 finally 的异常,因为它覆盖了 catch 抛出来的异常,成功上位并被传递给上一级处理。


所以问题来了,如何解决这种覆盖?有时候我们是真的想要所有的异常信息的,那么就需要 Throwable.addSuppressed() 这个方法了,这个方法可以把一个异常的信息加入到另外一个异常当中,那么我们只要在 try-catch-finally 语句的前面提前声明一个异常实例,如果 catch 抛出了异常,就把这个异常引用给提前声明的异常实例,从而保留下来。最后到 finally 语句的时候,将保存的异常实例添加到 finally 语句要抛出的异常中,这样就同时保留了两个不同的异常信息。

1
2
3
4
5
6
7
8
9
10
11
12
Exception origin = null;
try {
System.out.println(Integer.parseInt("abc"));
} catch (Exception e) {
origin = e;
} finally {
Exception e = new IllegalArgumentException();
if (origin != null) {
e.addSuppressed(origin);
}
throw e;
}

上面的代码我们就用 origin 来保存了 catch 想要抛出的异常,然后利用 Throwable.addSuppressed() 将保存的异常加入了 finally 要抛出的异常中,从而将两个异常的信息同时传递给上一级。

但是这种能不用就不用啦,如果一定要使用的话,可以用 Throwable.getSuppressed() 来查看一个异常实例中包括的所有不同的异常信息。

自定义异常

在 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

但是我们在实际的业务需求中,可能自带的异常行为是没有办法满足我们的需要,这里就需要我们自定义异常了,首先需要自定义一个 baseException 用来作为最基本的根异常(建议从 RuntimeException 中派生),然后从根异常中继承出来各种需要的异常种类。

NullPointerException

NullPointerException 异常是空指针异常,通常是因为在调用一个实例的时候,因为实例为 null,那么 JVM 在调用这个实例的时候,就会抛出 NullPointerException 。

其实在 Java 中是没有指针的概念的,这里指的是没有引用,所以名字应该是 NullReferenceException 才对,至于为什么叫作 Pointer 其实我也不知道,NPE 异常是逻辑错误,是需要被早早处理的。一个好的避免这种错误的方法就是初始化,比如 String 使用空字符 "" 初始化而不是不管或者初始化为 null .

对于一种引用调用层很多的类,出现 NPE 错误是,定位是哪里的调用出现了 NPE 是很麻烦的。比如调用 a.b.c.d() 你就不确定是 a a.b a.b.c 哪一个没有引用,这里可以使用 Java14 新特性来直接找到异常位置,但是需要给 JVM 添加参数来开启: XX:+ShowCodeDetailsInExceptionMessages

断言

断言 assert 是 Java 的一种语句,和 if 语句相似,用于判断一个布尔表达式。只不过当布尔为真的时候,断言无事发生,当布尔为假的时候,断言抛出错误。

1
assert Expression;

断言就是一个人信誓旦旦的对一件事进行评价(立Flag),如果对了,那么挺好,如果错误,直接打脸(抛出错误)。

断言一般都用在测试阶段使用,对于一些逻辑上一定正确的地方,直接加上断言,从而判断程序实际执行的情况,如果抛出错误那就是找到 bug 了。

断言默认不使用,如果要用,需要在命令行加上:-ea ,比如 java -ea test.java

JDK Logging 日志系统

还是需要在开始之前介绍一下日志是啥,不然学的时候一头雾水。

日志是对程序运行状态的一个记录,这个记录有很多用处:

  1. 查看程序当前的运行状态:

    比如在搞一个超级大的项目的时候,运行时间会很长,不太容易直接看到程序运行的结果。加入日志系统之后,就可以根据实时输出的日志系统分析程序在运行的实时状态,从而分析程序执行情况。

  2. 查看程序历史运行轨迹:

    一旦程序在长期运行的过程中,时不时抽风,就可以事后查看看抽风的时候的日志,判断程序都抽了哪些风,自己给它布置的任务是否认真完成。

  3. 排查系统问题:

    良好的日志系统能帮助程序员分析代码哪里出现了问题,这里差不多就是一个加强版本的打表 debug ,日志的表更加详细,更加强大。

  4. 优化系统性能:

    通过日志时间找运行最慢的代码,然后专门去优化它。

  5. 安全审计:

    万一黑客入侵,可以根据日志系统查看黑客到底都干了啥,从而快速定位损失情况。

对于日志的实现,我们最简单的方法就是使用 JDK 自己带的日志系统,放在 java.util.logging 里面。使用之前需要用 import 语句导入 Logger 这个类,然后用 Logger 的静态方法 getLogger(String name) 创建一个 Logger 实例。

之后就可以使用各种日志语句了

  • severe()
  • warning()
  • info()
  • config()
  • fine()
  • finer()
  • finest()

其中 info() 及以上的严重程度的日志才会被打印出来,一般的使用方法为这样:

1
2
3
4
5
6
7
8
import java.util.logging.Logger;
public class test {
public static void main(String[] args) {
Logger logger=Logger.getLogger("TestLogger");
logger.info("start....");
logger.warning("end....");
}
}

Commons Logging

首先需要梳理一下它们的关系:

最开始 Java 好像没有自己日志记录系统,然后 Apache 开源社区自己搞了一个 Log4j 作为日志系统使用,但是 Sun 公司(Java 的开发公司)为了展示本公司才是正宗血统,于是搞了一个 JDK Logging 放到了 java.util.logging 包里面,作为官方唯一指定日志系统。但是这玩意一是不太好用,二是来的太晚,Log4j 已经深入人心,两者又互不相通,所以也没成为主流。

其他开发者也开发过各种奇奇怪怪的日志系统,但同样时各自为政,互不相通。但是这就太乱了!尤其是在调用其他开发者开发的库的时候,如果库使用的日志系统和自己项目使用的不一样,那就完蛋。怎么办?

Commons Logging 横空出世,它作为一个日志接口,将开发者和各种乱七八糟的日志系统之间建立了一个桥梁,开发者只需要和 Commons Logging 打交道即可,至于下面使用的是啥日志系统,都不需要担心,接口会帮你解决一切!


首先需要导入 Commons Logging 的两个必须的类,org.apache.commons.logging.Logorg.apache.commons.logging.LogFactory ,当然这两个类不是 Java 自己带的,需要去 Apache 下载,然后放到 classpath 定义的文件夹里面。

首先需要使用 LogFactory 类的静态方法 getLog() 来创建一个 Log 类的实例,需要传入的参数为使用这个实例的编译好的类名,这里建议使用 getClass() ,这样父类创建的日志子类也可以使用给你。像下面的代码一样创建一个实例:

1
Log log=LogFactory.getLog(getClass());

然后就可以利用这个 Log 类输出程序运行的状态日志了。

Commons Logging 定义了6个日志级别:

  • fatal() 致命错误
  • error() 普通错误
  • warning() 警告
  • info() 有用信息
  • debug() Debug信息
  • trance() 追踪信息

这些日志输出方法都有两个重载方法,一个是只有 String,另外一个是既有 String 又有 Throwable,其中 Throwable 参数是用来在日志中打印异常。

最后在编译运行的时候,需要把 jar 包添加到 classpath 中,也就是对应着命令行的 java -cp c:\classpath\commons-logging-1.2.jar test.jar