谈谈异常

2018-05-18 java版web项目

前段时间,在公众号推荐了一下杨晓峰老师的专栏。其实在公众号推荐一些课程时,我还是比较犹豫的,主要是怕坑了一直支持我的同学们,我一般都会全方位的去了解这门课程后才推广;最近,晓峰老师在不断的更新自己的专栏,我也在听,自我感觉还是比较有收获的「可以在历史记录查看」。

晓峰老师在第二讲主要讲了关于Exception和Error的区别?本人之前也写过一篇关于异常的文章,但是没有深入去分析,只是做了一些简单的总结;这次从 JVM 底层异常处理的角度来谈谈本人对异常的认识,欢迎各位同学指正我文中不正确的地方。

一、为什么需要异常处理机制?

我相信大多数同学跟我一样,接触的第一门计算机语言便是 C 语言,C 语言是面向过程的编程语言,在异常的处理上,一般通过错误码或者约定俗成的方式来处理异常,而这种处理方式带来一个问题,大多数的程序员不会对错误状态进行检测并处理,如:printf() 的返回值有几个人在关心呢?

又比如,你写了一个框架或者组件,在编写的过程中预测出会在运行时发生异常「如网络抖动,资源问题等」,但是不知道怎么去处理它,需要「直接或间接」使用者自己去处理。因此,需要一种异常处理机制来解决这个问题。

说了这么多,归根结底异常处理机制最终的目的就是解决代码的可读性健壮性

二、Java 的异常结构

Java 很多东西都是借鉴于 C++,异常处理机制也不例外;比如,C++ 的 logic_error 类相当于 Java 中的 RuntimeException,用来表示程序中的逻辑错误;runtime_error 类相当于 Java 中的 Checked Exception,表示受检测异常。

在 Java 中“一切皆是对象”,毫不例外异常在 JVM 看来也是一个对象,所有的异常类的基类便是 Throwable 。在出现异常时,就会对当前的栈帧进行快照并生成一个异常对象「这是一个比较重的操作」,因此,对性能要求比较高的系统需要对异常设计进行优化。

可能你会有疑问:生成异常对象为什么耗时多呢?代码能很好的说明一切,见下图。

我们可以发现 fillInStackTrace 方法记录异常时的栈信息是一个独占锁操作,这便是引起它非常耗时的一个原因,如果我们在开发时不需要关注栈信息,则可以将其覆盖来提升性能,见下图。

测试后,发现性能提升在 10 倍左右。

接着,咱们通过一张图来了解一下 Java 的异常结构「这张图很重要,大多数面试题都是围绕这张图展开的」。

我们可以发现 Java 的异常对象的基类是 Throwable,然后由 Throwable 延展开来 Error 和 Exception,Exception 里面又分为 Runtime Exception 和 Checked Exception 两大类别。

为什么说这里的考点特别多呢?比如下面一些问题:

  1. 你实际开发中经常碰到哪些 Error 和 Exception?

  2. ClassNotFoundException 和 NoClassDefFoundError的区别?

  3. JVM 的哪一个内存区不会抛异常?其它内存区会抛哪些异常?

  4. 等等

对于这类型的问题还有很多,出题者的目的是要考察一个人对 Java 研究的深度和广度,而真正能回答出来了,要么真的很厉害,要么就是背书过后,所以我们要经常总结学过的知识点,才好在以后的面试中游刃有余。

怎么回答上面的问题呢?以第一个问题为例,如果是我的话,我一般会从 JVM 的内存区的角度出发来描述每一个内存区「栈、堆、方法区或者直接内存」会产生哪些异常,并且结合线上 JVM 调优的一些案例来详细阐述这个异常为什么引起的,怎么进行分析和排查的;如果你再继续抓住一个框架「比如Dubbo、Spring」来详细阐述它内部是怎么处理异常,甚至还可以聊聊异常链追踪系统「zipkin」的实现原理,聊到这里,大多数面试官基本上都满意了。

在我看来,面试跟相亲差不多。面试官应提前了解应聘者,结合面试者最擅长的东西来探讨,而不是一副欠了你钱的姿态...

三、JVM 怎么处理异常流程的?

一般来说,异常发生时,会在堆上生成一个异常对象「包含当前栈帧的快照」;然后停止当前的执行流程,将上面的异常对象从当前的 context 丢出「便于卸掉解决问题的职责」;此刻便由异常处理机制接手,寻找能继续执行的适当地点「即异常处理函数」,使程序继续执行。

下面咱们通过一个例子来具体说明,见下图:

咱们再通过 javap 工具来查看它生成的字节码,如下图:

从字节码角度,我们可以发现会生成一个异常表。try 的范围体现在异常表行记录的起点和终点。JVM 在 try 中捕获到异常,就会在当前栈帧的异常表中找到相匹配的异常入口指令号,然后跳转到该指令执行;异常指令执行完成再执行后面的代码。如果异常表中没有发现匹配的项,JVM会将当前栈帧从栈中弹出并抛出异常,交给异常处理机制。

其实我觉得异常处理里面最神秘的关键字便是 throw,为什么这么说呢?通过上面的例子,我们可以发现 throw 也会返回一个异常对象,但它的返回和函数的正常返回却又天壤之别,throw 背后会引发 JVM 进行一系列的异常处理操作,正所谓失之毫厘,差之千里。

athrow 指令的关键代码实现见代码:hotspot/src/share/vm/interpreter/bytecodeInterpreter.cpp。

有的小伙伴会问你怎么知道它的实现在这里呢?这要感谢 RednaxelaFX 大神写的怎么阅读 openjdk 源码的一系列文章。

查阅源码可以发现它使用 while(1) 的方式循环 swtich 寄存器指令。

我们可以发现 hotspot 源码的底层实现跟我们上面描述的一致,见下图:

_athrow case语句内部,第一句提取操作栈中引用的异常对象,第二句检测异常是否为空,第三局为当前线程设置异常对象。最后跳转到 handle_exception 标识。

在 handle_exception 代码块中,我画红线标记的地方值得仔细品味,第一处便是 CALL_VM() 函数的调用,它主要用于异常表的查找,具体实现在函数 InterpreterRuntime::exception_handler_for_exception()。如果找到,则if (continuation_bci >= 0) 这句成立,其中 bci 表示字节索引,则进入 SET_STACK_OBJECT(except_oop(), 0) 把异常对象重新入栈,pc = METHOD->code_base() + continuation_bci 表示重置PC指针为异常handler的起始位置,然后跳转到run处开始下一轮的循环switch过程。如果没有找到,则重新设置 pending_exception,具体实现见 THREAD->set_pending_exception(except_oop(), NULL, 0)。

在 handle_return 代码块中会根据 pending_exception 这个标志来决定方法是否出现异常,要不要退出,见下图画红线的代码。


到这里,我们通过源码大概理解了 JVM 底层到底怎么来处理异常的了。既然聊到这里,我想提醒同学们,了解理解贯通之间是有距离的,需要我们对一个问题不断的回过头来思辨和深挖。

四、异常的设计原则

撸了多年 Java 代码的老手都知道捕获异常的最佳时机便是编译期,但大多数的异常都需要在运行期才能发现,并且很难恢复。

那到底该怎么设计异常呢?其实网上有很多朋友在讨论,特别是 03 年对“为什么 Java 中要使用 Checked Exceptions ”这个主题,在网上闹的沸沸扬扬,感兴趣的小伙伴可以去搜一下。

本人从《Effective Java》这本经典著作,对异常设计的原则进行了大概的归纳,如下:

  • 不要忽略异常。

  • 对于可以恢复的情况使用检查异常,对于编程中的错误使用运行异常。

  • 异常是为异常流程设计的,不要将它们用于普通控制流程。

  • 优先使用标准异常。

  • 抛出与抽象相对应的异常。

  • 每个方法抛出的异常都要有文档。

  • 在细节消息中包含能捕获失败的信息。

  • 努力使失败保持原子性。

五、引申问题

  • Spring MVC 的异常处理机制是怎么样的?

  • Dubbo 底层怎么处理异常的?

  • 分布式服务的异常链路追踪是怎么实现的?

上面这些问题,我会全部放在我的知识星球上面,跟星球里面的同学一起讨论,如果感兴趣的话,可以加入我的知识星球来分享。

六、参考

  • 《Effective Java》

  • 《Java 编程思想》

  • 《Java 核心技术 卷I》

  • http://openjdk.java.net

  • http://www.iteye.com/topic/2038

本站所有内容仅供本人订阅使用!RSS + Sitemap