性能文章>聊一聊Java异常:JVM是如何捕获异常原理和实例分析>

聊一聊Java异常:JVM是如何捕获异常原理和实例分析原创

2年前
488046

 

正文

异常的基本概念

在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类。

  • Throwable 是所有异常的根,java.lang.Throwable
  • Error 是错误,java.lang.Error
  • Exception 是异常,java.lang.Exception

Error类一般是指与虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢出等。对于这类错误导致的应用程序中断,仅靠程序本身无法恢复和和预防,遇到这样的错误,建议让程序终止。

Exception类表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常。

Exception 又包含了运行时异常(RuntimeException, 又叫非检查异常)和非运行时异常(又叫检查异常)

  • 运行时异常都是 RuntimeException 类及其子类,如 NullPointerException、IndexOutOfBoundsException 等, 这些异常是不检查的异常, 是在程序运行的时候可能会发生的, 所以程序可以捕捉, 也可以不捕捉。这些错误一般是由程序的逻辑错误引起的, 程序应该从逻辑角度去尽量避免。

  • 检查异常是运行时异常以外的异常, 也是 Exception 及其子类, 这些异常从程序的角度来说是必须经过捕捉检查处理的, 否则不能通过编译. 如 IOException、SQLException 等。

通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。

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

当然,在生成栈轨迹时,Java 虚拟机会忽略掉异常构造器以及填充栈帧的 Java 方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。此外,Java 虚拟机还会忽略标记为不可见的 Java 方法栈帧。

JVM是如何捕获异常的?

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

⚠️:每个方法都带有异常表吗?还是只有实现了catch代码块的方法才带有?经过实际验证,只有实现了catch代码块的方法才有异常表

如下述案例所示:

public class NoExceptionTest {
      

  public static void main(String[] args) {
      
    eat();
  }

  public static void eat() {
      
    System.out.println("恰个饭吧");
  }
}

查看其字节码文件,没有发现 Exception table 相关内容。

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

public class ExceptionTest {
      

  public static void main(String[] args) {
      
    try {
      
      mayArithmeticException(0);
    } catch (Exception e) {
      
      e.printStackTrace();
    }
  }

  public static void mayArithmeticException(int num) {
      
    double result = 1 / num;
  }
}

//查看其字节码
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: iconst_0
         1: invokestatic  #2                  // Method mayArithmeticException:(I)V
         4: goto          12
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method java/lang/Exception.printStackTrace:()V
        12: return
      Exception table:
         from    to  target type
             0     4     7   Class java/lang/Exception

查看字节码后,我们可以知道,main 方法中对应有一个异常表,该表拥有一个条目。其 from 指针和 to 指针分别为 0 和 4,代表它的监控范围从索引为 0 的字节码开始,到索引为 4 的字节码结束(不包括 4)。该条目的 target 指针是 7,代表这个异常处理器从索引为 7 的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是 Exception。

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

public class MyException extends ArithmeticException {
      

  @Override
  public void printStackTrace() {
      
    System.out.println("自定义的错误");
  }
}

public class ExceptionTest {
      

  public static void main(String[] args) {
      
    int sum = 0;
    try {
      
      mayArithmeticException(0);
    } catch (MyException e) {
      
      sum = 121;
    } catch (ArithmeticException e) {
      
      sum = 119;
    }
    System.out.println(sum);
}

  public static void mayArithmeticException(int num) {
      
    double result = 1 / num;
  }
}

执行结果为 119,由此我们可知 MyException 虽然是 ArithmeticException 的子类,但是两者并不匹配。

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

比如说我们将上述代码中的 ArithmeticException 异常改为 NullPointerException,那么遍历完所有异常表条目也没有匹配上的异常处理器,那么则会抛出这种代码未捕获的异常。

finally代码块

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

针对上述 ExceptionTest 的实现我们做一点修改:

    int sum = 0;
    try {
      
      mayArithmeticException(0);
    } catch (MyException e) {
      
      sum = 121;
    } catch (ArithmeticException e) {
      
      sum = 119;
    } finally {
      
      System.out.println(sum);
      sum = 999;
    }
    System.out.println(sum);

执行结果为:

119
999

查看编译后的字节码文件,重点查看 Exception table

      Exception table:
         from    to  target type
             2     6    20   Class com/msdn/java/hotspot/exception/MyException
             2     6    38   Class java/lang/ArithmeticException
             2     6    56   any
            20    24    56   any
            38    42    56   any

观察可知,加了 finally 后,Java 编译器监控整个 try-catch 代码块,并且捕获所有种类的异常(在 javap 中以 any 指代)。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块。并且,在这个 finally 代码块的最后,Java 编译器会重新抛出所捕获的异常。

如果 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是哪个呢?

答案是后者。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。如下述代码所示,最终执行结果只会抛出空指针异常,而我们是无法查看上一层的异常,即算术运算异常。

public static void main(String[] args) {
      
  int sum = 0;
  try {
      
    mayArithmeticException(0);
  } catch (MyException e) {
      
    sum = 121;
  } catch (ArithmeticException e) {
      
    sum = 119;
    mayNullPointerException();
  } finally {
      
    System.out.println(sum);
    sum = 999;
  }
  System.out.println(sum);
}

public static void mayNullPointerException(){
      
  Object object = null;
  System.out.println(object.toString());
}

关于上述问题后文将给出答案,我们继续往下看。

Suppressed 异常以及语法糖

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

来看一个简单的示例:

public class Main {
      

  public static void main(String[] args) throws Exception {
      
    Exception origin = null;
    try {
      
      System.out.println(Integer.parseInt("abc"));
    } catch (Exception e) {
      
      origin = e;
      throw e;
    } finally {
      
      Exception e = new IllegalArgumentException();
      if (origin != null) {
      
        e.addSuppressed(origin);
      }
      throw e;
    }
  }
}

执行结果如下:

Exception in thread "main" java.lang.IllegalArgumentException
	at com.msdn.java.hotspot.exception.Main.main(Main.java:33)
	Suppressed: java.lang.NumberFormatException: For input string: "abc"
		at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
		at java.lang.Integer.parseInt(Integer.java:580)
		at java.lang.Integer.parseInt(Integer.java:615)
		at com.msdn.java.hotspot.exception.Main.main(Main.java:28)

总结来说,先用origin变量保存原始异常,然后调用Throwable.addSuppressed(),把原始异常添加进来,最后在finally抛出。

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

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

我们来看一个资源关闭的示例代码,每次打开之后都需要关闭,如果有多个文件打开,那关闭就更麻烦了。

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 还会使用 Suppressed 异常的功能,来避免原异常“被消失”。

public class ExceptionalResource implements AutoCloseable {
      

  public void processSomething() {
      
    throw new IllegalArgumentException("Thrown from processSomething()");
  }

  @Override
  public void close() throws Exception {
      
    throw new NullPointerException("Thrown from close()");
  }

  public static void main(String[] args) throws Exception {
      
    try (ExceptionalResource exceptionalResource = new ExceptionalResource()) {
      
      exceptionalResource.processSomething();
    }
  }
}

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

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

扩展

try-catch-finally 中,如果 catch 中 return 了,finally 还会执行吗?

public class TryStudy {
      

    public static int test(String str){
      
        try {
      
            return str.charAt(0) - '0';
        }catch (Exception e){
      
            return 1;
        }finally {
      
            return 2;
        }
    }

    public static int test2(String str){
      
        try {
      
            return str.charAt(0) - '0';
        }catch (Exception e){
      
            return 1;
        }finally {
      
            System.out.println("finally....");
        }
    }
    public static void main(String[] args) {
      
        //当finally里有return时,返回finally里return的结果,撤销之前的return语句
        System.out.println(test(null)+","+test("3"));
        System.out.println("****************");
        //当try里无异常,且有返回值时,先执行finally再返回值;当try有异常,catch里有返回,先执行finally在return
        System.out.println(test2(null)+","+test2("3"));
    }
}
//执行结果为:
2,2
****************
finally....
finally....
1,3

异常处理完成以后,Exception对象会发生什么变化?

某个 Exception 异常被处理后,该对象不再被引用,gc 将其标记,在下一个回收过程中被回收。

 

💥看到这里的你,如果对于我写的内容很感兴趣,有任何疑问,欢迎在下面留言📥,会第一次时间给大家解答,谢谢!

点赞收藏
分类:标签:
hresh

会写代码的厨艺爱好者

请先登录,查看4条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

随机一门技术分享之Netty

随机一门技术分享之Netty

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

6
4