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


目录
相关文章
|
7月前
|
监控 Java Unix
6个Java 工具,轻松分析定位 JVM 问题 !
本文介绍了如何使用 JDK 自带工具查看和分析 JVM 的运行情况。通过编写一段测试代码(启动 10 个死循环线程,分配大量内存),结合常用工具如 `jps`、`jinfo`、`jstat`、`jstack`、`jvisualvm` 和 `jcmd` 等,详细展示了 JVM 参数配置、内存使用、线程状态及 GC 情况的监控方法。同时指出了一些常见问题,例如参数设置错误导致的内存异常,并通过实例说明了如何排查和解决。最后附上了官方文档链接,方便进一步学习。
935 4
|
3月前
|
安全 Oracle Java
JAVA高级开发必备·卓伊凡详细JDK、JRE、JVM与Java生态深度解析-形象比喻系统理解-优雅草卓伊凡
JAVA高级开发必备·卓伊凡详细JDK、JRE、JVM与Java生态深度解析-形象比喻系统理解-优雅草卓伊凡
283 0
JAVA高级开发必备·卓伊凡详细JDK、JRE、JVM与Java生态深度解析-形象比喻系统理解-优雅草卓伊凡
|
11月前
|
监控 算法 Java
Java虚拟机(JVM)垃圾回收机制深度剖析与优化策略####
本文作为一篇技术性文章,深入探讨了Java虚拟机(JVM)中垃圾回收的工作原理,详细分析了标记-清除、复制算法、标记-压缩及分代收集等主流垃圾回收算法的特点和适用场景。通过实际案例,展示了不同GC(Garbage Collector)算法在应用中的表现差异,并针对大型应用提出了一系列优化策略,包括选择合适的GC算法、调整堆内存大小、并行与并发GC调优等,旨在帮助开发者更好地理解和优化Java应用的性能。 ####
288 27
|
4月前
|
存储 运维 Kubernetes
Java启动参数JVM_OPTS="-Xms512m -Xmx1024m -XX:+HeapDumpOnOutOfMemoryError"
本文介绍了Java虚拟机(JVM)常用启动参数配置,包括设置初始堆内存(-Xms512m)、最大堆内存(-Xmx1024m)及内存溢出时生成堆转储文件(-XX:+HeapDumpOnOutOfMemoryError),用于性能调优与故障排查。
406 0
|
5月前
|
Java 程序员 数据库连接
我们详细地讲解一下 Java 异常及要如何处理
我是小假 期待与你的下一次相遇 ~
123 1
|
6月前
|
存储 监控 算法
Java程序员必学:JVM架构完全解读
Java 虚拟机(JVM)是 Java 编程的核心,深入理解其架构对开发者意义重大。本文详细解读 JVM 架构,涵盖类加载器子系统、运行时数据区等核心组件,剖析类加载机制,包括加载阶段、双亲委派模型等内容。阐述内存管理原理,介绍垃圾回收算法与常见回收器,并结合案例讲解调优策略。还分享 JVM 性能瓶颈识别与调优方法,分析 Java 语言特性对性能的影响,给出数据结构选择、I/O 操作及并发同步处理的优化技巧,同时探讨 JVM 安全模型与错误处理机制,助力开发者提升编程能力与程序性能。
Java程序员必学:JVM架构完全解读
|
12月前
|
监控 算法 Java
Java虚拟机(JVM)的垃圾回收机制深度解析####
本文深入探讨了Java虚拟机(JVM)的垃圾回收机制,旨在揭示其背后的工作原理与优化策略。我们将从垃圾回收的基本概念入手,逐步剖析标记-清除、复制算法、标记-整理等主流垃圾回收算法的原理与实现细节。通过对比不同算法的优缺点及适用场景,为开发者提供优化Java应用性能与内存管理的实践指南。 ####
|
8月前
|
存储 缓存 算法
JVM简介—1.Java内存区域
本文详细介绍了Java虚拟机运行时数据区的各个方面,包括其定义、类型(如程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和直接内存)及其作用。文中还探讨了各版本内存区域的变化、直接内存的使用、从线程角度分析Java内存区域、堆与栈的区别、对象创建步骤、对象内存布局及访问定位,并通过实例说明了常见内存溢出问题的原因和表现形式。这些内容帮助开发者深入理解Java内存管理机制,优化应用程序性能并解决潜在的内存问题。
372 29
JVM简介—1.Java内存区域
|
8月前
|
SQL Java 中间件
【YashanDB知识库】yasdb jdbc驱动集成BeetISQL中间件,业务(java)报autoAssignKey failure异常
在BeetISQL 2.13.8版本中,客户使用batch insert向yashandb表插入数据并尝试获取自动生成的sequence id时,出现类型转换异常。原因是beetlsql在prepareStatement时未指定返回列,导致yashan JDBC驱动返回rowid(字符串),与Java Bean中的数字类型tid不匹配。此问题影响业务流程,使无法正确获取sequence id。解决方法包括:1) 在batchInsert时不返回自动生成的sequence id;2) 升级至BeetISQL 3,其已修正该问题。
【YashanDB知识库】yasdb jdbc驱动集成BeetISQL中间件,业务(java)报autoAssignKey failure异常
|
8月前
|
SQL druid Oracle
【YashanDB知识库】yasdb jdbc驱动集成druid连接池,业务(java)日志中有token IDENTIFIER start异常
客户Java日志中出现异常,影响Druid的merge SQL功能(将SQL字面量替换为绑定变量以统计性能),但不影响正常业务流程。原因是Druid在merge SQL时传入null作为dbType,导致无法解析递归查询中的`start`关键字。