再扒 JAVA 中的异常处理
https://i-beta.cnblogs.com/posts/edit;postId=12097856 上面的博客记录了异常控制流的原理,我们基于异常处理流,再来扒一下 JAVA 中的异常处理。
上篇博客中提过,异常的触发和处理是分层级的,总的来说,有以下几种(个人总结):
1. 由硬件触发,操作系统处理。
该类异常由硬件逻辑自行检查并触发。硬件自身检测到逻辑异常后向 CPU 中断引脚发送中断信号,使 CPU 跳转到异常向量表查找异常处理程序。
中断处理程序由操作系统启动时,注册到 CPU 被指定位置的异常向量表。
比如著名的除零异常便是由硬件检测,由操作系统处理。所以在没有操作系统的情况下,如果向处理器发送一个除零指令,处理器会跑飞。
处理器层将异常反馈给操作系统层的路方式是中断。
2. 由操作系统触发,操作系统处理。
除了硬件定义的异常外,操作系统需要为自身可能发生的逻辑错误对异常进行扩展。这部分异常由操作系统定义、检查和触发。
对于操作系统层,异常的处理更加灵活。即可以借助处理器指令集中的中断指令(如int指令)发出软中断,根据中断号查询 IDT 表跳转到某异常处理程序进行处理,当然真实情况下操作系统会有更加结构化的处理方式,比如 windows 下的 SEH。
3. 由操作系统触发,进程处理。
在早期的 C 语言中,是没有异常处理的概念的,一个函数是否执行成功需要调用方通过返回值判断并作出对应的处理。
比如 C 调用分配内存的系统函数,如果内存分配成功,则返回内存地址;如果分配失败则返回 0 ,应用层在调用时需要根据返回值判断操作系统是否发生了异常,作出相应的响应。当然这种情况对于操作系统来说并不算异常,但对应用来说算。
另一种情况是,如果检查到进程有相应的信号处理函数(Linux下,windows则是向SEH直接注册进程的异常处理函数?这点我不确定(1)),操作系统发生异常后向应用程序进程所属的 PCB 发送了信号,在进程被再次调度时,会触发信号处理程序进行处理。
比如 Linux 下的 int SIGFPE 信号,由操作系统告知应用程序发生了除零或整数溢出异常,进程在再次被调度时发现该信号并进入异常处理程序,应用程序会尝试寻找处理该信号的信号处理函数,如果找到,则进行处理,比如 JAVA 中会针对除零信号封装一个 ArithmeticException 异常并抛出。
我们看一个对除零异常信号进行信号处理的例子:
#include <stdio.h> #include <signal.h> #include <setjmp.h> jmp_buf env; void handler (int signal) { printf("caught exception\n"); longjmp(env, 2); } int main(int argc, char* argv[]) { { struct sigaction sa = {}; sa.sa_handler = handler; if(sigaction(SIGFPE, &sa, NULL) == -1) { perror("sigaction"); } } if (0 == setjmp(env)) { int a = 3/(argc - 1); printf("a is %d\n", a); } else { /*exception*/ printf("in exception\n"); } return 1; }
异常处理线程与工作线程共享一个堆栈标量结构体 env ,信号处理函数负责将工作线程 longjump 到 setjump 处进行堆栈回滚和异常处理。
而如果JVM没有定义 SIGFPE 信号的处理函数,操作系统在进入异常处理程序后会直接采用操作系统注册的默认处理函数,kill 掉我们的进程或者提示错误,万幸的是这种情况不会发生。
4. 由进程触发,进程处理。
对于上层的应用来说,硬件及操作系统定义的异常显然是不够用的,千变万化的需求下我们必然有更多的逻辑错误需要进行处理,比如 InterruptedException 。
其实抛开硬件层及操作系统层的错误,应用层本身的异常便是一些我们可以自己检查的逻辑错误,我们通过 if-else 即可将其检查出来并进行处理。
如上面所说早期的 C 语言也确实是这样做的,但是这样做的缺点是,我们需要一堆 if-else 判断来检查异常,并且他们与我们的业务代码耦合在一起,使我们的代码变的非常混乱。
所以前辈们为我们提供了更加优雅的处理方式:非本地跳转。
即 C 中的 setjump 与 longjump ,我们通过 setjump 保存某处的堆栈信息到某处内存,longjump 将堆栈回滚到 setjump 保存的位置并设置 setjump 的返回值,根据 longjump 设置的 setjump 的返回值进行二次判断,程序应该跳转到何处。非本地跳转相比本地跳转增加了跳转的灵活性,因为其无视了正常的堆栈规则,不再拘泥于在一个栈帧内的蝇营狗苟,而是不管调用层级的对堆栈进行无条件的回滚。
try-catch 便是其更加结构化的版本,try 便是对 setjump 的封装,throw 便是对 longjump 的封装,catch 是对跳转处的封装。通过 try-catch ,我们将异常处理与业务逻辑区分开来,throw 分隔了发生异常时的位置,throw 下方的代码在发生异常后将不会被执行。
可以看到,异常处理本质上是对逻辑错误的检查。而使情况变复杂的是,被调用方需要把自己检查出的异常传递给上层的调用方,硬件采取的方式是中断,操作系统采取的方式是信号。
这种方式符合异常处理的逻辑:提早抛出,延迟捕获。
我们过早的捕获异常,比如操作系统发生异常后不交给进程,而是自己处理。因为操作系统并不清楚进程中代码的逻辑以及该异常可能会对进程造成的影响,所以只能采取最暴力的方式--终止进程来防止其错误的运行下去。
因此操作系统会先尝试让进程自己处理异常,仅对进程无法处理的异常采取铁腕手段。
这么一来,JAVA中的异常实际分两类:
1. 操作系统层面已定义的异常,JAVA 通过建立相应的异常类与之对应,在收到操作系统发来的 signal 后由信号处理程序协助线程抛出(当然我们也可以自己抛出)。
2. JAVA 层面自定义的异常,由 JVM 或我们自己定义,自己 throw 。
而无论那种异常,其捕获和处理都是基于结构化的长跳转:try-catch 机制。
在 class 字节码文件中,每个类都会有一张方法表记录该类中的方法,而方法表中有一个叫做异常表的可选项,记录着该方法 try 的范围以及 catch 可处理的异常类型。
每次 try 都会保存当时的堆栈副本,而每当异常发生时,在 longjump 前会先查询最近的 try 所在的方法是否可处理该类异常,如果不行则沿着调用链继续向上寻找(异常展开),找到则将堆栈恢复为该处 try 保留的堆栈副本,并进入异常处理程序。而如果一直找到最顶层(如 main 函数)都未找到可处理该类异常的 try ,则线程退出。
总的来说,我们需要从两个角度来认识异常:异常的抛出、异常的捕获和处理。
异常可能会在各个层面(硬件层、操作系统层、应用层)抛出,对于本层面来说,异常便是一个逻辑错误。硬件抛出异常的方式是处理器触发中断、操作系统抛出异常的方式是使用软中断陷入异常处理程序(向进程发送信号的逻辑也在异常处理程序中)、应用层则依赖本身的语法用自己能捕获的方式自己抛出。
对于跨层面的捕获,我们通过中断或信号机制对异常进行传递。而对于同层面内部的捕获,我们通过保存和回滚堆栈来进行长跳转。
对于 JVM 来说,其通过本身定义的数据结构:字节码文件中的方法表、异常表 来为非本地跳转寻找跳转的落脚点。