异常(下)

Mr.Zhang2022年12月9日Java异常大约 16 分钟

异常(下)

异常导致程序变慢的核心原因是什么?

上一节,我们讲解了如何合理的使用异常,其中提到,不合理的使用异常,比如异常太多,会导致程序变慢,那么,异常太多导致程序变慢的核心原因在哪里?本节我们就深究到异常的底层实现原理来查找其原因。

一、异常实现原理

上一节,我们讲了异常的基本使用方法,本节,我们来看一个稍微复杂点的例子,代码如下所示。你觉得这段代码的运行结果是什么呢?

public class Demo {
  public static void main(String[] args) {
    double res = div(2, 1);
    System.out.println(res);
    res = div(2, 0);
    System.out.println(res);
  }

  public static double div(int a, int b) {
    try {
      double res = a / b;
      System.out.println("in try block.");
      return res;
    } catch(ArithmeticException e) {
      System.out.println("in ATE catch block.");
      return -1.0;
    } finally {
      System.out.println("in finally block.");
    }
  }
}

上述代码的运行结果如下所示。

//div(2, 1)
in try block.
in finally block.
2.0
//div(2, 0)
in ATE catch block.
in finally block.
-1.0

一般来讲,程序有3种执行结构:顺序、分支和循环,而异常的运行机制非常特别,从上述执行结果,我们发现,不管try监听的代码块有没有异常抛出,finally代码块总是被执行,并且,在finally代码执行完成之后,try代码块和catch代码块中的return语句才会被执行。异常的这种特殊的运行机制,其底层是如何实现的呢?

实际上,我们只要看下上述代码对应的字节码,答案就一目了然了。上述代码中div()函数的字节码如下所示。字节码比较长,为了清晰的了解其结构,我对其做了一些标注。

img
img

异常独特的运行机制主要包含以下3个部分内容。

1)异常表

异常表对应上图字节码中最后一部分Exception table。其中,from、to、target都表示字节码的行号,当行号在[from, to)之间的代码抛出type类型的异常时,JVM会跳转至target行字节码继续执行。

如上图的Exception Table,try代码块的字节码对应的行号是[0, 16),其抛出的type为ArithmeticException的异常,会匹配Exception table中的第一条规则,因此,JVM跳转到27行字节码继续执行,对应的就是catch代码块。除了ArithmeticException之外,try代码块抛出的其他异常,将会匹配Exception table中的第二条规则,因此,JVM跳转到50行字节码继续执行,对应的就是异常兜底字节码。异常兜底字节码主要做的工作是执行finally代码块,然后再原封不动的将异常抛出。

catch代码块的字节码对应的行号是[27, 40),其在执行过程中,也有可能抛出异常。其抛出的异常,如果没有在catch代码块中捕获,根据Exception table中的第3条规则,JVM会跳转到第50行字节码继续执行,也就是由异常兜底字节码来兜底。

2)异常兜底

刚刚提到了异常兜底,实际上,这部分工作很简单,主要是捕获try代码块和catch代码块中未被捕获的异常,然后在执行完finally代码块之后,原封不动的将未被捕获的异常抛出。

3)finally内联

从上图中,我们可以发现,JVM在生成字节码时,会将finally代码块内联(也就是插入)到try代码块和catch代码块中的return语句之前,这样就可以实现不管程序是否抛出异常,finally代码块总是会被执行,并且在函数返回之间执行。

通过上述分析,我们对上面的示例代码稍加改造,在finally代码块中添加return语句,如下所示,现在,这段代码的运行结果是什么呢?

public class Demo {
  public static void main(String[] args) {
    double res = div(2, 1);
    System.out.println(res);
    res = div(2, 0);
    System.out.println(res);
  }

  public static double div(int a, int b) {
    try {
      double res = a / b;
      System.out.println("in try block.");
      return res;
    } catch(ArithmeticException e) {
      System.out.println("in ATE catch block.");
      return -1.0;
    } finally {
      System.out.println("in finally block.");
      return -2.0; //额外添加了这一行
    }
  }
}

上述代码的运行结果如下所示。

//div(2, 1)
in try block.
in finally block.
-2.0
//div(2, 0)
in ATE catch block.
in finally block.
-2.0

不管try代码块中的代码是正常运行还是抛出异常,div()函数返回的值总是-2.0,也就是说,执行完finally代码块中的return语句之后,函数就返回了,JVM没有机会再执行try或catch代码块中的return语句了。实际上,如果我们把finally代码块中的return语句改为throw语句,效果也是一样的。所以,在开发中,我们切记不要在finally代码块中使用return语句和throw语句,否则,try和catch代码块中的return和throw将不会被执行。

二、异常性能分析

很多人都有这样的认识:异常会导致程序变慢,那么,为什么异常会导致程序变慢呢?慢在哪里呢?接下来,我们就来详细的分析一下。

从上一小节示例代码的字节码中,我们可以发现,如果try代码块没有抛出异常,那么JVM就不会去搜索异常表,也不会执行catch代码块,程序正常执行结束,其运行轨迹跟没有try-catch语句包裹时的基本一样。除此之外,异常表、异常兜底字节码、finally内联都是在编译时生成的,不会占用运行时间。因此,如果程序未抛出异常,那么程序的性能完全不会因为try-catch语句的引入而下降。

实际上,异常导致程序变慢的情况,只发生在异常被抛出时。当一个异常被抛出时,程序中一般会额外增加这样三个操作:使用new创建异常、使用throw抛出异常、打印异常调用链,实际上,这三个操作都是比较耗时的。我们来依次分析一下。

1)使用new创建异常

使用new创建异常的过程,主要包含两部分工作。第一部分是在堆上创建异常对象,初始化成员变量,这部分工作跟创建其他类型的对象没有区别,第二部分是调用异常父类Throwbale中的fillStackTrace()函数生成栈追踪信息。

栈追踪信息指的是当创建异常时函数调用栈中的所有函数的信息。栈追踪信息记录了异常产生的整个函数调用链路,方便程序员定位此异常是如何产生的。我们举个例子来解释一下。示例如下代码所示。

public class Demo {
  public static void main(String[] args) {
    fe();
  }
  public static void fe() { fd(); }
  public static void fd() { fc(); }
  public static void fc() { fb(); }
  public static void fb() { fa(); }
  public static void fa() {
    throw new RuntimeException("oops!");
  }
}

当fa()函数通过new创建RuntimeException异常时,根据专栏第2节内容的讲解,函数调用栈中将包含fa()、fb()、fc()、fd()、fe()、main()这5个函数的栈帧,如下图所示。

img
img

fillStackTrace()函数所做的工作就是遍历函数调用栈,将每个函数的信息存入异常的stackTrace成员变量中。stackTrace成员变量的定义如下所示。

private StackTraceElement[] stackTrace;

stackTrace为StackTraceElement类型的数组,StackTraceElement类的定义如下所示,其成员变量有4个,分别是函数所属类名、函数名、函数所属类文件名,以及异常抛出时,此函数执行到了哪一行。

public final class StackTraceElement {
    private String declaringClass;
    private String methodName;
    private String fileName;
    private int    lineNumber;
    //...省略其他代码...
}

我们可以通过getStackTrace()函数,将异常的stackTrace栈追踪信息打印出来,如下代码所示。

public class Demo {
  public static void main(String[] args) {
    fe();
  }
  public static void fe() { fd(); }
  public static void fd() { fc(); }
  public static void fc() { fb(); }
  public static void fb() { fa(); }
  public static void fa() {
    RuntimeException e = new RuntimeException("oops!");
    
    // 打印strackTrace
    StackTraceElement[] stackTrace = e.getStackTrace();
    for (StackTraceElement element : stackTrace) {
      System.out.println(element);
    }
    
    throw e;
  }
}

上述代码的打印结果如下所示。

demo.Demo17_3.fa(Demo17_3.java:18)
demo.Demo17_3.fb(Demo17_3.java:16)
demo.Demo17_3.fc(Demo17_3.java:15)
demo.Demo17_3.fd(Demo17_3.java:14)
demo.Demo17_3.fe(Demo17_3.java:13)
demo.Demo17_3.main(Demo17_3.java:6)

如果函数调用栈的深度比较小,如上示例代码所示,只包含5个函数的栈帧,那么fillStackTrace()生成栈追踪信息的耗时也会比较少,但是,对于大部分项目来说,业务代码往往都是运行在容器(比如Tomcat)、框架(比如Dubbo、Spring)中,尽管业务代码的函数调用层次不会很深,但是累加上容器和框架中的函数调用,总的函数调用层次就有可能很深,这种情况下,fillStackTrace()的耗时就相当可观了。这就是异常导致程序变慢的其中一个原因。

特别是,如果在递归中抛出异常,函数调用栈非常深,有上千个函数栈帧,那么fillStackTrace()将会非常耗时。所以,在递归中不要轻易抛出异常。

2)使用throw抛出异常

当创建完异常之后,我们一般会紧接着将其抛出,如下代码所示。

public static void fa() {
    ...
    throw new RuntimeException("oops!");  //第3行代码
}

实际上,上述代码中的第3行代码包含两个操作,一个是创建异常,一个是抛出异常,等价于以下两行代码。其中,创建异常的性能我们已经分析过了。我们再来分析一下抛出异常的性能。

RuntimeException e = new RuntimeException("oops!");
throw e;

当函数执行throw语句抛出异常时,JVM底层会执行“栈展开(stack unwinding)”,依次将函数调用栈中的函数栈帧弹出,直到查找到哪个函数可以捕获这个异常为止。我们举个例子解释一下,示例代码如下所示。

public class Demo17_4 {
  public static void main(String[] args) {
    fe();
  }
  public static void fe() { fd(); }
  public static void fd() { //捕获处理
    try {
      fc();
    } catch(Exception e) {
      System.out.println("I catch you!");
    }
  }
  public static void fc() { fb(); }
  public static void fb() { fa(); }
  public static void fa() { //抛出异常
    throw new RuntimeException("oops!");
  }
}

当fa()函数执行throw语句抛出异常时,JVM将执行栈展开,查找能够处理这个异常的函数。而fa()、fb()、fc()这三个函数内部均没有捕获次异常,所以,统统被弹出函数调用栈。直到遇到fd()函数,它捕获了fa()函数抛出的异常,所以,不会被弹出函数调用栈,JVM从fd()函数继续再执行。上述栈展开过程如下图所示。

img
img

相对于普通的函数返回(调用return语句)导致的栈帧弹出,调用throw导致的栈展开除了包含栈帧弹出之外,还增加了一个过程,那就是在函数的异常表中查找是否有可匹配的处理规则。如果异常抛出之后,经过很多函数调用,最终才被捕获,那么查询这些函数的异常表的耗时就会比较多。这就是异常导致程序变慢的另一个原因。当然,相比于创建异常来说,栈展开的耗时要更小一些。

因此,对于异常,我们应该遵守“能捕获就尽量早捕获”的开发原则。很多人喜欢使用Spring AOP,在程序运行的很外层,捕获所有的异常,实际上这样的做法增加了栈展开的耗时。不过,权衡性能和开发的便利性,在没有大量异常抛出的情况下,这样做也是可行的。

3)打印异常调用链

上一节我们讲到,当异常被捕获之后,一般有3种处理方式:原封不动的抛出,封装成新的异常抛出和记录日志。其中,原封不动的抛出就相当于没有去捕获。我们重点分析封装成新的异常抛出和记录日志这两种处理方法。

上一节讲到,捕获到异常之后,如果封装成新的异常再抛出,我们一般会将捕获到的异常,通过cause参数,传递给新的异常,如下所示,这样异常调用链就不会断掉。

try {
  ...
} catch (IOException e) {
  throw new RuntimeException("oops!", e);
}

上述代码中会调用如下构造函数,将对象e和字符串“oops”分别传递给新的异常的cause成员变量和detailMessage成员变量。实际上,异常中最重要的三个成员变量就是stackTrace、detailMessage和cause。

public class Throwbale {
  private StackTraceElement[] stackTrace;
  private String detailMessage;
  private Throwable cause;

  public Throwable(String message, Throwable cause) {
    fillInStackTrace(); //生成stackTrace
    this.detailMessage = message;
    this.cause = cause;
 }
 //...省略其他无关代码...
}

一般来说,在平时的开发中,我们使用日志框架来记录异常,如下所示。异常调用链信息会输出到日志文件中,方便程序员事后查看。我们一般不推荐使用e.printStackTrace()函数来打印异常日志,因为printStackTrace()函数会将异常调用链信息打印到标注出错输出(System.err)中(关于什么是标准出错输出,我们在下一节讲解I/O时会介绍),简单理解就是打印到命令行,显然,这不方便保存以便反复查看。

try {
  ...
} catch (IOException e) {
  log.error("....", e);
  //e.printStackTrace(); //不推荐此种方法
}

实际上,log.error("...", e)和e.printStackTrace()的主要区别在输出的目的地不同,其生成的异常调用链信息大差不差,都包含整个异常调用链上的异常之间的caused by关系,以及每个异常的栈追踪信息。我们拿printStackTrace()函数来举例分析。

我们通过一个例子来看下printStackTrace()函数是如何执行的。示例代码如下所示。

public class Demo {
  public static class LowLevelException extends Exception {
    public LowLevelException() { super(); }
    public LowLevelException(String msg, Throwable cause) { super(msg, cause); }
    public LowLevelException(String msg) { super(msg); }
    public LowLevelException(Throwable cause) { super(cause); }
  }

  public static class MidLevelException extends Exception {
    //...与LowLevelException实现类似,省略代码实现...
  }

  public static class HighLevelException extends RuntimeException {
    //...与LowLevelException实现类似,省略代码实现...
  }

  public static void fa() throws LowLevelException {
    throw new LowLevelException("LowLevelException-msg");
  }

  public static void fb() throws LowLevelException {
    fa();
  }

  public static void fc() throws MidLevelException {
    try {
      fb();
    } catch (LowLevelException e) {
      throw new MidLevelException("MidLevelException-msg", e);
    }
  }

  public static void fd() {
    try {
      fc();
    } catch (MidLevelException e) {
      throw new HighLevelException("HighLevelException-msg", e);
    }
  }

  public static void fe() {
    fd();
  }

  public static void main(String[] args) {
    try {
      fe();
    } catch(HighLevelException e) {
      e.printStackTrace();
    }
  }
}

当main()函数调用e.printStackTrace()时,整个异常调用链如下图所示。

img
img

理论上,最终打印出来的异常调用链应该如下所示。每个异常的stackTrace栈追踪信息都打印出来了。

demo.Demo$HighLevelException: HighLevelException-msg
	at demo.Demo.fd(Demo.java:45)
	at demo.Demo.fe(Demo.java:50)
	at demo.Demo.main(Demo.java:55)
Caused by: demo.Demo$MidLevelException: MidLevelException-msg
	at demo.Demo.fc(Demo.java:37)
	at demo.Demo.fd(Demo.java:43)
	at demo.Demo.fe(Demo.java:50)
	at demo.Demo.main(Demo.java:55)
Caused by: demo.Demo$LowLevelException: LowLevelException-msg
	at demo.Demo.fa(Demo.java:26)
	at demo.Demo.fb(Demo.java:30)
	at demo.Demo.fc(Demo.java:35)
	at demo.Demo.fd(Demo.java:43)
	at demo.Demo.fe(Demo.java:50)
	at demo.Demo.main(Demo.java:55)

但最终打印出来的异常调用链的真实情况却如下所示。每个异常只打印它生命周期(从创建到被捕获不再抛出)所经历的函数,这样打印出来的异常调用链信息的可读性更好。其中“... 2 more”和“... 3 more”记录的是stackTrace栈追踪信息中没有被打印出来的函数的个数。

demo.Demo$HighLevelException: HighLevelException-msg
	at demo.Demo.fd(Demo.java:45)
	at demo.Demo.fe(Demo.java:50)
	at demo.Demo.main(Demo.java:55)
Caused by: demo.Demo$MidLevelException: MidLevelException-msg
	at demo.Demo.fc(Demo.java:37)
	at demo.Demo.fd(Demo.java:43)
	... 2 more
Caused by: demo.Demo$LowLevelException: LowLevelException-msg
	at demo.Demo.fa(Demo.java:26)
	at demo.Demo.fb(Demo.java:30)
	at demo.Demo.fc(Demo.java:35)
	... 3 more

因为Throwable类中暴露了足够的接口(比如getStackTrace()、getCause()),所以我们也可以自己实现一个异常打印类,比如ExceptionPrinter,按照自己想要的格式来输出异常调用。实际上,log.error("...", e)就是这么干的。

最后,我们再回到异常性能分析上。大部分情况下,我们都需要调用日志框架来打印异常调用链,把所有异常的栈追踪信息都打印出来,显然是比较耗时的。

三、异常最佳实践

尽管异常会导致程序变慢,但毕竟异常并不常发生,所以,我们没必要为此担心。但是,据我了解,很多人会在程序中定义大量的业务异常,比如查询用户不存在时抛出UserNotExistingException异常。在高并发下,这类业务异常就有可能频繁发生,进而大量异常的创建、抛出、打印,就很有可能导致程序变得非常慢。

我自己曾经开发过一个限流框架,应用到公司里的大部分业务系统,起到限流的作用。当促销活动导致业务系统流量暴增时,限流框架在生效的同时,也不幸导致了业务系统的宕机。最后追踪发现,这个问题就是由异常引起。

在我们设计中,当访问被限流时,限流框架会抛出OverloadException异常。当大量访问被限流时,线程会大量创建、抛出、打印异常,而这些操作都是非常耗时的,因此,线程就无法响应其他正常请求了,进而导致整个系统响应变慢,甚至超时。

怎么来解决这个问题呢?

实际上,对于业务异常,我们没必要记录stackTrace栈追踪信息,只需要将一些有用的信息,记录在异常的detailMessage成员变量中即可。比如对于UserNotExistingException这个业务异常,我们只需要记录不存在的用户的ID即可。

怎么才能创建不包含栈追踪信息的异常呢?

我们知道,stackTrace栈追踪信息是在异常创建的同时调用fillStackTrace()函数生成的,Throwable有一个特殊的构造函数,可以用来禁止在创建异常的同时调用fillStackTrace()函数,此构造函数如下所示。

protected Throwable(String message, Throwable cause,
                    boolean enableSuppression,
                    boolean writableStackTrace) {
    if (writableStackTrace) {
        fillInStackTrace();
    } else {
        stackTrace = null;
    }
    detailMessage = message;
    this.cause = cause;
    if (!enableSuppression)
        suppressedExceptions = null;
}

我们只需要在自定义异常时,调用父类Throwable的上述构造函数,将writableStackTrace赋值为false即可。示例代码如下所示。

public class UserNotExistingException extends Throwable {
  public UserNotExistingException() {
    super(null,null, true, false);
  }
  
  public UserNotExistingException(String msg, Throwable cause) {
    super(msg, cause, true, false);
  }
  
  public UserNotExistingException(String msg) {
    super(msg, null, true, false);
  }
  
  public UserNotExistingException(Throwable cause) {
    super(null, cause, true, false);
  }
}

这样没有了栈追踪信息,异常创建和打印的耗时就微乎其微了,唯一耗时的部分就是抛出异常时触发的栈展开耗时,但相比异常创建和打印耗时,这部分耗时要小很多,所以,也可以忽略。因此,使用这种方法可以解决在高并发下,程序中大量业务异常导致程序变慢的问题了。

四、课后思考题

1)既然在打印异常调用链时,我们只需要打印每个异常的生命周期里所经历的函数,并不需要打印从main()函数到异常创建所经历的所有函数,那么,我们是不是可以在每个异常的stackTrace栈追踪信息中,只记录生命周期内所经历的函数呢?

2)我们将下面的代码编译成字节码之后,finally代码块会内联到catch代码块中的throw语句之前吗?为什么?

public static double div(int a, int b) {
  try {
    double res = a / b;
    System.out.println("in try block.");
    return res;
  } catch(ArithmeticException e) {
    System.out.println("in ATE catch block.");
    throw new RuntimeException("whoiam");
  } finally {
    System.out.println("in finally block.");
  }
}