# 异常机制

先看类结构,再学使用,不然讲使用的时候会有点懵。

# 异常层次结构

Java 异常都是对象,是 Throwable 子类的实例。Java 通过 Throwable 众多子类描述各种不同的异常,描述出现在一段编码中的错误条件,当条件生成时,错误就会引发异常。

错误比异常更严重,当出现时,JVM 直接就崩了,本文主要讲的还是异常。

Throwable 包含了其线程创建时线程执行堆栈的快照,它提供了 printStackTrace() 等接口用于获取堆栈跟踪数据等信息。

对于异常,分为两种:

  • 运行时异常

都是 RuntimeException 类及其子类,比如 NullPointerException 异常,它就是继承 RuntimeException 异常。这种异常一般是由程序逻辑错误引起的,只有程序跑起来才会出现的异常,开发者在编写代码时也应该极力避免出现这种异常,这是程序员该做的事情,不应该交给编译器,所以编译器也不会检查它,即使没有 try-catch 捕获它,没有用 throws 声明,也能够编译通过。

运行时异常和错误也叫做不可查异常,编译器不要求强制处置的异常

  • 编译时异常

也叫做非运行时异常,除了 RuntimeException 以外所有的异常。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。编译时异常也叫作可查异常,从一定程度上这种异常的发生是可以预料到的。

# 异常的使用

了解了异常的层次结构后,再来使用就明了很多。

# 自定义异常

自定义编译时异常只需要继承 Exception 即可:

public class CyanException extends Exception{
    public CyanException(String message){
        super(message);   // 这里我们选择使用父类的带参构造,这个参数就是异常的原因
    }
}

运行时异常继承 RuntimeException 即可:

public class CyanException extends RuntimeException{
    public CyanException(String message){
        super(message);
    }
}

有时我们会从 catch 中抛出一个异常,目的是为了改变异常的类型。多用于在多系统集成时,当某个子系统故障,异常类型可能有多种,可以用统一的异常类型向外暴露,不需暴露太多内部异常细节。

private static void readFile(String filePath) throws MyException {    
    try {
        // code
    } catch (IOException e) {
        MyException ex = new MyException("read file failed.");
        ex.initCause(e);
        throw ex;
    }
}

initCause 方法就是:当我们 try 住某块代码发生了异常后,希望再 catch 中 throw 一个新的异常,但是又不希望丢失引起异常的原始异常则可以使用该方法设置原始异常到我们的新异常中。

# 抛出异常

抛出异常就要用到关键字 throws/throw

如果一个方法中可能会出现异常(会出现异常的情况很多,比如代码执行语句存在出现异常的可能性,或者调用了一个可能抛出异常的方法),我们又没有在可能出现异常的地方捕获并处理它,那么就需要通过 throws 关键字申明当前方法可能出现的异常。

在方法头中显式声明该异常,以便于告知方法调用者此方法有异常,需要进行处理。

通常,应该捕获那些知道如何处理的异常,将不知道如何处理的异常继续传递下去。举个例子:

public class Main {
    public static void main(String[] args)  {
        readFile();// 此时会报错
    }
    private static void readFile() throws IOException {
    }
}

main 调用了 readFile 方法,但是后者申明了自己这个方法可能会抛出 IOException 这个异常,所以 main 要么继续向上抛出 IOException ,即在 main 的方法头继续使用 throws 抛出,或者使用 try-catch 语句捕获这个异常并处理。

Throws 抛出异常的其他规则:

  • 如果是不可查异常(unchecked exception),即 Error、RuntimeException 或它们的子类,那么可以不使用 throws 关键字来声明要抛出的异常,编译仍能顺利通过,但在运行时会被系统抛出
  • 调用方法必须遵循任何可查异常的处理和声明规则。若覆盖一个方法,则不能声明与覆盖方法不同的异常。声明的任何异常必须是被覆盖方法所声明异常的同类或子类。

学了 throws ,那么它和 throw 又有什么区别呢?为什么我要先讲 throws 呢?在学习 throw 之前,我们再次回顾一下:

  1. 运行时异常比较宽松,不使用 throwstry-catch 都可以,但是编译时异常必须要处理,要么捕获,要么继续向上抛(也就是当前方法不处理,交给我的调用者去处理)。
  2. 当前方法有一段代码,这段代码也可能是一个方法调用,总之,这段代码抛出了一个异常,我们就必须处理它。

你可能疑惑为什么第 2 点和第 1 点后半部分一样。第 2 点我强调了抛出这个行为,第 1 点我强调了处理这个行为。 throw 就是抛出这个行为,这就是需要知道的,除了代码本身逻辑有问题,比如:

Object o = null;
o.toString();

这样会自动抛出一个异常, throw 关键字就是主动抛出一个异常。

下面看个例子,加深一下理解:

// 代码会标红报错,这样写是不对的
public static void test1() {
    throw new Exception("我是编译时异常!");
}
// 代码正常
public static void test2() {
    throw new RuntimeException("我是运行时异常!");
}

为什么 test1 会报错呢?还是回到最上面的, test1 方法中有一个代码段抛出了一个异常,这个异常还是一个编译时异常,我们必须要捕获它或者继续向上抛出:

// 解决 1:捕获该异常
public static void test1() {
    try{
        // 这个代码段抛出异常
    	throw new Exception("我是编译时异常!");    
    } cetch(Exception e) {
        e.printStackTrace();
    }
}
// 解决 2:抛出该异常
public static void test1() throws Exception{
    throw new Exception("我是编译时异常!");
}

我们在重写方法时,如果父类中的方法表明了会抛出某个异常,只要重写的内容中不会抛出对应的异常我们可以直接省去,而重写方法抛出的异常,也不能超过父类的方法抛出的异常。

# 捕获异常

使用的语句有: try-catchtry-catch-finallytry-finallytry-with-resource

着重讲一下:

  • try-finally :常常用于资源的关闭或者解锁等。但是 finally 遇到以下情况就不会执行

    • try 中代码使用 System.exit() 退出程序
    • finally 发生异常
    • 程序所在线程死亡
    • 关闭 CPU

    当 try 有 return 语句,没有产生异常时,执行到 return 语句时,会先算出 return 表达式的值,并将其保存起来。注意,此时没有返回,只是计算表达式的值并保存起来,然后再去执行 finally 代码块,如果 finally 代码块有 return 语句,程序执行到 return 语句,程序会提前结束,然后返回值,不会去执行 try 中的 return。

    当然,规范是你不应该在 finally 使用 return 语句

  • try-with-resource :自动释放资源,但是该资源需要实现 AutoCloseable 接口

private  static void tryWithResourceTest(){
    try (Scanner scanner = new Scanner(new FileInputStream("c:/abc"),"UTF-8")){
        // code
    } catch (IOException e){
        // handle exception
    }
    // 离开 try 语句后自定关闭 Scanner 资源(调用 scanner.close )
}

和把 scanner.close 方法放在 finally 代码块中不同的是,若 scanner.close 抛出异常,则会被抑制。

到这里,异常基本算是说完了,但是还没有涉及原理部分。

# 深入理解异常

看一下底层实现和一些规范约束。

# JVM 处理异常机制

先看一段代码:

package com.cyan;
public class Main {
    public static void main(String[] args)  {
        test();
    }
    public static void test() {
        try {
            oneException(); // 一个可能抛出异常的方法
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void oneException() throws Exception{
        throw new Exception("抛出编译时异常");
    }
}

编译后,使用 javap -c Main.class 查看字节码文件:

public static void test();
    Code:
       0: invokestatic  #3                  // Method oneException:()V
       3: goto          11
       6: astore_0
       7: aload_0
       8: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
      11: return
    Exception table:
       from    to  target type
           0     3     6   Class java/lang/Exception

发现有一个异常表:

  • from 可能发生异常的起始点
  • to 可能发生异常的结束点
  • target 上述 from 和 to 之前发生异常后的异常处理者的位置
  • type 异常处理者处理的异常的类信息

JVM 处理异常的一些机制:

  1. JVM 会在当前出现异常的方法中,查找异常表,是否有合适的处理者来处理
  2. 如果当前方法异常表不为空,并且异常符合处理者的 from 和 to 节点,并且 type 也匹配,则 JVM 调用位于 target 的调用者来处理。
  3. 如果上一条未找到合理的处理者,则继续查找异常表中的剩余条目
  4. 如果当前方法的异常表无法处理,则向上查找(弹栈处理)刚刚调用该方法的调用处,并重复上面的操作。
  5. 如果所有的栈帧被弹出,仍然没有处理,则抛给当前的 Thread,Thread 则会终止。
  6. 如果当前 Thread 为最后一个非守护线程,且未处理异常,则会导致 JVM 终止运行。

# 异常耗时

使用异常是更加耗时间的,不同语言的耗时原因也可能各有不同。在 Java 中,创建一个异常对象,比创建一个普通 Object 对象耗时得多,20 倍以上(使用 System.nanoTime() 可以检验)。

有这么一段代码:

public static boolean validateInteger(String str) { 
	try { 
        Integer.parseInt(str); 
     } catch (Exception ex) { 
         return false; 
     } 
     return true; 
}

当时这个帖子的讨论链接:https://www.iteye.com/topic/856221

楼主提问:验证 String 是不是整数,用异常作判断怎么了!

这种通过异常来代替控制语句的方式,我这学期(大三上)的全栈开发老师也提到过(c# 语言),老师的观点就比较中肯:咱没测试过谁快,咱也不瞎说,只是说,用异常代替控制流不会出现语法错误。

然后那节课老师放 b 站了,有个网友是这么评价的:

异常处理确实比 if 慢,因为异常会通过内核调用激活 Windows 标准异常处理机制。这必然会导致异常代码从用户态到内核态再回到用户态,这样绕一圈开销是很大的。
另外,异常和 if 在语义是是有区分的,只是很多人乱用。if 是正常流程的分支,在不同情况做不同工作。异常则表示程序遇到了不在正常流程设计中的情况,至少对写抛出异常语句的那个人来说是这样,我不知道这时应该怎么办,只能把错误报告给外面,寻找知道该怎么办的人。如果直到栈顶都没找到,操作系统就会为了保护自己和其他程序的安全把进程干掉。因此异常要顺着调用栈往上爬,寻找知道知道如何处理的人或者等死。

我看完帖子后,感觉这种说法也没说到点子上,也许这种说法是符合规范的,但是没有说出楼主问题的本质。

帖子讨论的问题是到底怎么了,有些人是从性能方面解释的,通过暴力判断字符串是不是由数字组成耗时和异常比较,没出异常两个都是差不多的,但是出了异常就是前者更快。但是楼主本身是知道异常更慢的,他的观点在于,Java 更应该是便利程序开发,对于这个问题,这点时间是可以忽略的。

具体讨论了什么你们自己去看看吧

还有个大佬的文章也可以参考:https://www.pudn.com/news/628f832bbf399b7f351e7284.html

# 参考

https://blog.csdn.net/wang0907/article/details/114143735

https://pdai.tech/md/java/basic/java-basic-x-exception.html

https://www.yuque.com/qingkongxiaguang/javase/zqu2fy#e33db845

try-finally 存在 return