Java 异常处理机制

我们画不出完美的圆,我们无法追求到绝对的美。--罗翔

我们无法创造出没有bug的程序世界,程序世界一样不完美,总有意外情况,阻止程序完成自身的使命。因此,提供一套机制来处理异常,才能最大程度上保证程序的可靠性。

java 中的异常处理机制可以从 Exception 和 Error 讲起。

一、Exception 和 Error

Exception 和 Error 是什么呢?这事要从 Throwable 讲起。

Throwable 在 java.lang 包中,是 Java 中所有异常和错误的超类,Exception 和 Error 都继承了 Throwable。使用 throw,catch 等关键字的时候,会被判断是不是 Throwable 的子类。

其中,Exception 通常指程序可预知的意外情况,常见的 NPE 就属于该类;

对应的,Error 指的是不可预料的,难以处理的情况,通常是程序所在环境(JVM)出了严重问题,例如熟知的 OOM。

这些错误类型有什么区别呢?处理方式是不是一样呢?

二、checked vs unchecked

所有的异常和错误,都可以归为检查型异常(checked Exception)和非检查型异常(unchecked Exception)。

关于异常的场景,越早进行处理,通常收益越大,能在编译期就进行检测的,收益就大于在运行时进行处理。

检查型异常,就是编译时能够进行检测的异常,例如 IOException,当文件操作的时候,编译器就会提醒进行异常处理,这里的FileNotFoundException 就是检测型的 IO 异常,继承于 IOException。

image.png

    public void testFile(String fileName){
        try {
            BufferedReader in = new BufferedReader(new FileReader(fileName));
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
    }

但是,编译时能提示的异常总归是有限的,大部分无法进行有效提示,例如 NullPointerException。

所以,Error 和 RuntimeException 类统称为非检查型异常,无法在编写时进行检测提示,而其他的都是属于检查型异常。

检查型的异常,有点像免责声明,提示使用者使用时有一定的风险,并给出了风险类型,以便使用者做出应对方案。非检查型,是无法提前预知的风险,不知道要发生什么,也不知道何时发生。

总之,两类维度,从不同阶段来说,分成了检查型和非检查型,以便用不同的方式处理,而从重要程度来说,又分为异常和错误,来表达其严重性。

那当异常实际发生的时候,JVM 是如何处理这些异常的呢?

三、处理机制

简单来说,用了一张异常表来管理异常,记录了监听的代码块,对应的异常类型,以及异常发生时要跳转的目标代码(其实就是 goto语句)。如下所示,代表的是 0~7 发生 ClassNotFoundException 异常的时候,跳转到 17 进行处理,发生 Exception 的时候,跳转到 36 进行处理。

Java 异常处理机制_第1张图片

这里有一个重要的知识点,就是 finally 是如何体现的?如何保证的 finally 的代码一定能执行呢?

编译的时候,finally 里面的代码被 copy 到了其它代码块里面,放到了代码块的 return 之前,比如下面这个例子,在编译后,在字节码层面就类似第二种形式。

    public void test(String name){
        try {
            Class.forName(name);
        } catch (ClassNotFoundException e) {
            System.out.println("catch ClassNotFoundException");
        } catch (Exception e){
            System.out.println("catch Exception");
        } finally {
            System.out.println("finally code");
        }
    }
    public void test(String name){
        try {
            Class.forName(name);
            System.out.println("finally code");
        } catch (ClassNotFoundException e) {
            System.out.println("catch ClassNotFoundException");
            System.out.println("finally code");
        } catch (Exception e){
            System.out.println("catch Exception");
            System.out.println("finally code");
        }
    }

所以这里有个比较诡异的地方,如果 finally 里面写了return,就会导致其他代码块里面的 return 和 throw 都不生效了,所以养成良好的习惯,避免在 finally 写 return,throw 等中断语句,不然会很难理解,不知道发生了啥。

值得一提的是,java 7 后引入了语法糖 try-with-resource,看个文件操作的例子,实例化输入输出流,在 finally 里面需要进行很繁杂的关闭连接的处理。

    public void testFile(String fileInputName,String fileOutputName){
        BufferedInputStream bis = null;
        BufferedOutputStream bos = null;

        try {
            bis = new BufferedInputStream(new FileInputStream(fileInputName));
            bos = new BufferedOutputStream(new FileOutputStream(fileOutputName));
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } finally {
            if (bis != null) {
                try {
                    bis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    if (bos != null) {
                        try {
                            bos.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }

try-with-resources 只需要这样写,很整洁优雅,写在 try 里面的资源,发生异常的时候,会自动关闭申请的资源连接。而且这样写还有一个优势,上述例子中,如果 finally 里面的代码发生了异常,就会覆盖原有的异常,而新写法则不会,而是通过 addSuppressed 的方式,将关闭连接的异常加到原有的异常里面。

    public void testFile(String fileInputName,String fileOutputName){

        try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileInputName)); 
             BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fileOutputName))) {
            //todo 
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

这里有几个关键的知识点,一个是异常表,感兴趣的同学可以看看编译后的字节码文件,一个是 finally 的实现原理,本质上就是 copy 代码,理解这个后很多相关面试题目就迎刃而解,最后,是新的写法,让资源管理更优雅,而且解决了异常被抑制的问题,让出现问题后,排查效率更加高效。

四、经典例子

知道了基础知识后,分析下 ClassNotFoundExceptionNoClassDefFoundError 这个经典的例子,加深下理解。

ClassNotFoundException,是一个检查型异常,编写 Class.forName() 的时候,会提示进行异常处理。当 JVM 加载 class 文件到方法区的时候,如果没有找到该类,就会抛出该异常。一般来说,是输入的名称有问题导致的。

NoClassDefFoundError,是一个 Error,一般是 new 一个对象的时候,找不到类的定义导致的,也就是编译的时候还有该文件,但是运行的时候却找不到了。能出现这种情况的,一般是环境上出问题了,例如编译好的 jar 被破坏了等等因素。

五、应用原则

在平时写业务代码的时候,有什么样的指导原则呢?接下来介绍一些比较重要的原则。

最值得提的,是 Throw early, catch late 原则,适用性比较广。

首先是 throw 要尽量提前,比如要读取文件的时候,提前判断文件名称是否为空,尽早终止流程,对比如下两种写法,第二种能够更快的定位到问题,第一种的堆栈信息可能会很长,很一眼就看出问题所在。

    public void test(String className){
        try {
            Class.forName(className);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
    public void test(String className){
        try {
            if (className == null) {
                throw new NullPointerException();
            }
            Class.forName(className);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

这个思路也可以应用到写业务代码的时候,可以提前进行参数校验,业务校验,再执行具体的业务行为。

甚至,生活上也一样,出门前,检查”身手钥钱“,避免上地铁时没带手机的尴尬。

其次,catch later 表达的是捕获异常后,如果不知道如何处理,就往上层抛,而不是吞掉异常,或者随便处理。因为在更高的层次,会有更多的信息来处理异常。好比一个案件如果在市里面无法处理,应当到省里面处理。

说到 catch 后的处理,这里也有几个要点需要注意下:

1、不要吞掉异常,无法处理要往上抛;
2、不调用 e.printStackTrace(),因为是标准错误输出;
3、不 catch Exception 或 throwable;
4、catch 多个类型要注意顺序,由小到大,依次处理。

还有就是不要用异常来做业务的流程控制,虽然能达到目的,但是可读性不高,维护效率低,另外一个是运行效率也低,因为 try-catch 是需要额外的开销,比 if else 判断效率低很多,且每实例化一个 Exception,都会对堆栈进行快照,这是个很重的动作。

六、总结

讲了这么多,总的来说,java 这套异常处理机制的优势在哪里呢?

首先,想象下如果没有这套机制会怎么样,例如 C 语言,就没有专门的异常处理机制,异常体现在返回值里面,这样的做法会让处理异常的逻辑侵入业务代码,降低代码的可读性。

其次,如果不对异常进行封装处理,对用户极其不友好,代码的专业报错对用户来说是不可理解的,这种做法会降低产品的体验。

七、扩展

最后,留几个思考题:

1、业务开发中,有必要定义检查型异常吗?为什么?

2、像 lambda 这种反应式编程,如何处理异常?

3、业务开发中,用拦截器统一拦截接口的异常进行处理,这样的方式是好是坏呢?

你可能感兴趣的:(java)