Exception 和 Error 区别详解

1,123 阅读13分钟

问题

请对比一下 Exception 和 Error。另外,请说一下运行时异常与一般异常有什么区别?

答案

概念

Exception 和 Error

相同点

都继承了 Throwable 类。在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch)。它是异常处理机制的基本组成类型。

不同点

Exception 和 Error 则体现了 Java 平台设计者对于不同异常情况的分类。

Exception 是程序正常运行过程中,可以被预料到的意外情况可能并且应该被捕获并进行相应处理的

Error 是指在正常情况下,不太可能出现的情况。绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常、不可恢复的状态。既然是非正常情况,所以不方便捕获,也不需要捕获,常见的比如 OutOfMemoryError 之类,都是 Error 的子类。

检查异常和非检查异常

检查异常就是 编译器要求你必须处理的异常。比如在我们日常编码过程中,你写的某段代码,编译器要求你必须对这段代码加上 try ... catchthrows exception,这就是检查异常。也就是说,你的代码还没运行呢,编译器就会检查你的代码,看看是否会出现异常,并要求你对可能出现的异常必须做出相应的处理。

非检查异常就是 编译器不会强制要求处置的异常。虽然你有可能出现错误,但是编译器不会去检查,没必要,也不可能。

Java 语言在设计之初就提供了相对完善的异常处理机制,这也是 Java 得以大行其道的原因之一,因为这种机制大大降低了编写和维护可靠程序的门槛。如今,异常处理机制已经成为现代编程语言的标配。

如何处理

  • 检查异常:用 try ... catch 捕获。注意:必须处理。
  • 非检查异常:一般不处理,因为你很难判断会出什么问题,而且有些异常很难在运行时处理,比如空指针,需要人手动去查找。

运行时异常和非运行时异常

  • 运行时异常:不检查异常就是运行时异常,都是 RuntimeException 类及其子类异常,如 NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)。程序中可与选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误导致的,我们应该从程序的逻辑角度尽量避免这类异常发生。
  • 非运行时异常:是 RuntimeException 以外的异常,类型上都属于 Exception 类及其子类。从程序语法角度来讲是必须进行处理的异常,如果不处理,就无法编译通过,如 IOException 、SQLException 异常。

深入学习

理解 Throwable、Exception、Error 的设计和分类

Throwable、Exception、Error 的设计和分类

LinkageError 表明 A 类对 B 类有依赖,然而 B 类与 A 类却有不兼容性问题。

常见的运行时异常有:

  • NullPointerException - 空指针异常
  • ClassCastException - 类型强制转换异常
  • IndexOutOfBoundsException - 下标越界异常
  • IllegalArgumentException - 传递非法参数异常
  • ArithmeticException - 算数运算异常
  • ArrayStoreException - 向数组中存放与声明类型不兼容对象异常
  • NumberFormatException - 数字格式异常
  • SecurityException - 安全异常
  • UnsupportedOperationException - 不支持的操作异常
  • NegativeArraySizeException - 创建一个大小为负数的数组错误异常

ClassNotFoundException 和 NoClassDefFoundError 的区别

ClassNotFoundException 分析

首先,从名字上来看,NoClassDefFoundError 是一个 错误(Error),而 ClassNotFoundException 是一个异常(Exception)。

ClassNotFoundException 告诉我们的是当我们使用类加载器加载某个类的时候,发现所有的 path 下面都没有找到该类,从引导类路径、扩展类路径到当前的 classpath 下全部都没有找到,就会抛出上面的异常,最常见的例子就是加载 JDBC 驱动包的时候,它的依赖 jar 包并不在 classpath 里面,比如:

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
        try {
            Class.forName("oracle.jdbc.driver.OracleDriver");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

提示的错误信息如下:

java.lang.ClassNotFoundException: oracle.jdbc.driver.OracleDriver
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:602)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
	at java.base/java.lang.Class.forName0(Native Method)
	at java.base/java.lang.Class.forName(Class.java:340)
	at com.example.wys.demo.DemoApplication.main(DemoApplication.java:12)

出现这种情况,其实根本原因就是类找不到,通常执行下面的方法时容易抛出该异常:

Class.forName(),
ClassLoader.loadClass()  
ClassLoader.findSystemClass()

NoClassDefFoundError 分析

我们再来看一看 NoClassDefFoundError,从名字上看这就不是异常了,而是 JVM 的 ERROR 了。

这个错误主要是由两种情况造成的:

  1. 编译时存在某个类,但是在运行时却找不到,如下:
public class TestA {

    public static void main(String[] args) {
        try {
            TestB testB = new TestB();
        }catch (Throwable e){
            System.out.println(e);
        }
    }
}

public class TestB {

    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

上面的 Java 类编译后会生成两个类文件,一个是 TestA.class,另一个是 TestB.class。现在当我编译完成后,删掉了 TestB.class 文件,然后直接执行 TestA 中的 main 方法,就会抛出 NoClassDefFoundError 错误,因为当执行到了 TestB testB = new TestB(); 这一步的时候,JVM 认为这个类肯定在当前的 classpath 里面,要不然编译都不会通过,更不用执行了。既然它存在,那么在 JVM 里面一定能招到,如果不能招到,那就说明出大事了,因为编译和运行不一致,所以直接抛出这个 ERROR,代表问题很严重。

  1. 第二种情况就是,类根本就没有初始化成功,结果你还把它当做正常类来使用,这个时候,肯定也要抛出个 ERROR 来了。
public class TestA {

    public static void main(String[] args) {
        try {

            double i=TestB.i;

        }catch (Throwable e){

            System.out.println(e);
        }

        TestB.print();
    }

}


public class TestB {

    static double i = 1/0;

    public static void print() {
        System.out.println("Hello World!");
    }
}

执行 TestA 的 main 方法,报错如下:

Exception in thread "main" java.lang.NoClassDefFoundError: Could not initialize class com.example.wys.demo.TestB
	at com.example.wys.demo.TestA.main(TestA.java:15)

注意,这里我们故意将类 TestB 初始化失败的,这个情况很特殊,并不是编译时和运行时环境不一致导致的,而是对于一个类初始化失败后,你还继续使用,那么 JVM 会认为这是不正常的。由于它第一次调用已经失败,JVM 就会假设后面继续调用肯定仍然会失败,所以直接抛 ERROR 错误出来。

Throwable 实践

try ... catch ... finally 执行顺序

我们来举个小例子来看一下,try ... catch ... finally 的执行顺序。

public class TestA {

    public static void main(String[] args) {
        String result = test();
        System.out.println("最终返回结果:" + result);
    }

    public static String test() {
        try {
            System.out.println("try 块开始执行");
            int[] array = {1, 2, 3};
            System.out.println(array[4]);
            System.out.println("try 块执行结束");
            return "try 块返回结果";
        }catch (Throwable e){
            System.out.println("catch 块开始执行");
            System.out.println(e);
            System.out.println("catch 块执行结束");
            return  "catch 块返回结果";
        } finally {
            System.out.println("finally 块开始执行");
            System.out.println("finally 块执行结束");
            return  "finally 块返回结果";
        }
    }
}

执行结果如下:

try 块开始执行
catch 块开始执行
java.lang.ArrayIndexOutOfBoundsException: Index 4 out of bounds for length 3
catch 块执行结束
finally 块开始执行
finally 块执行结束
最终返回结果:finally 块返回结果

我们可以基于这个例子继续实验,最终得到的结果如下:

  1. try 模块出现异常时,后面的代码不会执行
  2. finally 模块一定会执行。
  3. trycatchfinally 模块都有 return 语句时,返回的是 finally 模块的 return 语句,如果没有 finally 模块,则返回 catch 模块的语句,如果只有 try 模块有 return 语句,则需要修改返回值为 void 了。

throw 和 throws 的区别

throw 是语句抛出的一些异常。

语法:throw 异常对象;

示例:

        try {
            
        }catch (Throwable e){
            throw e;
        } 

throws 是方法可能抛出异常的声明。(用在声明方法时,表示该方法可能要抛出异常)

语法:(修饰符)(返回值类型)(方法名)([参数列表])[throws(异常类)]{......}

示例:

public static String test() throws IOException {

try-with-resources 和 multiple catch

try-with-resources

try-with-resources 语句是一个声明了 1 到多个资源的 try 语句。资源是指这个 try 执行完成后必需 close 掉的对象,比如 connection,resultset 等。

try-with-resources 语句会确保在 try 语句结束时关闭所有资源。实现了java.lang.AutoCloseablejava.io.Closeable 的对象都可以做为资源。

这给我们提供了很大方便,我们就不用一个一个手动关闭了。同时也避免了 finally 嵌套的问题,比如我们关闭了一个资源,还有可能对其他有影响,因此我们要还要进行判断。

示例:

public class TestA {

    public static void main(String[] args) {
        String path = "/Users/wys/Desktop/test.txt";
        System.out.println(test(path));
    }

    public static String test(String path) {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(path))) {
            return bufferedReader.readLine();
        }catch (Throwable e){

        }
        return path;
    }
}

multiple catch

正常情况下,假设我们有多个 catch,我们的语法可能如下:

    public static void multiCatch() {
        try {
            if (System.currentTimeMillis() % 2 == 0) {
                throw new IOException();
            } else {
                throw new ClassNotFoundException();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

但是,上述两种异常的处理逻辑相同,假设我们有多个异常的话,就要写很多一模一样的 catch 语句,导致代码体积过于庞大。

这种情况下我们就可以用 multiple catch 了,将代码简化为:

    public static void multiCatch() {
        try {
            if (System.currentTimeMillis() % 2 == 0) {
                throw new IOException();
            } else {
                throw new ClassNotFoundException();
            }
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

注意:使用 multi-catch 语法时的异常不能有相交。如 IOException 是 Exception 的子类, 所以以后用 | 分隔开的异常不能有父子关系。例如:catch (IOException | Exception e) 将报错。

实战经验

1. 尽量不要捕获类似 Exception 异常,而是要捕获特别异常

举例看一下:


try {
  // 业务代码
  // …
  Thread.sleep(1000L);
} catch (Exception e) {
  // Ignore it
}

为什么这样要求呢?

这是因为在日常的开发和合作中,我们读代码的机会往往超过写代码,软件工程是门协作的艺术,所以我们有义务让自己的代码能够直观地体现出尽可能多的信息。而泛泛的 Exception 之类,恰恰隐藏了我们的目的。另外,我们也要保证程序不会捕获到我们不希望捕获的异常。比如,你可能更希望 RuntimeException 被扩散出来,而不是被捕获。进一步讲,除非深思熟虑了,否则不要捕获 Throwable 或者 Error,这样很难保证我们能够正确程序处理 OutOfMemoryError

2. 不要生吞(swallow)异常

同样是上面的例子:

try {
  // 业务代码
  // …
  Thread.sleep(1000L);
} catch (Exception e) {
  // Ignore it
}

不要生吞(swallow)异常。这是异常处理中需要特别注意的事情。因为很可能会导致特别难以诊断的诡异情况。生吞异常,往往是基于假设这段代码可能不会发生,或者感觉忽略异常是无所谓的,但是千万不要在产品代码中做这样的假设!如果我们不能把异常跑出来,或者也没有输出到日志(Logger)中,程序可能在后续代码以不可控的方式结束。没人能够轻易判断究竟是哪里抛出了异常,以及是什么原因产生了异常。

3. 异常信息最好输出到业务日志中

我们来看一下下面的代码:


try {
   // 业务代码
   // …
} catch (IOException e) {
    e.printStackTrace();
}

这段安代码作为一段实验代码,它是没有任何问题的,但是在产品代码中,通常是不允许这样处理。这是为什么呢?

我们来看一下 printStackTrace() 方法的文档,开头就是“Prints this throwable and its backtrace to the standard error stream”。

问题就在这里,在稍微复杂一点的生产系统中,标准出错(STERR)不是个合适的输出选项,因为你很难判断出到底输出到哪里去了。尤其是对于分布式系统,如果发生异常,但是无法找到堆栈轨迹(stacktrace),这纯属是为诊断设置障碍。

所以,最好使用业务日志,详细的输出到日志系统里。

4. Throw early, catch late 原则

Throw early

Throw early 原则就是 让错误尽早抛出,不要等到我们的代码执行到一半的时候,再抛出异常,这样就很有可能导致有一部分变量处于异常状态,从而引发更多错误。

比如下面这段代码:


public void readPreferences(String fileName){
   //...perform operations... 
  InputStream in = new FileInputStream(fileName);
   //...read the preferences file...
}

如果 filename 是 null,那么程序就会抛出 NullPointerException,但是由于没有第一时间暴露出问题,堆栈信息可能非常令人费解,往往需要相对复杂的定位。这个 NPE 只是作为例子,实际产品代码中,可能是各种情况,比如获取配置失败之类的。在发现问题的时候,第一时间抛出,能够更加清晰地反映问题。我们可以修改一下,让问题“throw early”,对应的异常信息就非常直观了。


public void readPreferences(String filename) {
  Objects. requireNonNull(filename);
  //...perform other operations... 
  InputStream in = new FileInputStream(filename);
   //...read the preferences file...
}

catch late

catch late 原则是,捕获异常后,如果不知道如何处理,应该选择保留原有的异常的 cause 信息,直接选择抛出,或者构成新的异常抛出,等到更高的层面再选择捕获处理。

原因在于,到了更高的层面,我们的业务逻辑会更加清晰,这个时候我们会更清楚合适的处理方法。

有的时候,我们会根据需要自定义异常,这个时候除了保证提供足够的信息,还有两点需要考虑:

  • 是否需要定义成 Checked Exception,因为这种类型设计的初衷更是为了从异常情况恢复,作为异常设计者,我们往往有充足信息进行分类。
  • 在保证诊断信息足够的同时,也要考虑避免包含敏感信息,因为那样可能导致潜在的安全问题。如果我们看 Java 的标准类库,你可能注意到类似 java.net.ConnectException,出错信息是类似 Connection refused (Connection refused)。而不包含具体的机器名、IP、端口等,一个重要考量就是信息安全。类似的情况在日志中也有,比如,用户数据一般是不可以输出到日志里面的。

性能

我们再从性能角度来审视一下 Java 的异常处理机制。这里有两个可能会相对昂贵的地方:

  • try-catch 代码段会产生额外的性能开销,或者换个角度说,它往往会影响 JVM 对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的 try 包住整段代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else、switch)要低效。
  • Java 每实例化一个 Exception,都会对当时的栈进行快照,这是一个比较重的操作。如果发生的非常频繁,这个开销可能就不能被忽略了。

总结

今天我们通过了一个简单的异常问题,对异常分类做了一些对比,比如检查异常和非检查异常,同时我们讲了一些 Java 中的语法糖,比如 try-with-resources 和 multiple catch,同时给大家讲了一些日常业务开发的最佳实践,希望能够对大家有所帮助。

参考文档