异常处理流程原来是这样的?

对于开发者来说,应该没有哪位老铁敢自诩自己开发的程序不会出错吧,越是复杂的应用,在开发的过程中,可能出现问题或者考虑不到的地方就会越多。这种可能在我们预料之外的情况,我们通常将其称为异常(Exception),只有正确处理好意外情况,才能保证程序的可靠性。Java 语言在设计之初就提供了相对完善的异常处理机制,这也是 Java 得以大行其道的原因之一,因为这种机制也大大降低了编写和维护可靠程序的门槛。

对异常的一些基础概念不了解老铁们,可以看下异常到底该怎么处理,接下来,我们聊一些关于异常处理流程相关的内容。看后,相信你会有一定的收获。

我们都知道异,常的处理在java语言层面的处理方式是利用 try{}catch()finally{} 范式来处理的。其中:
try代码块,是可能出来异常的代码。
catch代码块,是对try代码块中抛出异常的处理逻辑。
finally代码块,是try和catch代码块执行后,一定会执行的代码逻辑。

正常情况下,代码的执行流程从上到下,先执行try,在执行catch,最后执行finally。然而,很多时候,try中抛出的异常,catch可能无法处理,即使catch可以处理,但是catch在处理的过程中也有可能会产生新的异常。对于后面两种特殊情况的处理,是由finally来完成的,finally会捕获到这些异常,但是由于finally中没有执行这些异常的引用,所以finally对捕获异常的处理,只能将他们抛出去。

异常的处理流程可以参考下图:
在这里插入图片描述

上图是目前java中对异常处理的实现方式。java编译器会将finally代码块的内容,复制到try-catch代码块所有正常执行路径和以及异常执行路径的出口处。对于正常执行路径:try代码正常执行的情况和try触发的异常被catch捕获情况,如图中蓝色和黄色的finally代码块。对于异常执行路径:try中抛出了catch无法捕获异常,或者catch中抛出了异常,如图中红色的finally代码块。

下面我们从字节码层洞察一下,编译器对try-catch-finally的编译结果。为了更好的进行验证,异常处理的java语言层面的代码如下:

public class ExceptionTest {
    int tryBlock ;
    int catchBlock;
    int finallyBlock;
    int otherBlock;

    public void tryCatchFinally() {
        try{
            tryBlock = 1;
        }catch (Exception e) {
            catchBlock = 2;
        }finally {
            finallyBlock = 3;
        }
        otherBlock = 4;
    }
  }

通过使用javap命令可以查看编译后的字节码:

 public void tryCatchFinally();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: iconst_1
         2: putfield      #5                  // Field tryBlock:I
         5: aload_0
         6: iconst_3
         7: putfield      #6                  // Field finallyBlock:I
        10: goto          35
        13: astore_1
        14: aload_0
        15: iconst_2
        16: putfield      #8                  // Field catchBlock:I
        19: aload_0
        20: iconst_3
        21: putfield      #6                  // Field finallyBlock:I
        24: goto          35
        27: astore_2
        28: aload_0
        29: iconst_3
        30: putfield      #6                  // Field finallyBlock:I
        33: aload_2
        34: athrow
        35: aload_0
        36: iconst_4
        37: putfield      #9                  // Field otherBlock:I
        40: return
      Exception table:
         from    to  target type
             0     5    13   Class java/lang/Exception
             0     5    27   any
            13    19    27   any

从字节码中可以看出,带有try-catch-finally异常处理的方法都会附带一个异常表 Exception table,表中每一个条目代表一个异常处理器,每个条目由from指针,to指针,target指针以及可处理异常类型构成。其中from和to指针表示,这个异常处理器,监控可能出现异常的代码范围(字节码层面),也就是这个异常处理器的作用范围。

type表示这个异常处理器,可以处理的异常类型。只有异常类型相互匹配的异常,才可以被这个异常处理器处理。

target:表示这个异常处理器,处理逻辑开始的指针位置。

总结来说,就是一个异常处理器,会监控从from指针到to指针(不包括to指针位置)的代码的异常情况,如果该范围内代码抛出了异常,且异常类型和该异常处理器的异常类型可匹配的话,那么程序执行流程会跳转到target指针指向的位置。

通常情况下一个方法的异常处理器会有多个,在本例中有3个,且三个异常处理器的优先级从上到下依次递减,当出现异常后,会从上到下遍历异常表,寻找符合条件的异常处理器,如果遍历完所有异常处理器后,jvm仍未找到匹配的异常处理器的话,那么它会弹出当前方法对应的栈帧,在调用者中异常表中查找合适的异常处理器。

在本例的异常处理表中,我们可以看到第一个异常处理器的from和to指针分别执行0和5,刚好就是try代码块的范围,如果该范围内代码抛出的异常被该异常处理器处理话,那么程序就会跳转到taget指向的8,并从这个位置开始向后执行。也就是执行 catch代码块,finally代码块和otherBlock。

第二个异常处理器的from和to指针指向的位置和第一个异常处理器相同,只不过异常类型为any,any表示任何异常,该异常处理器,刚好是第一个异常处理的一个补充,用来处理try代码块抛出的异常不能被catch捕获的情况,对于这种情况,程序执行流程会跳转到target指针指向的27行,也就是finally代码块,然后再将异常throw出去,也就是34行指向的字节码。

第三个异常处理器的from和to指针指向13和19,也就是catch代码块,该异常处理器可以处理任何类型的异常,对catch代码块抛出异常的处理方式是将程序执行流程跳转到27行,和第二个异常处理器的处理逻辑相同。

看到这里,对于Java中异常处理流程,你是不是有了一个更深层的认识了呢?

优雅的处理异常

其实对于上面第三个异常处理器,还有一个小问题,catch处理try中抛出的异常的过程中,又抛出了异常,那么被finally捕获后并重新抛出的异常,是try代码块中抛出的异常,还是catch代码块中抛出的异常呢? 答案是后者。也就是说最开始触发的异常被忽略掉了,这对于问题的排查是不利的,因为异常打印的堆栈中,并没有最开始出现问题的代码信息。

为了解决这个问题,在java7中引入了Suppressed异常来解决这个问题,这个新特性,可以将一个异常附于另一个异常之上,这就可以在一个异常上附加多个异常的信息。

除此之外,为了方便对资源类型对象的释放,在java7中专门构造了一个名为try-with-resource的语法糖,该语法糖可以精简资源的打开关闭。

在java7前,为了保证打开的资源在异常情况下也可以正常关闭,每一个资源都要对应一个独立的try-finally代码块,将关闭资源的操作放在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();
  }

而使用try-with-resouce语法糖,则可以极大的简化上述代码,该语法糖的使用,需要对应的资源类型实现AutoCloseable接口类,使用如下语法糖,可以实现和上述代码相同的作用,而且还会使用到Suppressed异常的功能:

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)

看到这里是不是觉得很神奇,好奇这个语法糖是如何实现的呢?其实很多语法糖都是编译器提供的一种障眼法,通过查看编译后的字节码,也就没什么秘密了。使用了语法糖可以在java语言层面简化代码,但是字节码层面,代码并没有简化。有兴趣的老铁,可以使用javap查看相应的字节码,进行验证。

异常被finally吞掉了?

最后和大家分享一个异常处理的踩坑经历,如果finally中有return语句的话,那么finally捕获到的异常,就不会抛出去了,给人一种异常被吞掉的感觉。

实验代码如下:

public void tryCatchFinallyWithReturn() {
        try{
            throw new RuntimeException("try exception");
        }catch (NullPointerException e) {
            // ignore
        }finally {
            return;
        }
    }

而如果finally中没有return的话,异常堆栈会被错误输出流打印出来,为什么使用错误输出流打印呢,可以参考 如何在多线程环境中优雅的处理异常

为什么有return的情况下,异常不会抛出去呢,老规矩,看一下字节码就清楚了。

 public void tryCatchFinallyWithReturn();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: new           #10                 // class java/lang/RuntimeException
         3: dup
         4: ldc           #15                 // String try exception
         6: invokespecial #12                 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
         9: athrow
        10: astore_1
        11: return
        12: astore_2
        13: return
      Exception table:
         from    to  target type
             0    10    10   Class java/lang/NullPointerException
             0    11    12   any

try代码块中抛出的异常,会被第二个异常处理器处理,该异常处理器的处理逻辑从12行开始,接着13行就return了,并没有athrow指令。为了方便对比,我们再查看一下,把finally代码块中return去掉后的字节码:

  public void tryCatchFinallyWithReturn();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: new           #10                 // class java/lang/RuntimeException
         3: dup
         4: ldc           #15                 // String try exception
         6: invokespecial #12                 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
         9: athrow
        10: astore_1
        11: goto          17
        14: astore_2
        15: aload_2
        16: athrow
        17: return
      Exception table:
         from    to  target type
             0    10    10   Class java/lang/NullPointerException
             0    11    14   any

同样是第二个异常处理器处理处理异常情况,处理逻辑从14行开始,获取捕获的异常实例,然后通过athrow完成抛出操作。

到这里,终于明白了为啥finally中有return的情况下,异常不会抛出去了。关于语法糖,它只是编译器在语言层面的一种特殊语法的支持,可以很大程度上减少语言层面的代码量,但是在字节码层面,代码量并没有减少,有可能还会更加复杂,到这里突然想到一句很有道理的话:哪有什么岁月静好,只不过有人替你负重前行。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值