谈谈异常
前段时间,在公众号推荐了一下杨晓峰老师的专栏。其实在公众号推荐一些课程时,我还是比较犹豫的,主要是怕坑了一直支持我的同学们,我一般都会全方位的去了解这门课程后才推广;最近,晓峰老师在不断的更新自己的专栏,我也在听,自我感觉还是比较有收获的「可以在历史记录查看」。
晓峰老师在第二讲主要讲了关于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 两大类别。
为什么说这里的考点特别多呢?比如下面一些问题:
你实际开发中经常碰到哪些 Error 和 Exception?
ClassNotFoundException 和 NoClassDefFoundError的区别?
JVM 的哪一个内存区不会抛异常?其它内存区会抛哪些异常?
等等
对于这类型的问题还有很多,出题者的目的是要考察一个人对 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 源码的底层实现跟我们上面描述的一致,见下图:
在 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
技术的乐趣在于分享而不是独享,独乐乐不如众乐乐。如果觉得我的文字不错,可以随意打赏 ~~~