JVM系列之:聊一聊Java异常

简介: JVM系列之:聊一聊Java异常

1.jpg

本文为《深入学习 JVM 系列》第五篇文章


异常的基本概念


在 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 将其标记,在下一个回收过程中被回收。


目录
相关文章
|
1月前
|
Java
在 Java 中捕获和处理自定义异常的代码示例
本文提供了一个 Java 代码示例,展示了如何捕获和处理自定义异常。通过创建自定义异常类并使用 try-catch 语句,可以更灵活地处理程序中的错误情况。
67 1
|
1月前
|
Java API 调度
如何避免 Java 中的 TimeoutException 异常
在Java中,`TimeoutException`通常发生在执行操作超过预设时间时。要避免此异常,可以优化代码逻辑,减少不必要的等待;合理设置超时时间,确保其足够完成正常操作;使用异步处理或线程池管理任务,提高程序响应性。
76 12
|
1月前
|
监控 算法 Java
Java虚拟机(JVM)的垃圾回收机制深度解析####
本文深入探讨了Java虚拟机(JVM)的垃圾回收机制,旨在揭示其背后的工作原理与优化策略。我们将从垃圾回收的基本概念入手,逐步剖析标记-清除、复制算法、标记-整理等主流垃圾回收算法的原理与实现细节。通过对比不同算法的优缺点及适用场景,为开发者提供优化Java应用性能与内存管理的实践指南。 ####
|
1月前
|
Java
在 Java 中,如何自定义`NumberFormatException`异常
在Java中,自定义`NumberFormatException`异常可以通过继承`IllegalArgumentException`类并重写其构造方法来实现。自定义异常类可以添加额外的错误信息或行为,以便更精确地处理特定的数字格式转换错误。
39 1
|
25天前
|
监控 算法 Java
Java虚拟机(JVM)垃圾回收机制深度剖析与优化策略####
本文作为一篇技术性文章,深入探讨了Java虚拟机(JVM)中垃圾回收的工作原理,详细分析了标记-清除、复制算法、标记-压缩及分代收集等主流垃圾回收算法的特点和适用场景。通过实际案例,展示了不同GC(Garbage Collector)算法在应用中的表现差异,并针对大型应用提出了一系列优化策略,包括选择合适的GC算法、调整堆内存大小、并行与并发GC调优等,旨在帮助开发者更好地理解和优化Java应用的性能。 ####
32 0
|
22天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
24天前
|
存储 监控 算法
Java虚拟机(JVM)垃圾回收机制深度解析与优化策略####
本文旨在深入探讨Java虚拟机(JVM)的垃圾回收机制,揭示其工作原理、常见算法及参数调优方法。通过剖析垃圾回收的生命周期、内存区域划分以及GC日志分析,为开发者提供一套实用的JVM垃圾回收优化指南,助力提升Java应用的性能与稳定性。 ####
|
28天前
|
机器学习/深度学习 监控 算法
Java虚拟机(JVM)的垃圾回收机制深度剖析####
本文深入探讨Java虚拟机(JVM)的垃圾回收机制,揭示其工作原理、常见算法、性能调优策略及未来趋势。通过实例解析,为开发者提供优化Java应用性能的思路与方法。 ####
44 1
|
28天前
|
Oracle 安全 Java
深入理解Java生态:JDK与JVM的区分与协作
Java作为一种广泛使用的编程语言,其生态中有两个核心组件:JDK(Java Development Kit)和JVM(Java Virtual Machine)。本文将深入探讨这两个组件的区别、联系以及它们在Java开发和运行中的作用。
73 1
|
1月前
|
监控 Java 开发者
Java虚拟机(JVM)深度优化指南####
本文深入探讨了Java虚拟机(JVM)的工作原理及其性能优化策略,旨在帮助开发者通过理解JVM的内部机制来提升Java应用的运行效率。不同于传统的技术教程,本文采用案例分析与实战技巧相结合的方式,为读者揭示JVM调优的艺术。 ####
58 8