面试官别再问系列:Java Exception 和 Error 的区别?

2,628 阅读10分钟

昨天老源Sir在面试间等待候选人,偶然听到隔壁房间的面试官问了候选人一个问题:“JavaExceptionError 有什么区别呢?”。老源Sir 听到这里心里嘿嘿一笑。这是个常见的连环问题了。结果没想到候选人第一个问题就卡壳了。老源 Sir 不禁为候选人捏了一把汗啊,这个问题其实还蛮基础的呢。。。当时心里也就下了个决心,一定要写篇文章把 Java 的异常相关讲明白,让大家再也不怕在面试中遇到这一类的问题。

throw 语句

有点 java 基础的同学应该都知道 throw 这个语句吧(如果不知道的话可以点击右上角的叉号了😂)。我们都知道throw 语句起到的作用,它会抛出一个 throwable 的子类对象,虚拟机会对这个对象进行一系列的操作,要么可以处理这个异常(被 catch),或者不能处理,最终会导致语句所在的线程停止。

那么 JVM 到底是怎么做的呢?让我们一起试试看吧:

首先写一段代码,throw 一个 RuntimeException:

package com.company;

public class TestException {
    public static void main(String[] args) {
        throw new RuntimeException();
    }
}

编译后,到 class 文件所在目录,用javap -verbose 打开 .class 文件:

 javap -verbose TestException

可以看到一些字节码。我们找到 TestException.main 函数对应的字节码:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: new           #2                  // class java/lang/RuntimeException
         3: dup
         4: invokespecial #3                  // Method java/lang/RuntimeException."<init>":()V
         7: athrow
      LineNumberTable:
        line 5: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  args   [Ljava/lang/String;

code 部分,实际上前三行就是对应 new RuntimeExcpetion(),由于主题原因,这里就不展开了。重头戏是后面这个 athrow,它到底做了什么呢?

1. 先检查栈顶元素,必须是一个java.lang.Throwable的子类对象的引用;

2. 上述引用出栈,搜索本方法的异常表,是否存在处理此异常的 handler;

    2.1 如果找到对应的handler,则用这个handler处理异常;

    2.2 如果找不到对应的handler,当前方法栈帧出栈(退出当前方法),到调用该方法的方法中搜索异常表(重复2);

3. 如果一直找不到 handler,当前线程终止退出,并输出异常信息和堆栈信息(其实也就是不断寻找 handler 的过程)。

可以看到 throw 这个动作会造成几个可能的副作用:

  1. 终止当前方法调用,并传递异常信息给方法调用方;
  2. 如果异常一直无法在方法的异常表里找到 handler,最终会导致线程退出。

好了,到这里我们已经搞明白 throw 到底干了些啥。但是我们注意到, athrow 指令寻找的是一个 java.lang.Throwable 的子类对象的引用,也就是说 throw 语句后面只能跟 java.lang.Throwable 的子类对象,否则会编译失败。那么Throwable 到底是个什么东西呢?

Throwable 类簇

Throwable 顾名思义,就是可以被 throw 的对象啦!它是 java 中所有异常的父类:

public class Throwable implements Serializable {
    ...
}

JDK 自带的异常类簇,继承关系大概是这个样子的:

首先可以看到 Throwable 分成两个大类,Exception 和 Error

Error

Error 是 java 虚拟机抛出的错误,程序运行中出现的较严重的问题。

例如,虚拟机的堆内存不够用了,就会抛出 OutOfMemoryError。这些异常用户的代码无需捕获,因为捕获了也没用。

这就好比船坏了,而船上的乘客即便知道船坏了也没办法,因为这不是他们能解决的问题。

Exception

Exception 是应用程序中可能的可预测、可恢复问题。

啥意思呢?也就是说Exception都是用户代码层面抛出的异常。换句话说,这些异常都是船上的乘客自己可以解决的。例如常见的空指针异常NullPointerException,取数组下标越界时会抛出ArrayIndexOutOfBoundException

这些都是“乘客”的错误操作引发的问题,所以“乘客”是可以解决的。

到了这里,老源Sir听到的那道面试题,是不是就已经解决了呢?

CheckedException 和 UncheckedException

老源Sir前面也说了,这是个常见的连环问题。那么解决了第一个问题,面试官接下来会问什么呢?

通过上面一节的叙述大家可以看到,就 Throwable 体系本身,与程序员关系比较大的其实还是 Exception 及其子类。因为船上的乘客都是程序员们创造,所以他们的错误行为,程序员还是要掌握得比较透彻的。

Exception 可分为两种,CheckedExceptionUncheckedException

  • UncheckedException

顾名思义,UncheckedException 也就是可以不被检查的异常。JVM 规定继承自 RuntimeException 的异常都是 UncheckedException.

  • CheckedException

所有非 RuntimeException 的 Exception.

那么问题来了,什么叫 被检查的异常 ? 谁检查?

throws 语句

试想一下以下这个开发场景:

  • 同学 A 写了一个工具类,编译后打成了一个 jar 包给同学 B 使用
  • 同学 B 调用这个工具类的时候,由于应用场景不同,被抛出了很多不同类型的异常,每出现一种新的异常,同学 B 都要修改代码去适配,非常痛苦。。。

为了解决这个场景中出现的问题,JVM 规定,每个函数必须对自己要抛出的异常心中有数,在函数声明时通过 throws 语句将该函数可能会抛出的异常声明出来:

 public Remote lookup(String name)
        throws RemoteException, NotBoundException, AccessException;

这个声明就是前面说的被检查的异常

那么可以不被检查的异常又是咋回事呢?

其实 CheckedExcpetion 之所以要被 Check,主要还是因为调用方是有呢你处理这些异常的。

java.net.URL 这个类的构造函数为例:

public final class URL implements java.io.Serializable {
    ...
    public URL(String protocol, String host, int port, String file,
               URLStreamHandler handler) throws MalformedURLException {
       ...
       if (port < -1) {
                throw new MalformedURLException("Invalid port number :" +
                                                    port);
            }
    }
}

MalformedURLException就是一种checked exception. 当输入的 port < -1 时,程序就会抛出 MalformedURLException 异常,这样调用方就可以修正port输入,得到正确的URL了。

但是有一些情况,比如下面这个函数:

public void method(){
   int [] numbers = { 1, 2, 3 };
   int sum = numbers[0] + numbers[3];
}

由于 numbers 数组只有3个元素,但函数中却取了第4个元素,所以调用 method() 时会抛出异常 ArrayIndexOutOfBoundsException。但是这个异常调用方是无法修正的。

对于这种情况,JVM 特意规定了 RuntimeException 及其子类的这种 UnchekcedException,可以不被 throws 语句声明,编译时不会报错。

异常处理

上面我们介绍了异常的定义和抛出方式,那么怎么捕获并处理异常呢?这个时候就轮到 try catch finally 出场了。举个例子:

public void readFile(String filePath) throws FileNotFoundException {
    FileReader fr = null;
    BufferedReader br = null;
    try{
        fr = new FileReader(filePath);
        br = new BufferedReader(fr);
        String s = "";
        while((s = br.readLine()) != null){
            System.out.println(s);
        }
    } catch (IOException e) {
        System.out.println("读取文件时出错: " + e.getMessage());
    } finally {
        try {
            br.close();
            fr.close();
        } catch (IOException ex) {
            System.out.println("关闭文件时出错: " + ex.getMessage());
        }
    }
}

这是一个逐行打印文件内容的函数。当输入的 filePath 不存在时,会抛出 CheckedException FileNotFoundException

在文件读取的过程中,也会出现一些意外情况可能造成一些 IOException,因此代码对可能出现的 IOException 进行了 try catch finally 的处理。 try 代码块中是正常的业务代码, catch 是对异常处理,finally 是无论try 是否异常,都要执行的代码。对于 readFile 这个函数来说,就是要关闭文件句柄,防止内存泄漏。

这里比较难受的是,由于 fr br 需要在 finally 块中执行,所以必须要在 try 前先声明。有没有优雅一点的写法呢?

这里要介绍一下 JDK 7 推出的新特性:

try-with-resources

try-with-resources 不是一个功能,而是一套让异常捕获语句更加优雅的解决方案。

对于任何实现了 java.io.Closeable 接口的类,只要在 try 后面的()中初始化,JVM 都会自动增加 finally 代码块去执行这些 Closeableclose()方法。

Closable 定义如下:

public interface Closeable extends AutoCloseable {

    /**
     * Closes this stream and releases any system resources associated
     * with it. If the stream is already closed then invoking this
     * method has no effect.
     *
     * <p> As noted in {@link AutoCloseable#close()}, cases where the
     * close may fail require careful attention. It is strongly advised
     * to relinquish the underlying resources and to internally
     * <em>mark</em> the {@code Closeable} as closed, prior to throwing
     * the {@code IOException}.
     *
     * @throws IOException if an I/O error occurs
     */
    public void close() throws IOException;
}

由于 FileReaderBufferedReader 都实现了 Closeable 接口,所以上述前面我们的 readFile 函数可以改写为:

    public void readFile(String filePath) throws FileNotFoundException {
        try(
                FileReader fr = new FileReader(filePath);
                BufferedReader br = new BufferedReader(fr)
        ){
            String s = "";
            while((s = br.readLine()) != null){
                System.out.println(s);
            }
        } catch (IOException e) {
            System.out.println("读取文件时出错: " + e.getMessage());
        }
    }

是不是清爽了许多呢?

finally 的几个特殊情形

finally,让我们来说一说 finally 😂。

对于一个完整的 try catch finally 代码块,它的执行顺序是: try --> catch --> finally。

但是总有一些很奇怪的代码,值得我们研究一下:

    public static void returnProcess() {
        try {
            return;
        } finally {
            System.out.println("Hello");
        }
    }

大家猜猜 finally里的代码会不会执行呢?按道理说,先执行 try 代码块,直接 return 了, finally 应该不会再执行了吧?但实际情况是, finally 中的代码在 return 之后是会被执行的。

那再看看下面的代码,猜猜 finally 中的代码会不会执行:

    public void exitProcess() {
        try {
            System.exit(1);
        } finally {
            System.out.println("Hello");
        }
    }

有的同学会说了,前面不是说了嘛,无论 try 中的代码有没有抛出异常, finally 中的代码都会执行。但其实这个说法是不准确的。在本例中, try 中的代码调用了 System.exit(1),这条语句会直接退出 Java 进程,进程都退出了,finally 语句代码块的执行机制也就不存在了 finally 中的语句也就不会执行了。

再看下面这段代码:

    public static int returnInt() {
        int res = 10;
        try {
            res = 30;
            return res;
        } finally {
            res = 50;
        }
    }

    public static void main(String[] args) {
        System.out.println(returnInt());
    }

根据代码1的经验,finally 中的语句会执行,那 res 应该被赋值为50了,因此程序会被输出50吧?

但其实并不是,程序依然会被输出 30

神马!!!

这里请大家不要站在函数的视角去看 return 语句,而要站在 JVM 视角看 return 语句。从函数视角看, return 总是最后一行执行的语句。但是站在 JVM 视角来看,它也只是一个普通的语句而已。

小调皮同学又问了:那如果在 finallyreturn 一下会怎样呢?

    public static int returnInt() {
        int res = 10;
        try {
            res = 30;
            return res;
        } finally {
            res = 50;
            return res;
        }
    }

    public static void main(String[] args) {
        System.out.println(returnInt());
    }

哈哈,这个时候就会输出 50 了!

神马鬼东西!我已凌乱在风中!

这是因为 return 语句实际上是会被 “覆盖”的。也就是说,当 finally 中出现了 return 语句时,其他地方出现的 return 语句都无效了。而 finally 语句中的 return 语句时在 res = 50 这个赋值语句之后的,因此就返回了 50

所以看得出来,在 finally 代码块中使用 return 是个非常危险的事情:

不要在 finally 中使用 return!

小结

本文主要介绍了以下几个内容:

  • Java 异常的工作原理;
  • Java 异常类簇;
  • 异常处理流程;
  • finally 语句的几个特例。

希望大家看了本文以后,不会再被 Java 异常方面的面试题问倒!由于水平有限,如果有什么不足,也很欢迎大家在留言区讨论哦!