Java异常体系

2023年 7月 14日 56.0k 0

异常的层次结构

异常指不期而至的各种状况,如:文件找不到、网络连接失败、非法参数等。异常是一个事件,它发生在程序运行期间,干扰了正常的指令流程。Java通 过API中Throwable类的众多子类描述各种不同的异常。因而,Java异常都是对象,是Throwable子类的实例,描述了出现在一段编码中的错误条件。当条件生成时,错误将引发异常。

Java异常类层次结构图:

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

Exception和Error体现了Java平台设计者对不同异常情况的分类。Exception是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。

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

Exception又分为可检查(checked)异常和不检查(unchecked)异常,

  • 可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。

  • 不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException之类,通常是可以避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译期强制要求。

Throwable

Throwable 是 Java 语言中所有错误与异常的超类。

Throwable 包含两个子类:Error(错误)和 Exception(异常),它们通常用于指示发生了异常情况。

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

Error(错误)

Error 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误。

此类错误一般表示代码运行时 JVM 出现问题。 通常有 Virtual MachineError(虚拟机运行错误)、NoClassDefFoundError(类定义错误)等。比如 OutOfMemoryError:内存不足错误;StackOverflowError:栈溢出错误。此类错误发生时,JVM 将终止线程。

这些错误是不受检异常,非代码性错误。因此,当此类错误发生时,应用程序不应该去处理此类错误。按照Java惯例,我们是不应该实现任何新的Error子类的!

Exception(异常)

程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常。

运行时异常

都是RuntimeException类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。

运行时异常的特点是Java编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用try-catch语句捕获它,也没有用throws子句声明抛出它,也会编译通过。

非运行时异常 (编译异常)

是RuntimeException以外的异常,类型上都属于Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如IOException、SQLException等以及用户自定义的Exception异常,一般情况下不自定义检查异常。

可查的异常(checked exceptions)和不可查的异常(unchecked exceptions)

  • 可查异常(编译器要求必须处置的异常):

正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。

除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。

  • 不可查异常(编译器不要求强制处置的异常)

包括运行时异常(RuntimeException与其子类)和错误(Error)。

众所周知,异常处理的两大组成要素是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移。

抛出异常可分为显式和隐式两种。

  • 显式抛异常的主体是应用程序,它指的是在程序中使用“throw”关键字,手动将异常实例抛出。
  • 隐式抛异常的主体则是 Java 虚拟机,它指的是 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。

举例来说,Java 虚拟机在执行读取数组操作时,发现输入的索引值是负数,故而抛出数组索引越界异常(ArrayIndexOutOfBoundsException)。

在 Java 语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用 throws 关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。

异常实例的构造十分昂贵。这是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。

异常基础

异常关键字

  • try – 用于监听。将要被监听的代码(可能抛出异常的代码)放在try语句块之内,当try语句块内发生异常时,异常就被抛出。
  • catch – 用于捕获异常。catch用来捕获try语句块中发生的异常。
  • finally – finally语句块总是会被执行。它主要用于回收在try块里打开的物力资源(如数据库连接、网络连接和磁盘文件)。只有finally块,执行完成之后,才会回来执行try或者catch块中的return或者throw语句,如果finally中使用了return或者throw等终止方法的语句,则就不会跳回执行,直接停止。
  • throw – 用于抛出异常。
  • throws – 用在方法签名中,用于声明该方法可能抛出的异常。

异常的声明(throws)

在Java中,当前执行的语句必属于某个方法,Java解释器调用main方法执行开始执行程序。若方法中存在检查异常,如果不对其捕获,那必须在方法头中显式声明该异常,以便于告知方法调用者此方法有异常,需要进行处理。 在方法中声明一个异常,方法头中使用关键字throws,后面接上要声明的异常。若声明多个异常,则使用逗号分割。如下所示:

public static void method() throws IOException, FileNotFoundException{
    //something statements
}public static void method() throws IOException, FileNotFoundException{
    //something statements
}

注意:若是父类的方法没有声明异常,则子类继承方法后,也不能声明异常。

通常,应该捕获那些知道如何处理的异常,将不知道如何处理的异常继续传递下去。传递异常可以在方法签名处使用 throws 关键字声明可能会抛出的异常。

private static void readFile(String filePath) throws IOException {
    File file = new File(filePath);
    String result;
    BufferedReader reader = new BufferedReader(new FileReader(file));
    while((result = reader.readLine())!=null) {
        System.out.println(result);
    }
    reader.close();
}
private static void readFile(String filePath) throws IOException {
    File file = new File(filePath);
    String result;
    BufferedReader reader = new BufferedReader(new FileReader(file));
    while((result = reader.readLine())!=null) {
        System.out.println(result);
    }
    reader.close();
}

Throws抛出异常的规则:

  • 如果是不可查异常(unchecked exception),即Error、RuntimeException或它们的子类,那么可以不使用throws关键字来声明要抛出的异常,编译仍能顺利通过,但在运行时会被系统抛出。
  • 必须声明方法可抛出的任何可查异常(checked exception)。即如果一个方法可能出现受可查异常,要么用try-catch语句捕获,要么用throws子句声明将它抛出,否则会导致编译错误
  • 仅当抛出了异常,该方法的调用者才必须处理或者重新抛出该异常。当方法的调用者无力处理该异常的时候,应该继续抛出,而不是囫囵吞枣。
  • 调用方法必须遵循任何可查异常的处理和声明规则。若覆盖一个方法,则不能声明与覆盖方法不同的异常。声明的任何异常必须是被覆盖方法所声明异常的同类或子类。

异常的抛出(throw)

如果代码可能会引发某种错误,可以创建一个合适的异常类实例并抛出它,这就是抛出异常。如下所示:

public static double method(int value) {
    if(value == 0) {
        throw new ArithmeticException("参数不能为0"); //抛出一个运行时异常
    }
    return 5.0 / value;
}public static double method(int value) {
    if(value == 0) {
        throw new ArithmeticException("参数不能为0"); //抛出一个运行时异常
    }
    return 5.0 / value;
}

大部分情况下都不需要手动抛出异常,因为Java的大部分方法要么已经处理异常,要么已声明异常。所以一般都是捕获异常或者再往上抛。

有时我们会从 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;
    }
}
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;
    }
}

异常的自定义

习惯上,定义一个异常类应包含两个构造函数,一个无参构造函数和一个带有详细描述信息的构造函数(Throwable 的 toString 方法会打印这些详细信息,调试时很有用), 比如上面用到的自定义MyException:

public class MyException extends Exception {
    public MyException(){ }
    public MyException(String msg){
        super(msg);
    }
    // ...
}
public class MyException extends Exception {
    public MyException(){ }
    public MyException(String msg){
        super(msg);
    }
    // ...
}

异常的捕获

异常捕获处理的方法通常有:

  • try-catch
  • try-catch-finally
  • try-finally
  • try-with-resource

try-catch

在一个 try-catch 语句块中可以捕获多个异常类型,并对不同类型的异常做出不同的处理

private static void readFile(String filePath) {
    try {
        // code
    } catch (FileNotFoundException e) {
        // handle FileNotFoundException
    } catch (IOException e){
        // handle IOException
    }
}
private static void readFile(String filePath) {
    try {
        // code
    } catch (FileNotFoundException e) {
        // handle FileNotFoundException
    } catch (IOException e){
        // handle IOException
    }
}

同一个 catch 也可以捕获多种类型异常,用 | 隔开

private static void readFile(String filePath) {
    try {
        // code
    } catch (FileNotFoundException | UnknownHostException e) {
        // handle FileNotFoundException or UnknownHostException
    } catch (IOException e){
        // handle IOException
    }
}
private static void readFile(String filePath) {
    try {
        // code
    } catch (FileNotFoundException | UnknownHostException e) {
        // handle FileNotFoundException or UnknownHostException
    } catch (IOException e){
        // handle IOException
    }
}

try-catch-finally

常规语法

try {                        
    //执行程序代码,可能会出现异常                 
} catch(Exception e) {   
    //捕获异常并处理   
} finally {
    //必执行的代码
}
try {                        
    //执行程序代码,可能会出现异常                 
} catch(Exception e) {   
    //捕获异常并处理   
} finally {
    //必执行的代码
}

执行的顺序

  • 当try没有捕获到异常时:try语句块中的语句逐一被执行,程序将跳过catch语句块,执行finally语句块和其后的语句;
  • 当try捕获到异常,catch语句块里没有处理此异常的情况:当try语句块里的某条语句出现异常时,而没有处理此异常的catch语句块时,此异常将会抛给JVM处理,finally语句块里的语句还是会被执行,但finally语句块后的语句不会被执行;
  • 当try捕获到异常,catch语句块里有处理此异常的情况:在try语句块中是按照顺序来执行的,当执行到某一条语句出现异常时,程序将跳到catch语句块,并与catch语句块逐一匹配,找到与之对应的处理程序,其他的catch语句块将不会被执行,而try语句块中,出现异常之后的语句也不会被执行,catch语句块执行完后,执行finally语句块里的语句,最后执行finally语句块后的语句;

一个完整的例子

private static void readFile(String filePath) throws MyException {
    File file = new File(filePath);
    String result;
    BufferedReader reader = null;
    try {
        reader = new BufferedReader(new FileReader(file));
        while((result = reader.readLine())!=null) {
            System.out.println(result);
        }
    } catch (IOException e) {
        System.out.println("readFile method catch block.");
        MyException ex = new MyException("read file failed.");
        ex.initCause(e);
        throw ex;
    } finally {
        System.out.println("readFile method finally block.");
        if (null != reader) {
            try {
                reader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
private static void readFile(String filePath) throws MyException {
    File file = new File(filePath);
    String result;
    BufferedReader reader = null;
    try {
        reader = new BufferedReader(new FileReader(file));
        while((result = reader.readLine())!=null) {
            System.out.println(result);
        }
    } catch (IOException e) {
        System.out.println("readFile method catch block.");
        MyException ex = new MyException("read file failed.");
        ex.initCause(e);
        throw ex;
    } finally {
        System.out.println("readFile method finally block.");
        if (null != reader) {
            try {
                reader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

try-finally

try块中引起异常,异常代码之后的语句不再执行,直接执行finally语句。 try块没有引发异常,则执行完try块就执行finally语句。

try-finally可用在不需要捕获异常的代码,可以保证资源在使用后被关闭。例如IO流中执行完相应操作后,关闭相应资源;使用Lock对象保证线程同步,通过finally可以保证锁会被释放;数据库连接代码时,关闭连接操作等等。

//以Lock加锁为例,演示try-finally
ReentrantLock lock = new ReentrantLock();
try {
    //需要加锁的代码
} finally {
    lock.unlock(); //保证锁一定被释放
}
//以Lock加锁为例,演示try-finally
ReentrantLock lock = new ReentrantLock();
try {
    //需要加锁的代码
} finally {
    lock.unlock(); //保证锁一定被释放
}

finally遇见如下情况不会执行

  • 在前面的代码中用了System.exit()退出程序。
  • finally语句块中发生了异常。
  • 程序所在的线程死亡。
  • 关闭CPU。

try-with-resource

try-with-resource是Java 7中引入的。

上面例子中,finally 中的 close 方法也可能抛出 IOException, 从而覆盖了原始异常。JAVA 7 提供了更优雅的方式来实现资源的自动释放,自动释放的资源需要是实现了 AutoCloseable 接口的类。

代码实现

private  static void tryWithResourceTest(){
    try (Scanner scanner = new Scanner(new FileInputStream("c:/abc"),"UTF-8")){
        // code
    } catch (IOException e){
        // handle exception
    }
}
private  static void tryWithResourceTest(){
    try (Scanner scanner = new Scanner(new FileInputStream("c:/abc"),"UTF-8")){
        // code
    } catch (IOException e){
        // handle exception
    }
}
  • 看下Scanner
public final class Scanner implements Iterator, Closeable {
  // ...
}
public interface Closeable extends AutoCloseable {
    public void close() throws IOException;
}
public final class Scanner implements Iterator, Closeable {
  // ...
}
public interface Closeable extends AutoCloseable {
    public void close() throws IOException;
}

try 代码块退出时,会自动调用 scanner.close 方法,和把 scanner.close 方法放在 finally 代码块中不同的是,若 scanner.close 抛出异常,则会被抑制,抛出的仍然为原始异常。被抑制的异常会由 addSusppressed 方法添加到原来的异常,如果想要获取被抑制的异常列表,可以调用 getSuppressed 方法来获取。

异常基础总结

  • try、catch和finally都不能单独使用,只能是try-catch、try-finally或者try-catch-finally。
  • try语句块监控代码,出现异常就停止执行下面的代码,然后将异常移交给catch语句块来处理。
  • finally语句块中的代码一定会被执行,常用于回收资源 。
  • throws:声明一个异常,告知方法调用者。
  • throw :抛出一个异常,至于该异常被捕获还是继续抛出都与它无关。

Java编程思想一书中,对异常的总结。

  • 在恰当的级别处理问题。(在知道该如何处理的情况下了捕获异常。)

  • 解决问题并且重新调用产生异常的方法。

  • 进行少许修补,然后绕过异常发生的地方继续执行。

  • 用别的数据进行计算,以代替方法预计会返回的值。

  • 把当前运行环境下能做的事尽量做完,然后把相同的异常重抛到更高层。

  • 把当前运行环境下能做的事尽量做完,然后把不同的异常抛到更高层。

  • 终止程序。

  • 进行简化(如果你的异常模式使问题变得太复杂,那么用起来会非常痛苦)。

  • 让类库和程序更安全。

一个Demo

先开看第一个吧,下面的代码反映了异常处理中哪些不当之处?

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

这段代码虽然很短,但是已经违反了异常处理的两个基本原则。

第一,尽量不要捕获类似Exception这样的通用异常,而是应该捕获特定异常,在这里是Thread.sleep()抛出的InterruptedException。

这是因为在日常的开发和合作中,我们读代码的机会往往超过写代码,软件工程是门协作的艺术,所以我们有义务让自己的代码能够直观地体现出尽量多的信息,而泛泛的Exception之类,恰恰隐藏了我们的目的。另外,我们也要保证程序不会捕获到我们不希望捕获的异常。比如,你可能更希望RuntimeException被扩散出来,而不是被捕获。

进一步讲,除非深思熟虑了,否则不要捕获Throwable或者Error,这样很难保证我们能够正确程序处理OutOfMemoryError。

第二,不要生吞(swallow)异常。这是异常处理中要特别注意的事情,因为很可能会导致非常难以诊断的诡异情况。

生吞异常,往往是基于假设这段代码可能不会发生,或者感觉忽略异常是无所谓的,但是千万不要在产品代码做这种假设!

也就是不要试着去隐藏一个异常,不要去掩盖异常。
不知道如何处理的话,先保留原有异常的cause信息,再抛出去,在更高的层面上往往会有清晰的思路来解决。

如果我们不把异常抛出来,或者也没有输出到日志(Logger)之类,程序可能在后续代码以不可控的方式结束。没人能够轻易判断究竟是哪里抛出了异常,以及是什么原因产生了异常。

再来看看第二段代码

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

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

我们先来看看printStackTrace()的文档,开头就是“Prints this throwable and its backtrace to the standard error stream”。问题就在这里,在稍微复杂一点的生产系统中,标准出错(STERR)不是个合适的输出选项,因为你很难判断出到底输出到哪里去了。

尤其是对于分布式系统,如果发生异常,但是无法找到堆栈轨迹(stacktrace),这纯属是为诊断设置障碍。所以,最好使用产品日志,详细地输出到日志系统里。

我们接下来看下面的代码段,体会一下Throw early, catch late原则。

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”,其实是我们经常苦恼的问题,捕获异常后,需要怎么处理呢?最差的处理方式,就是我前面提到的“生吞异常”,本质上其实是掩盖问题。如果实在不知道如何处理,可以选择保留原有异常的cause信息,直接再抛出或者构建新的异常抛出去。在更高层面,因为有了清晰的(业务)逻辑,往往会更清楚合适的处理方式是什么。

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

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

业界有一种争论(甚至可以算是某种程度的共识),Java语言的Checked Exception也许是个设计错误,反对者列举了几点:

  • Checked Exception的假设是我们捕获了异常,然后恢复程序。但是,其实我们大多数情况下,根本就不可能恢复。Checked Exception的使用,已经大大偏离了最初的设计目的。
  • Checked Exception不兼容functional编程。

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

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

所以,对于部分追求极致性能的底层类库,有种方式是尝试创建不进行栈快照的Exception。这本身也存在争议,因为这样做的假设在于,我创建异常时知道未来是否需要堆栈。问题是,实际上可能吗?小范围或许可能,但是在大规模项目中,这么做可能不是个理智的选择。如果需要堆栈,但又没有收集这些信息,在复杂情况下,尤其是类似微服务这种分布式系统,这会大大增加诊断的难度。当我们的服务出现反应变慢、吞吐量下降的时候,检查发生最频繁的Exception也是一种思路。

异常实践

在 Java 中处理异常并不是一个简单的事情。不仅仅初学者很难理解,即使一些有经验的开发者也需要花费很多时间来思考如何处理异常,包括需要处理哪些异常,怎样处理等等。这也是绝大多数开发团队都会制定一些规则来规范进行异常处理的原因。

当你抛出或捕获异常的时候,有很多不同的情况需要考虑,而且大部分事情都是为了改善代码的可读性或者 API 的可用性。

异常不仅仅是一个错误控制机制,也是一个通信媒介。因此,为了和同事更好的合作,一个团队必须要制定出一个最佳实践和规则,只有这样,团队成员才能理解这些通用概念,同时在工作中使用它。

这里给出几个被很多团队使用的异常处理最佳实践。

只针对不正常的情况才使用异常

异常只应该被用于不正常的条件,它们永远不应该被用于正常的控制流。《阿里手册》中:【强制】Java 类库中定义的可以通过预检查方式规避的RuntimeException异常不应该通过catch 的方式来处理,比如:NullPointerException,IndexOutOfBoundsException等等。

比如,在解析字符串形式的数字时,可能存在数字格式错误,不得通过catch Exception来实现

  • 代码1
if (obj != null) {
  //...
}
if (obj != null) {
  //...
}
  • 代码2
try { 
  obj.method(); 
} catch (NullPointerException e) {
  //...
}
try { 
  obj.method(); 
} catch (NullPointerException e) {
  //...
}

主要原因有三点:

  • 异常机制的设计初衷是用于不正常的情况,所以很少会会JVM实现试图对它们的性能进行优化。所以,创建、抛出和捕获异常的开销是很昂贵的。
  • 把代码放在try-catch中返回阻止了JVM实现本来可能要执行的某些特定的优化。
  • 对数组进行遍历的标准模式并不会导致冗余的检查,有些现代的JVM实现会将它们优化掉。

在 finally 块中清理资源或者使用 try-with-resource 语句

当使用类似InputStream这种需要使用后关闭的资源时,一个常见的错误就是在try块的最后关闭资源。

  • 错误示例
public void doNotCloseResourceInTry() {
    FileInputStream inputStream = null;
    try {
        File file = new File("./tmp.txt");
        inputStream = new FileInputStream(file);
        // use the inputStream to read a file
        // do NOT do this
        inputStream.close();
    } catch (FileNotFoundException e) {
        log.error(e);
    } catch (IOException e) {
        log.error(e);
    }
}
public void doNotCloseResourceInTry() {
    FileInputStream inputStream = null;
    try {
        File file = new File("./tmp.txt");
        inputStream = new FileInputStream(file);
        // use the inputStream to read a file
        // do NOT do this
        inputStream.close();
    } catch (FileNotFoundException e) {
        log.error(e);
    } catch (IOException e) {
        log.error(e);
    }
}

问题就是,只有没有异常抛出的时候,这段代码才可以正常工作。try 代码块内代码会正常执行,并且资源可以正常关闭。但是,使用 try 代码块是有原因的,一般调用一个或多个可能抛出异常的方法,而且,你自己也可能会抛出一个异常,这意味着代码可能不会执行到 try 代码块的最后部分。结果就是,你并没有关闭资源。

所以,你应该把清理工作的代码放到 finally 里去,或者使用 try-with-resource 特性。

方法一:使用 finally 代码块

与前面几行 try 代码块不同,finally 代码块总是会被执行。不管 try 代码块成功执行之后还是你在 catch 代码块中处理完异常后都会执行。因此,你可以确保你清理了所有打开的资源。

public void closeResourceInFinally() {
    FileInputStream inputStream = null;
    try {
        File file = new File("./tmp.txt");
        inputStream = new FileInputStream(file);
        // use the inputStream to read a file
    } catch (FileNotFoundException e) {
        log.error(e);
    } finally {
        if (inputStream != null) {
            try {
                inputStream.close();
            } catch (IOException e) {
                log.error(e);
            }
        }
    }
}
public void closeResourceInFinally() {
    FileInputStream inputStream = null;
    try {
        File file = new File("./tmp.txt");
        inputStream = new FileInputStream(file);
        // use the inputStream to read a file
    } catch (FileNotFoundException e) {
        log.error(e);
    } finally {
        if (inputStream != null) {
            try {
                inputStream.close();
            } catch (IOException e) {
                log.error(e);
            }
        }
    }
}
方法二:Java 7 的 try-with-resource 语法

如果你的资源实现了 AutoCloseable 接口,你可以使用这个语法。大多数的 Java 标准资源都继承了这个接口。当你在 try 子句中打开资源,资源会在 try 代码块执行后或异常处理后自动关闭。

public void automaticallyCloseResource() {
    File file = new File("./tmp.txt");
    try (FileInputStream inputStream = new FileInputStream(file);) {
        // use the inputStream to read a file
    } catch (FileNotFoundException e) {
        log.error(e);
    } catch (IOException e) {
        log.error(e);
    }
}
public void automaticallyCloseResource() {
    File file = new File("./tmp.txt");
    try (FileInputStream inputStream = new FileInputStream(file);) {
        // use the inputStream to read a file
    } catch (FileNotFoundException e) {
        log.error(e);
    } catch (IOException e) {
        log.error(e);
    }
}

尽量使用标准的异常

代码重用是值得提倡的,这是一条通用规则,异常也不例外。

重用现有的异常有几个好处:

  • 它使得你的API更加易于学习和使用,因为它与程序员原来已经熟悉的习惯用法是一致的。
  • 对于用到这些API的程序而言,它们的可读性更好,因为它们不会充斥着程序员不熟悉的异常。
  • 异常类越少,意味着内存占用越小,并且转载这些类的时间开销也越小。

Java标准异常中有几个是经常被使用的异常。如下表格:

异常 使用场合
IllegalArgumentException 参数的值不合适
IllegalStateException 参数的状态不合适
NullPointerException 在null被禁止的情况下参数值为null
IndexOutOfBoundsException 下标越界
ConcurrentModificationException 在禁止并发修改的情况下,对象检测到并发修改
UnsupportedOperationException 对象不支持客户请求的方法

虽然它们是Java平台库迄今为止最常被重用的异常,但是,在许可的条件下,其它的异常也可以被重用。例如,如果你要实现诸如复数或者矩阵之类的算术对象,那么重用ArithmeticException和NumberFormatException将是非常合适的。如果一个异常满足你的需要,则不要犹豫,使用就可以,不过你一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是名字的基础上。

最后,一定要清楚,选择重用哪一种异常并没有必须遵循的规则。例如,考虑纸牌对象的情形,假设有一个用于发牌操作的方法,它的参数(handSize)是发一手牌的纸牌张数。假设调用者在这个参数中传递的值大于整副牌的剩余张数。那么这种情形既可以被解释为IllegalArgumentException(handSize的值太大),也可以被解释为IllegalStateException(相对客户的请求而言,纸牌对象的纸牌太少)。

对异常进行文档说明

当在方法上声明抛出异常时,也需要进行文档说明。目的是为了给调用者提供尽可能多的信息,从而可以更好地避免或处理异常。

在 Javadoc 添加 @throws 声明,并且描述抛出异常的场景。

/**
* Method description
* 
* @throws MyBusinessException - businuess exception description
*/
public void doSomething(String input) throws MyBusinessException {
   // ...
}
/**
* Method description
* 
* @throws MyBusinessException - businuess exception description
*/
public void doSomething(String input) throws MyBusinessException {
   // ...
}

同时,在抛出MyBusinessException 异常时,需要尽可能精确地描述问题和相关信息,这样无论是打印到日志中还是在监控工具中,都能够更容易被人阅读,从而可以更好地定位具体错误信息、错误的严重程度等。

优先捕获最具体的异常

大多数 IDE 都可以帮助你实现这个最佳实践。当你尝试首先捕获较不具体的异常时,它们会报告无法访问的代码块。

但问题在于,只有匹配异常的第一个 catch 块会被执行。 因此,如果首先捕获 IllegalArgumentException ,则永远不会到达应该处理更具体的 NumberFormatException 的 catch 块,因为它是 IllegalArgumentException 的子类。

总是优先捕获最具体的异常类,并将不太具体的 catch 块添加到列表的末尾。

你可以在下面的代码片断中看到这样一个 try-catch 语句的例子。 第一个 catch 块处理所有 NumberFormatException 异常,第二个处理所有非 NumberFormatException 异常的IllegalArgumentException 异常。

public void catchMostSpecificExceptionFirst() {
    try {
        doSomething("A message");
    } catch (NumberFormatException e) {
        log.error(e);
    } catch (IllegalArgumentException e) {
        log.error(e)
    }
}
public void catchMostSpecificExceptionFirst() {
    try {
        doSomething("A message");
    } catch (NumberFormatException e) {
        log.error(e);
    } catch (IllegalArgumentException e) {
        log.error(e)
    }
}

不要捕获 Throwable 类

Throwable 是所有异常和错误的超类。你可以在 catch 子句中使用它,但是你永远不应该这样做!

如果在 catch 子句中使用 Throwable ,它不仅会捕获所有异常,也将捕获所有的错误。JVM 抛出错误,指出不应该由应用程序处理的严重问题。 典型的例子是 OutOfMemoryError 或者 StackOverflowError 。两者都是由应用程序控制之外的情况引起的,无法处理。

所以,最好不要捕获 Throwable ,除非你确定自己处于一种特殊的情况下能够处理错误。

public void doNotCatchThrowable() {
    try {
        // do something
    } catch (Throwable t) {
        // don't do this!
    }
}
public void doNotCatchThrowable() {
    try {
        // do something
    } catch (Throwable t) {
        // don't do this!
    }
}

不要忽略异常

很多时候,开发者很有自信不会抛出异常,因此写了一个catch块,但是没有做任何处理或者记录日志。

public void doNotIgnoreExceptions() {
    try {
        // do something
    } catch (NumberFormatException e) {
        // this will never happen
    }
}
public void doNotIgnoreExceptions() {
    try {
        // do something
    } catch (NumberFormatException e) {
        // this will never happen
    }
}

但现实是经常会出现无法预料的异常,或者无法确定这里的代码未来是不是会改动(删除了阻止异常抛出的代码),而此时由于异常被捕获,使得无法拿到足够的错误信息来定位问题。

合理的做法是至少要记录异常的信息。

public void logAnException() {
    try {
        // do something
    } catch (NumberFormatException e) {
        log.error("This should never happen: " + e); // see this line
    }
}
public void logAnException() {
    try {
        // do something
    } catch (NumberFormatException e) {
        log.error("This should never happen: " + e); // see this line
    }
}

不要记录并抛出异常

这可能是本文中最常被忽略的最佳实践。

可以发现很多代码甚至类库中都会有捕获异常、记录日志并再次抛出的逻辑。如下:

try {
    new Long("xyz");
} catch (NumberFormatException e) {
    log.error(e);
    throw e;
}
try {
    new Long("xyz");
} catch (NumberFormatException e) {
    log.error(e);
    throw e;
}

这个处理逻辑看着是合理的。但这经常会给同一个异常输出多条日志。如下:

17:44:28,945 ERROR TestExceptionHandling:65 - java.lang.NumberFormatException: For input string: "xyz"
    
Exception in thread "main" java.lang.NumberFormatException: For input string: "xyz"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.(Long.java:965)
at com.stackify.example.TestExceptionHandling.logAndThrowException(TestExceptionHandling.java:63)
at com.stackify.example.TestExceptionHandling.main(TestExceptionHandling.java:58)
17:44:28,945 ERROR TestExceptionHandling:65 - java.lang.NumberFormatException: For input string: "xyz"
    
Exception in thread "main" java.lang.NumberFormatException: For input string: "xyz"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.(Long.java:965)
at com.stackify.example.TestExceptionHandling.logAndThrowException(TestExceptionHandling.java:63)
at com.stackify.example.TestExceptionHandling.main(TestExceptionHandling.java:58)

如上所示,后面的日志也没有附加更有用的信息。如果想要提供更加有用的信息,那么可以将异常包装为自定义异常。

public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}
public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}

因此,仅仅当想要处理异常时才去捕获,否则只需要在方法签名中声明让调用者去处理。

包装异常时不要抛弃原始的异常

捕获标准异常并包装为自定义异常是一个很常见的做法。这样可以添加更为具体的异常信息并能够做针对的异常处理。 在你这样做时,请确保将原始异常设置为原因(注:参考下方代码 NumberFormatException e 中的原始异常 e )。Exception 类提供了特殊的构造函数方法,它接受一个 Throwable 作为参数。否则,你将会丢失堆栈跟踪和原始异常的消息,这将会使分析导致异常的异常事件变得困难。

public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}
public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}

不要使用异常控制程序的流程

不应该使用异常控制应用的执行流程,例如,本应该使用if语句进行条件判断的情况下,你却使用异常处理,这是非常不好的习惯,会严重影响应用的性能。

不要在finally块中使用return。

try块中的return语句执行成功后,并不马上返回,而是继续执行finally块中的语句,如果此处存在return语句,则在此直接返回,无情丢弃掉try块中的返回点。

如下是一个反例:

private int x = 0;
public int checkReturn() {
    try {
        // x等于1,此处不返回
        return ++x;
    } finally {
        // 返回的结果是2
        return ++x;
    }
}
private int x = 0;
public int checkReturn() {
    try {
        // x等于1,此处不返回
        return ++x;
    } finally {
        // 返回的结果是2
        return ++x;
    }
}

JVM处理异常的机制

提到JVM处理异常的机制,就需要提及Exception Table,以下称为异常表。

一个demo

public static void simpleTryCatch() {
   try {
       testNPE();
   } catch (Exception e) {
       e.printStackTrace();
   }
}
public static void simpleTryCatch() {
   try {
       testNPE();
   } catch (Exception e) {
       e.printStackTrace();
   }
}

上面的代码是一个很简单的例子,用来捕获处理一个潜在的空指针异常。

使用javap来分析这段代码

 xuyatao@evan171206  ~/work/spring_mybatis_1/java_core/src/main/java/com/core  javac ExceptionTableDemo.java 
 xuyatao@evan171206  ~/work/spring_mybatis_1/java_core/src/main/java/com/core  javap -c ExceptionTableDemo.class     
Compiled from "ExceptionTableDemo.java"
public class com.core.ExceptionTableDemo {
  public com.core.ExceptionTableDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

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

 xuyatao@evan171206  ~/work/spring_mybatis_1/java_core/src/main/java/com/core  javac ExceptionTableDemo.java 
 xuyatao@evan171206  ~/work/spring_mybatis_1/java_core/src/main/java/com/core  javap -c ExceptionTableDemo.class     
Compiled from "ExceptionTableDemo.java"
public class com.core.ExceptionTableDemo {
  public com.core.ExceptionTableDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

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

上文中的Exception table,就是异常表。

异常表中包含了一个或多个异常处理者(Exception Handler)的信息,这些信息包含如下

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

异常表的作用

当异常发生的时候,JVM处理异常的流程:

  • JVM会在当前出现异常的方法中,查找异常表,是否有合适的处理者来处理

  • 如果当前方法异常表不为空,并且异常符合处理者的from和to节点,并且type也匹配,则JVM调用位于target的调用者来处理。

  • 如果上一条未找到合理的处理者,则继续查找异常表中的剩余条目

  • 如果当前方法的异常表无法处理,则向上查找(弹栈处理)刚刚调用该方法的调用处,并重复上面的操作。

  • 如果所有的栈帧被弹出,仍然没有处理,则抛给当前的Thread,Thread则会终止。

  • 如果当前Thread为最后一个非守护线程,且未处理异常,则会导致JVM终止运行。

  • try-catch-finally

    除了简单的try-catch外,我们还常常和finally做结合使用。比如这样的代码

    public static void simpleTryCatchFinally() {
       try {
           testNPE();
       } catch (Exception e) {
           e.printStackTrace();
       } finally {
           System.out.println("Finally");
       }
    }
    public static void simpleTryCatchFinally() {
       try {
           testNPE();
       } catch (Exception e) {
           e.printStackTrace();
       } finally {
           System.out.println("Finally");
       }
    }
    

    同样我们使用javap分析一下代码

     xuyatao@evan171206  ~/work/spring_mybatis_1/java_core/src/main/java/com/core  javap -c ExceptionTableDemo.class
    Compiled from "ExceptionTableDemo.java"
    public class com.core.ExceptionTableDemo {
      public com.core.ExceptionTableDemo();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."":()V
           4: return
    
      public static void simpleTryCatchFinally();
        Code:
           //try 部分提取finally代码,如果没有异常发生,则执行输出finally操作,直至goto到41位置,执行返回操作。  
           0: invokestatic  #7                  // Method testNPE:()V
           3: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
           6: ldc           #18                 // String Finally
           8: invokevirtual #20                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          11: goto          41
          //catch部分提取finally代码,如果没有异常发生,则执行输出finally操作,直至执行got到41位置,执行返回操作。
          14: astore_0
          15: aload_0
          16: invokevirtual #28                 // Method java/lang/Exception.printStackTrace:()V
          19: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
          22: ldc           #18                 // String Finally
          24: invokevirtual #20                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          27: goto          41
          //finally部分的代码如果被调用,有可能是try部分,也有可能是catch部分发生异常。
          30: astore_1
          31: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
          34: ldc           #18                 // String Finally
          36: invokevirtual #20                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          39: aload_1
          40: athrow //如果异常没有被catch捕获,而是到了这里,执行完finally的语句后,仍然要把这个异常抛出去,传递给调用处。
          41: return
        Exception table:
           from    to  target type
               0     3    14   Class java/lang/Exception
               0     3    30   any
              14    19    30   any
     xuyatao@evan171206  ~/work/spring_mybatis_1/java_core/src/main/java/com/core  javap -c ExceptionTableDemo.class
    Compiled from "ExceptionTableDemo.java"
    public class com.core.ExceptionTableDemo {
      public com.core.ExceptionTableDemo();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."":()V
           4: return
    
      public static void simpleTryCatchFinally();
        Code:
           //try 部分提取finally代码,如果没有异常发生,则执行输出finally操作,直至goto到41位置,执行返回操作。  
           0: invokestatic  #7                  // Method testNPE:()V
           3: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
           6: ldc           #18                 // String Finally
           8: invokevirtual #20                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          11: goto          41
          //catch部分提取finally代码,如果没有异常发生,则执行输出finally操作,直至执行got到41位置,执行返回操作。
          14: astore_0
          15: aload_0
          16: invokevirtual #28                 // Method java/lang/Exception.printStackTrace:()V
          19: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
          22: ldc           #18                 // String Finally
          24: invokevirtual #20                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          27: goto          41
          //finally部分的代码如果被调用,有可能是try部分,也有可能是catch部分发生异常。
          30: astore_1
          31: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
          34: ldc           #18                 // String Finally
          36: invokevirtual #20                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          39: aload_1
          40: athrow //如果异常没有被catch捕获,而是到了这里,执行完finally的语句后,仍然要把这个异常抛出去,传递给调用处。
          41: return
        Exception table:
           from    to  target type
               0     3    14   Class java/lang/Exception
               0     3    30   any
              14    19    30   any
    

    和之前有所不同,这次异常表中,有三条数据,而我们仅仅捕获了一个Exception, 异常表的后两个item的type为any; 上面的三条异常表item的意思为:

    • 如果0到3之间,发生了Exception类型的异常,调用14位置的异常处理者。
    • 如果0到3之间,无论发生什么异常,都调用30位置的处理者
    • 如果14到19之间(即catch部分),不论发生什么异常,都调用30位置的处理者。

    Catch先后顺序的问题

    我们在代码中的catch的顺序决定了异常处理者在异常表的位置,所以,越是具体的异常要先处理,否则就会出现下面的问题

    private static void misuseCatchException() {
       try {
           testNPE();
       } catch (Throwable t) {
           t.printStackTrace();
       } catch (Exception e) { //error occurs during compilings with tips Exception Java.lang.Exception has already benn caught.
           e.printStackTrace();
       }
    }
    private static void misuseCatchException() {
       try {
           testNPE();
       } catch (Throwable t) {
           t.printStackTrace();
       } catch (Exception e) { //error occurs during compilings with tips Exception Java.lang.Exception has already benn caught.
           e.printStackTrace();
       }
    }
    

    这段代码会导致编译失败,因为先捕获Throwable后捕获Exception,会导致后面的catch永远无法被执行。

    Return 和finally的问题

    既有return,又有finally,那么finally会不会被执行?

    public class ExceptionTableDemo {
    
        public static String tryCatchReturn() {
            try {
                testNPE();
                return  "OK";
            } catch (Exception e) {
                return "ERROR";
            } finally {
                System.out.println("tryCatchReturn");
            }
        }
        private static void testNPE() {
            int i = 1 / 0;
        }
    
        public static void main(String[] args) {
            System.out.println(tryCatchReturn());
        }
    }public class ExceptionTableDemo {
    
        public static String tryCatchReturn() {
            try {
                testNPE();
                return  "OK";
            } catch (Exception e) {
                return "ERROR";
            } finally {
                System.out.println("tryCatchReturn");
            }
        }
        private static void testNPE() {
            int i = 1 / 0;
        }
    
        public static void main(String[] args) {
            System.out.println(tryCatchReturn());
        }
    }
    

    结果:

    tryCatchReturn
    ERRORtryCatchReturn
    ERROR
    

    那么既有return,又有finally的情况下finally会被执行。

    那么使用上面的方法,我们来看一下为什么finally会执行。

    public static java.lang.String tryCatchReturn();
        Code:
           0: invokestatic  #3                  // Method testNPE:()V
           3: ldc           #6                  // String OK
           5: astore_0
           6: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
           9: ldc           #8                  // String tryCatchReturn
          11: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          14: aload_0
          15: areturn       返回OK字符串,areturn意思为return a reference from a method
          16: astore_0
          17: ldc           #10                 // String ERROR
          19: astore_1
          20: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
          23: ldc           #8                  // String tryCatchReturn
          25: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          28: aload_1
          29: areturn  //返回ERROR字符串
          30: astore_2
          31: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
          34: ldc           #8                  // String tryCatchReturn
          36: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          39: aload_2
          40: athrow  如果catch有未处理的异常,抛出去。
    public static java.lang.String tryCatchReturn();
        Code:
           0: invokestatic  #3                  // Method testNPE:()V
           3: ldc           #6                  // String OK
           5: astore_0
           6: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
           9: ldc           #8                  // String tryCatchReturn
          11: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          14: aload_0
          15: areturn       返回OK字符串,areturn意思为return a reference from a method
          16: astore_0
          17: ldc           #10                 // String ERROR
          19: astore_1
          20: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
          23: ldc           #8                  // String tryCatchReturn
          25: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          28: aload_1
          29: areturn  //返回ERROR字符串
          30: astore_2
          31: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
          34: ldc           #8                  // String tryCatchReturn
          36: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          39: aload_2
          40: athrow  如果catch有未处理的异常,抛出去。
    

    异常是否耗时?为什么会耗时?

    说用异常慢,首先来看看异常慢在哪里?有多慢?下面的测试用例简单的测试了建立对象、建立异常对象、抛出并接住异常对象三者的耗时对比:

    public class ExceptionTest {  
      
        private int testTimes;  
      
        public ExceptionTest(int testTimes) {  
            this.testTimes = testTimes;  
        }  
      
        public void newObject() {  
            long l = System.nanoTime();  
            for (int i = 0; i < testTimes; i++) {  
                new Object();  
            }  
            System.out.println("建立对象:" + (System.nanoTime() - l));  
        }  
      
        public void newException() {  
            long l = System.nanoTime();  
            for (int i = 0; i < testTimes; i++) {  
                new Exception();  
            }  
            System.out.println("建立异常对象:" + (System.nanoTime() - l));  
        }  
      
        public void catchException() {  
            long l = System.nanoTime();  
            for (int i = 0; i < testTimes; i++) {  
                try {  
                    throw new Exception();  
                } catch (Exception e) {  
                }  
            }  
            System.out.println("建立、抛出并接住异常对象:" + (System.nanoTime() - l));  
        }  
      
        public static void main(String[] args) {  
            ExceptionTest test = new ExceptionTest(10000);  
            test.newObject();  
            test.newException();  
            test.catchException();  
        }  
    }  
    public class ExceptionTest {  
      
        private int testTimes;  
      
        public ExceptionTest(int testTimes) {  
            this.testTimes = testTimes;  
        }  
      
        public void newObject() {  
            long l = System.nanoTime();  
            for (int i = 0; i < testTimes; i++) {  
                new Object();  
            }  
            System.out.println("建立对象:" + (System.nanoTime() - l));  
        }  
      
        public void newException() {  
            long l = System.nanoTime();  
            for (int i = 0; i < testTimes; i++) {  
                new Exception();  
            }  
            System.out.println("建立异常对象:" + (System.nanoTime() - l));  
        }  
      
        public void catchException() {  
            long l = System.nanoTime();  
            for (int i = 0; i < testTimes; i++) {  
                try {  
                    throw new Exception();  
                } catch (Exception e) {  
                }  
            }  
            System.out.println("建立、抛出并接住异常对象:" + (System.nanoTime() - l));  
        }  
      
        public static void main(String[] args) {  
            ExceptionTest test = new ExceptionTest(10000);  
            test.newObject();  
            test.newException();  
            test.catchException();  
        }  
    }  
    

    运行结果:

    建立对象:575817  
    建立异常对象:9589080  
    建立、抛出并接住异常对象:47394475  
    建立对象:575817  
    建立异常对象:9589080  
    建立、抛出并接住异常对象:47394475  
    

    建立一个异常对象,是建立一个普通Object耗时的约20倍(实际上差距会比这个数字更大一些,因为循环也占用了时间),而抛出、接住一个异常对象,所花费时间大约是建立异常对象的4倍。

    Java 虚拟机是如何捕获异常的?

    在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。

    其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。

    public static void main(String[] args) {
      try {
        mayThrowException();
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
    // 对应的 Java 字节码
    public static void main(java.lang.String[]);
      Code:
        0: invokestatic mayThrowException:()V
        3: goto 11
        6: astore_1
        7: aload_1
        8: invokevirtual java.lang.Exception.printStackTrace
       11: return
      Exception table:
        from  to target type
          0   3   6  Class java/lang/Exception  // 异常表条目public static void main(String[] args) {
      try {
        mayThrowException();
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
    // 对应的 Java 字节码
    public static void main(java.lang.String[]);
      Code:
        0: invokestatic mayThrowException:()V
        3: goto 11
        6: astore_1
        7: aload_1
        8: invokevirtual java.lang.Exception.printStackTrace
       11: return
      Exception table:
        from  to target type
          0   3   6  Class java/lang/Exception  // 异常表条目
    

    举个例子,在上图的 main 方法中,我定义了一段 try-catch 代码。其中,catch 代码块所捕获的异常类型为 Exception。

    编译过后,该方法的异常表拥有一个条目。其 from 指针和 to 指针分别为 0 和 3,代表它的监控范围从索引为 0 的字节码开始,到索引为 3 的字节码结束(不包括 3)。该条目的 target 指针是 6,代表这个异常处理器从索引为 6 的字节码开始。条目的最后一列(type),代表该异常处理器所捕获的异常类型正是 Exception。

    当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。

    如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。

    finally 代码块的编译比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。

    针对异常执行路径,Java 编译器会生成一个或多个异常表条目,监控整个 try-catch 代码块,并且捕获所有种类的异常(在 javap 中以 any 指代)。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块。并且,在这个 finally 代码块的最后,Java 编译器会重新抛出所捕获的异常。

    如果你感兴趣的话,可以用 javap 工具来查看下面这段包含了 try-catch-finally 代码块的编译结果。为了更好地区分每个代码块,我定义了四个实例字段:tryBlock、catchBlock、finallyBlock、以及 methodExit,并且仅在对应的代码块中访问这些字段。

    public class Foo {
      private int tryBlock;
      private int catchBlock;
      private int finallyBlock;
      private int methodExit;
     
      public void test() {
        try {
          tryBlock = 0;
        } catch (Exception e) {
          catchBlock = 1;
        } finally {
          finallyBlock = 2;
        }
        methodExit = 3;
      }
    }
     
     
    $ javap -c Foo
    ...
      public void test();
        Code:
           0: aload_0
           1: iconst_0
           2: putfield      #20                 // Field tryBlock:I
           5: goto          30
           8: astore_1
           9: aload_0
          10: iconst_1
          11: putfield      #22                 // Field catchBlock:I
          14: aload_0
          15: iconst_2
          16: putfield      #24                 // Field finallyBlock:I
          19: goto          35
          22: astore_2
          23: aload_0
          24: iconst_2
          25: putfield      #24                 // Field finallyBlock:I
          28: aload_2
          29: athrow
          30: aload_0
          31: iconst_2
          32: putfield      #24                 // Field finallyBlock:I
          35: aload_0
          36: iconst_3
          37: putfield      #26                 // Field methodExit:I
          40: return
        Exception table:
           from    to  target type
               0     5     8   Class java/lang/Exception
               0    14    22   any
     
      ...public class Foo {
      private int tryBlock;
      private int catchBlock;
      private int finallyBlock;
      private int methodExit;
     
      public void test() {
        try {
          tryBlock = 0;
        } catch (Exception e) {
          catchBlock = 1;
        } finally {
          finallyBlock = 2;
        }
        methodExit = 3;
      }
    }
     
     
    $ javap -c Foo
    ...
      public void test();
        Code:
           0: aload_0
           1: iconst_0
           2: putfield      #20                 // Field tryBlock:I
           5: goto          30
           8: astore_1
           9: aload_0
          10: iconst_1
          11: putfield      #22                 // Field catchBlock:I
          14: aload_0
          15: iconst_2
          16: putfield      #24                 // Field finallyBlock:I
          19: goto          35
          22: astore_2
          23: aload_0
          24: iconst_2
          25: putfield      #24                 // Field finallyBlock:I
          28: aload_2
          29: athrow
          30: aload_0
          31: iconst_2
          32: putfield      #24                 // Field finallyBlock:I
          35: aload_0
          36: iconst_3
          37: putfield      #26                 // Field methodExit:I
          40: return
        Exception table:
           from    to  target type
               0     5     8   Class java/lang/Exception
               0    14    22   any
     
      ...
    

    可以看到,编译结果包含三份 finally 代码块。其中,前两份分别位于 try 代码块和 catch 代码块的正常执行路径出口。最后一份则作为异常处理器,监控 try 代码块以及 catch 代码块。它将捕获 try 代码块触发的、未被 catch 代码块捕获的异常,以及 catch 代码块触发的异常。

    这里有一个小问题,如果 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是哪个呢?答案是后者。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。

    Java 7 的 Supressed 异常以及语法糖

    Java 7 引入了 Supressed 异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常的信息。

    然而,Java 层面的 finally 代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁琐。

    为此,Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Supressed 异常。当然,该语法糖的主要目的并不是使用 Supressed 异常,而是精简资源打开关闭的用法。

    在 Java 7 之前,对于打开的资源,我们需要定义一个 finally 代码块,来确保该资源在正常或者异常执行状况下都能关闭。

    资源的关闭操作本身容易触发异常。因此,如果同时打开多个资源,那么每一个资源都要对应一个独立的 try-finally 代码块,以保证每个资源都能够关闭。这样一来,代码将会变得十分繁琐。

    FileInputStream in0 = null;
      FileInputStream in1 = null;
      FileInputStream in2 = null;
      ...
      try {
        in0 = new FileInputStream(new File("in0.txt"));
        ...
        try {
          in1 = new FileInputStream(new File("in1.txt"));
          ...
          try {
            in2 = new FileInputStream(new File("in2.txt"));
            ...
          } finally {
            if (in2 != null) in2.close();
          }
        } finally {
          if (in1 != null) in1.close();
        }
      } finally {
        if (in0 != null) in0.close();
      }FileInputStream in0 = null;
      FileInputStream in1 = null;
      FileInputStream in2 = null;
      ...
      try {
        in0 = new FileInputStream(new File("in0.txt"));
        ...
        try {
          in1 = new FileInputStream(new File("in1.txt"));
          ...
          try {
            in2 = new FileInputStream(new File("in2.txt"));
            ...
          } finally {
            if (in2 != null) in2.close();
          }
        } finally {
          if (in1 != null) in1.close();
        }
      } finally {
        if (in0 != null) in0.close();
      }
    

    Java 7 的 try-with-resources 语法糖,极大地简化了上述代码。程序可以在 try 关键字后声明并实例化实现了 AutoCloseable 接口的类,编译器将自动添加对应的 close() 操作。在声明多个 AutoCloseable 实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources 还会使用 Supressed 异常的功能,来避免原异常“被消失”。

    public class Foo implements AutoCloseable {
      private final String name;
      public Foo(String name) { this.name = name; }
     
      @Override
      public void close() {
        throw new RuntimeException(name);
      }
     
      public static void main(String[] args) {
        try (Foo foo0 = new Foo("Foo0"); // try-with-resources
             Foo foo1 = new Foo("Foo1");
             Foo foo2 = new Foo("Foo2")) {
          throw new RuntimeException("Initial");
        }
      }
    }
     
    // 运行结果:
    Exception in thread "main" java.lang.RuntimeException: Initial
            at Foo.main(Foo.java:18)
            Suppressed: java.lang.RuntimeException: Foo2
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
            Suppressed: java.lang.RuntimeException: Foo1
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
            Suppressed: java.lang.RuntimeException: Foo0
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)public class Foo implements AutoCloseable {
      private final String name;
      public Foo(String name) { this.name = name; }
     
      @Override
      public void close() {
        throw new RuntimeException(name);
      }
     
      public static void main(String[] args) {
        try (Foo foo0 = new Foo("Foo0"); // try-with-resources
             Foo foo1 = new Foo("Foo1");
             Foo foo2 = new Foo("Foo2")) {
          throw new RuntimeException("Initial");
        }
      }
    }
     
    // 运行结果:
    Exception in thread "main" java.lang.RuntimeException: Initial
            at Foo.main(Foo.java:18)
            Suppressed: java.lang.RuntimeException: Foo2
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
            Suppressed: java.lang.RuntimeException: Foo1
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
            Suppressed: java.lang.RuntimeException: Foo0
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
    

    除了 try-with-resources 语法糖之外,Java 7 还支持在同一 catch 代码块中捕获多种异常。实际实现非常简单,生成多个异常表条目即可。

    // 在同一 catch 代码块中捕获多种异常
    try {
      ...
    } catch (SomeException | OtherException e) {
      ...
    }// 在同一 catch 代码块中捕获多种异常
    try {
      ...
    } catch (SomeException | OtherException e) {
      ...
    }
    

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论