Java异常机制-从底层原理到使用实践

导读

本文是笔者在经过学习及工作实践后的一个关于Java异常机制的阶段性总结,同时分享出来,也希望能够帮助到其他正在学习Java异常的读者。受限于本人知识的浅薄,文中难免会有错误或解释不到位的地方,希望能够和读者于评论区中进行友善的交流与评论。

​ 在内容上,本文仅会简单介绍异常机制的简单概念以及相关理念,会着重于介绍Java异常的底层实现,并试图通过示例代码帮助辅助读者理解,同时会在文章后半部分列举一些关于异常的使用实践和注意事项。

关于异常

​ 显然的是,没有人能够保证自己写的程序完全没有bug。因此,首先会想到的是在错误出现时,能够按照指定的、事先安排好的流程去处理它,最大程度上避免不可控的情况出现。所以异常机制的出现,就是为了使程序具有更好的容错性,让程序更加健壮。

​ 当程序出现意外情形时,系统会自动生成一个包装好的异常对象来通知程序,从而实现“业务功能实现代码”和“错误处理代码”分离,提供更好的可读性。

异常概述

​ 首先,异常处理的两大组成要素是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移,是处理程序处于非预期状态的方式。

异常组成

抛出异常

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

​ 显示抛出异常的主体是应用程序(程序员当前在编写的代码),也就是程序中使用“throw”关键字,手动抛出异常实例。Java认为这种Checked异常都是可以在编译阶段被处理的异常,所以它强制程序处理所有的Checked异常。否则程序在编译阶段就会发生错误,无法通过编译。这样的设计思想,实际上是想表达出:没有完善错误处理的代码不能被执行。这在很大程度上,增加了程序的健壮性。

​ 隐式异常的主体是Java虚拟机,它指的是JVM在执行过程中,碰到无法继续执行的异常状态,自动抛出的异常。Runtime异常并不需要应用程序手动处理。如果程序需要捕获Runtime异常,也可以使用try…catch块来实现。

捕获异常

​ 捕获异常主要由下面三种代码块进行完成:

try代码块:用来标记需要进行异常监控的代码。

catch代码块:跟在try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理器。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会从上至下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。

finally 代码块:跟在 try 代码块和 catch 代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。

从字节码层面解读异常

基本实现

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

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

​ 下面会在举例的代码中补充注释进行说明以上原理(使用##标注)。

​ 源代码:

public class ExceptionByteCode {
    public static void main(String[] args) {
        try {
            throwNewException();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    private static void throwNewException() {
        throw new RuntimeException();
    }
}

​ 编译后生成的字节码:

Compiled from "ExceptionByteCode.java" ## 字节码中出现的数字标识也就是是字节码索引
public class com.zzz.exception.ExceptionByteCode {
  public com.zzz.exception.ExceptionByteCode();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #2                  // Method throwNewException:()V
       3: goto          11
       6: astore_1  ## target=6,标识异常处理器从索引为6的字节码开始,也就是异常处理器的真实位置(catch7: aload_1
       8: invokevirtual #4                  // Method java/lang/Exception.printStackTrace:()V
      11: return
    Exception table:  ## 这里就是main()中附带的异常表
       from    to  target type   ## frome和to指示的0-3表面了该异常处理器监控的范围
           0     3     6   Class java/lang/Exception ## 这里就是一个条目,也即一个异常处理器
}

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

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

在这里插入图片描述

finally代码块的实现

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

在这里插入图片描述

图片来源

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

​ 为了辅助理解上面的描述,请看下面的代码示例:

测试类源代码:

public class ExceptionFinally {
    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;
    }
}
# 在测试类所在包路径下,执行以下命令,获得字节码信息
javac ExceptionFinally.java
javap -c ExceptionFinally

测试类的字节码信息:

Compiled from "ExceptionFinally.java"
public class com.zzz.exception.ExceptionFinally {
  public com.zzz.exception.ExceptionFinally();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test();
    Code:
       0: aload_0
       1: iconst_0
       2: putfield      #2                  // Field tryBlock:I
       5: aload_0
       6: iconst_2
       7: putfield      #3                  // Field finallyBlock:I  ## tryBlock中的finallyBlock
      10: goto          35
      13: astore_1
      14: aload_0
      15: iconst_1
      16: putfield      #5                  // Field catchBlock:I
      19: aload_0
      20: iconst_2
      21: putfield      #3                  // Field finallyBlock:I ##catchBlock中的finallyBlock
      24: goto          35
      27: astore_2   ## 这里对应了“这些异常表条目的target指针将指向另一份复制的finally代码块”
      28: aload_0
      29: iconst_2
      30: putfield      #3                  // Field finallyBlock:I
      33: aload_2
      34: athrow    ## 这里就是将栈顶的异常抛出 “Java编译器会重新抛出所捕获的异常”
      35: aload_0
      36: iconst_3
      37: putfield      #6                  // Field methodExit:I
      40: return
    Exception table:
       from    to  target type
           0     5    13   Class java/lang/Exception
           0     5    27   any
          13    19    27   any
}

​ 观察字节码组成可以看到,其中包含了三份finally代码块。其中,前两份分别位于 try 代码块和 catch 代码块的正常执行路径出口。最后一份则作为异常处理器,监控 try 代码块以及 catch 代码块。它将捕获 try 代码块触发的、未被 catch 代码块捕获的异常,以及 catch 代码块触发的异常(使用any确保捕获)。

异常屏蔽

​ 在可能存在的一种场景中,存在着异常屏蔽的情况:

  1. 如果catch代码块中捕获了异常,但触发了新的异常,那么finally捕获并且重抛的异常会是后一个异常,这样原本的异常就会被忽略掉。

  2. 如果finally代码块中也抛出了异常,那么会是这个异常向上传递,try中的异常也就被“屏蔽”了。

    这样的情况对bug调试很不利。

以下是代码举例说明情况:

源代码:

public class ExceptionShield {
    public static void main(String[] args) {
        testExceptionShield();
    }
    private static void testExceptionShield() {
        try {
            double a = 1 / 0;
            System.out.println(a);
        } catch (Exception e) {
            int[] a = {1, 2};
            System.out.println(a[2]);
        } finally {
            System.out.println("finally");
        }
    }
}

运行结果:

finally
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 2
	at com.zzz.exception.ExceptionShield.testExceptionShield(ExceptionShield.java:15)
	at com.zzz.exception.ExceptionShield.main(ExceptionShield.java:6)

​ 可以看到,try内的原始错误应该为 java.lang.ArithmeticException: / by zero 异常,但却被catch内的新异常给“屏蔽”了。

​ 那么Java是如何解决这个问题的呢?

​ Java7引入了Suppressed异常机制来解决这个问题,其核心就是允许开发者将一个异常附于另一个异常之上,这样抛出的异常就可以附带多个异常的信息。实际上这是通过对Throwable类进行了修改以支持这种情况。

​ 在java7中为Throwable类增加addSuppressed方法。当一个异常被抛出的时候,可能有其他异常因为该异常而被抑制住,从而无法正常抛出。这时可以通过addSuppressed方法把这些被抑制的方法记录下来。被抑制的异常会出现在抛出的异常的堆栈信息中,也可以通过getSuppressed方法来获取这些异常。这样做的好处是不会丢失任何异常,方便开发人员进行调试。参考以下代码:

    private static void testExceptionShield() {
        try {
            double a = 1 / 0;
            System.out.println(a);
        } catch (Exception e) {
            try {
                int[] a = {1, 2};
                System.out.println(a[2]);
            } catch (Exception exception) {
                exception.addSuppressed(e);
                throw exception;
            }
        } finally {
            System.out.println("finally");
        }
    }

得到被抑制的异常

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 2
	at com.zzz.exception.ExceptionShield.testExceptionShield(ExceptionShield.java:16)
	at com.zzz.exception.ExceptionShield.main(ExceptionShield.java:5)
	Suppressed: java.lang.ArithmeticException: / by zero
		at com.zzz.exception.ExceptionShield.testExceptionShield(ExceptionShield.java:11)
		... 1 more

​ 这里同时还是存在一个问题,假设需要在finally代码块中捕获try或者catch中的异常,由于缺乏引用开发者需要在try代码块上面提前定义好异常的变量,以便引用,这样使用起来就比较繁琐。所以引出了下面要说的,关于try-with- resource的实现。

try-with-resource

​ Java7中专门构造了这个try-with-resource的语法糖,它的主要目的是精简资源打开关闭的用法,同时它还会在字节码层面上自动使用Suppressed异常功能。本文不再赘述它的用法,着重介绍一下它的实现。

示例源代码:

public class TryWithResource implements AutoCloseable {
    private final String name;
    public TryWithResource(String name) {
        this.name = name;
    }

    @Override
    public void close() {
        throw new RuntimeException(name);
    }

    public static void main(String[] args) {
        try (TryWithResource test1 = new TryWithResource("test1");
             TryWithResource test2 = new TryWithResource("test2");
             TryWithResource test3 = new TryWithResource("test3");) {
            throw new RuntimeException("Initial");
        }
    }
}

经过反编译字节码后的代码(编译器优化过后):

public class TryWithResource implements AutoCloseable {
    private final String name;

    public TryWithResource(String var1) {
        this.name = var1;
    }

    public void close() {
        throw new RuntimeException(this.name);
    }

    public static void main(String[] var0) {
        TryWithResource var1 = new TryWithResource("test1");
        Throwable var2 = null;

        try {
            TryWithResource var3 = new TryWithResource("test2");
            Throwable var4 = null;

            try {
                TryWithResource var5 = new TryWithResource("test3");
                Throwable var6 = null;

                try {
                    throw new RuntimeException("Initial");
                } catch (Throwable var44) {
                    var6 = var44;
                    throw var44;
                } finally {
                    if (var5 != null) {
                        if (var6 != null) {
                            try {
                                var5.close();
                            } catch (Throwable var43) {
                                var6.addSuppressed(var43);
                            }
                        } else {
                            var5.close();
                        }
                    }

                }
            } catch (Throwable var46) {
                var4 = var46;
                throw var46;
            } finally {
                if (var3 != null) {
                    if (var4 != null) {
                        try {
                            var3.close();
                        } catch (Throwable var42) {
                            var4.addSuppressed(var42);
                        }
                    } else {
                        var3.close();
                    }
                }

            }
        } catch (Throwable var48) {
            var2 = var48;
            throw var48;
        } finally {
            if (var1 != null) {
                if (var2 != null) {
                    try {
                        var1.close();
                    } catch (Throwable var41) {
                        var2.addSuppressed(var41);
                    }
                } else {
                    var1.close();
                }
            }

        }
    }
}

通过观察反编译后的字节码,可以看到try-with-resource语法糖为我们自动实现了suppressed异常。

并且资源的自动关闭是通过调用接口AutoClosable的close()实现的。
在这里插入图片描述

​ 通过观察JDK类库可以观察到,JDK已经帮我们实现了大部分常用的类的close()方法,这些方法会在try-with-resource语法糖的加持下实现“自动关闭”。

同一catch代码块中的多个异常条目

​ 除了 try-with-resources 语法糖之外,Java 7 还支持在同一 catch 代码块中捕获多种异常。

​ 实际实现非常简单,生成多个异常表条目即可。

示例源代码:

public class MoreException {
    public static void main(String[] args) {
        try {
            final ArrayList<Object> objects = new ArrayList<>();
        } catch (ArithmeticException | IndexOutOfBoundsException exception) {
            throw exception;
        }
    }
}

编译后的字节码:

public class com.zzz.exception.MoreException {
  public com.zzz.exception.MoreException();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: goto          14
      11: astore_1
      12: aload_1
      13: athrow
      14: return
    Exception table:
       from    to  target type
           0     8    11   Class java/lang/ArithmeticException
           0     8    11   Class java/lang/IndexOutOfBoundsException
}

​ 可以看到,异常条目表中只是多了具有相同from、to的条目。

为什么说异常实例的构造十分昂贵?

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

最佳实践

​ 以上的内容,就是异常机制的在字节码层面的体现,接下来会总结列出一些与实际开发更加息息相关的实践。

​ 首先我们要明确,成功的异常处理应该实现的功能:

  • 使程序代码混乱最小化

  • 捕获并保留诊断信息

  • 通知合适的人员

  • 采用合适的方式结束异常活动

    ​ 为了达到上述的的目标,在进行异常处理时,我们既要遵循基本使用原则,也可以学习一些得到开发人员共识的优秀经验。

基本使用原则

1. 不要过度使用异常

​ 滥用异常机制会带来一些负面影响,包括但不限于以下两个方面:

  • 把异常和普通错误混淆在一起,不再编写任务错误处理代码,而是简单地抛出异常来代替所有的错误处理。
  • 使用异常处理来代替流程控制。

由于异常实例的创建对于JVM来说是十分昂贵的,所以我们应该避免异常的滥用。

对于完全已知的错误,应该编写处理这种错误的代码,增加程序的健壮性;只有对外部的、不能确定和预知的运行时错误才使用异常。

2. 不要使用过于庞大的try块

​ 当try代码块过于庞大时,难免会在try代码块后紧跟大量的catch块才可以针对不同的异常提供不同的处理逻辑。同一个try代码块后紧跟大量的catch代码块则需要分析它们之间的逻辑关系,反而增加了编程复杂度。

​ 正确的做法是,把大块的try代码块分割成多个可能出现异常的程序段落,并把它们放在单独的try代码块中,从而分别捕获并处理异常。

3. 避免使用catch all语句

​ 所谓catch all就是在catch语句块中,捕获程序中可能发生的所有异常,也就是直接捕获Throwable异常。这种处理方式有如下两点不足之处。

  • 所有的异常都采用相同的处理方式,这将导致无法对不同的异常分情况处理,如果要分情况处理,则需要在catch块中使用分支语句进行控制,这是得不偿失的做法。
  • 这种捕获方式可能将程序中的错误、Runtime异常等可能导致程序终止的情况全部捕获到,从而“屏蔽”了异常。

4. 不要忽略捕获到的异常

​ 不要忽略异常,如果catch块中捕获到了异常,却什么也不做,那任何人都看不到程序出了什么问题。所以对于异常,我们要采取适当的措施:

  • 处理异常。对异常进行合适的修复,然后跳过异常发生的地方继续执行;或者用别的数据进行计算,以代替期望的方法返回值;或者提示用户重新操作。总之,对于Checked异常,程序应该尽量修复。
  • 重新抛出新异常。在把当前代码步骤该做的事情处理完成之后,进行异常转译,把异常包装成当前层的异常,重新抛出给上层调用者。
  • 在合适的层处理异常。如果当前层不清楚如何处理异常,就不要在当前层使用catch语句来捕获该异常,直接使用throws声明抛出该异常,让上层调用者负责处理该异常。

《Java开发手册》中推荐的异常处理

​ 下面贴一些阿里巴巴公布的《Java开发手册》中推荐的异常处理建议:

1.[强制] Java 类库中定义的可以通过预检查方式规避的 RuntimeException 异常不应该通过

catch 的方式来处理,比如:NullPointerException,IndexOutOfBoundsException 等等。

说明:无法通过预检查的异常除外,比如,在解析字符串形式的数字时,可能存在数字格式错误,不得不

通过 catch NumberFormatException 来实现。

正例:if (obj != null) {…}

反例:try { obj.method(); } catch (NullPointerException e) {…}

5.【强制】事务场景中,抛出异常被 catch 后,如果需要回滚,一定要注意手动回滚事务。

9.【强制】在调用 RPC、二方包、或动态生成类的相关方法时,捕捉异常必须使用 Throwable

类来进行拦截。

说明:通过反射机制来调用方法,如果找不到方法,抛出 NoSuchMethodException。什么情况会抛出

NoSuchMethodError 呢?二方包在类冲突时,仲裁机制可能导致引入非预期的版本使类的方法签名不匹配,

或者在字节码修改框架(比如:ASM)动态创建或修改类时,修改了相应的方法签名。这些情况,即使代

码编译期是正确的,但在代码运行期时,会抛出 NoSuchMethodError。

其他详细内容,可以参考完整版的内容。

不容易被注意的问题

另外补充一下catch和finally中的return和throw的问题。

1、finally覆盖catch(开头引子的例子):

1)如果finally有return会覆盖catch里的throw,同样如果finally里有throw会覆盖catch里的return。

​ (这里可以参考上面关于finally代码块的字节码实现)

2)如果catch里和finally都有return, finally中的return会覆盖catch中的。throw也是如此。

2、catch有return而finally没有:

当 try 中抛出异常且catch 中有 return 语句,finally 中没有 return 语句, java 先执行 catch 中非 return 语句,再执行 finally 语句,最后执行 catch 中 return 语句。

3、try有return语句,后续还有return语句,分为以下三种情况:

情况一:如果finally中有return语句,则会将try中的return语句”覆盖“掉,直接执行finally中的return语句,得到返回值,这样便无法得到try之前保留好的返回值。

情况二:如果finally中没有return语句,也没有改变要返回值,则执行完finally中的语句后,会接着执行try中的return语句,返回之前保留的值。

情况三:如果finally中没有return语句,但是改变了要返回的值,这里有点类似与引用传递和值传递的区别,分以下两种情况,:

​ 1)如果return的数据是基本数据类型或文本字符串,则在finally中对该基本数据的改变不起作用,try中的return语句依然会返回进入finally块之前保留的值。

​ 2)如果return的数据是引用数据类型,而在finally中对该引用数据类型的属性值的改变起作用,try中的return语句返回的就是在finally中改变后的该属性的值。

参考资料:

《深入拆解Java虚拟机》郑雨迪 付费专栏
《疯狂Java讲义》

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值