性能文章>卷向字节码-Java异常到底是怎么被处理的?>

卷向字节码-Java异常到底是怎么被处理的?原创

https://a.perfma.net/img/2382850
3年前
5947210

你好呀,我是why,你也可以叫我歪歪。

比如下面这位读者:

他是看了我《神了!异常信息突然就没了?》这篇文章后产生的疑问。

既然是看了我的文章带来的进一步思考,恰巧呢,我又刚好知道。

虽然这类文章看的人少,但是我还是来填个坑。

害,真是暖男石锤了。

异常怎么被抛出的。

先上一个简单代码片段:

运行结果大家都是非常的熟悉。

光看这仅有的几行代码,我们是探索不出来什么有价值的东西。

我们都知道运行结果是这样的,没有任何毛病。

这是知其然。

那么所以然呢?

所以然,就藏在代码背后的字节码里面。

通过 javap 编译之后,上面的代码的字节码是这样:

我们主要关注下面部分,字节码指令对应的含义我也在后面注释一下:

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1 //将int型的1推送至栈顶
       1: iconst_0 //将int型的0推送至栈顶
       2: idiv     //将栈顶两int型数值相除并将结果压入栈顶
       3: istore_1 //将栈顶int型数值存入第二个本地变量
       4: return   //从当前方法返回 void

别问我怎么知道字节码的含义的,翻表就行了,这玩意谁背得住啊。

通过字节码,好像也没看出什么玄机来。

但是,你先记着这个样子,马上我给你表演一个变形:

public class MainTest {

    public static void main(String[] args) {
        try {
            int a = 1 / 0;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

用 try-catch 把代码包裹起来,捕获一下异常。

再次用 javap 编译之后,字节码变成了这个样子:

可以明显的看到,字节码发生了变化,至少它变长了。

主要还是关注我框起来的部分。

把两种情况的字节码拿来做个对比:

对比一下就很清楚了,加入 try-catch 之后,原有的字节码指令一行不少。

没有被框起来的,就是多出来的字节码指令。

而多出来的这部分,其中有个叫做 Exception table 尤为明显:

异常表,这个玩意,就是 JVM 拿来处理异常的。

至于这里每个参数的含义是什么,我们直接绕过网上的“二手”资料,到官网上找文档:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.3

看起来英文很多,很有压力,但是不要怕,有我呢,我挑关键的给你 say:

首先 start_pc、end_pc 是一对参数,对应的是 Exception table 里面的 from 和 to,表示异常的覆盖范围。

比如前面的 from 是 0 ,to 是 4,代表的异常覆盖的字节码索引就是这个范围:

0: iconst_1 //将int型的1推送至栈顶
1: iconst_0 //将int型的0推送至栈顶
2: idiv     //将栈顶两int型数值相除并将结果压入栈顶
3: istore_1 //将栈顶int型数值存入第二个本地变量

有个细节,不知道你注意到了没有。

范围不包含 4,范围区间是这样的 [start_pc, end_pc)。

而至于为什么没有包含 end_pc,这个就有点意思了。

拿出来讲讲。

The fact that end_pc is exclusive is a historical mistake in the design of the Java Virtual Machine: if the Java Virtual Machine code for a method is exactly 65535 bytes long and ends with an instruction that is 1 byte long, then that instruction cannot be protected by an exception handler. A compiler writer can work around this bug by limiting the maximum size of the generated Java Virtual Machine code for any method, instance initialization method, or static initializer (the size of any code array) to 65534 bytes.

不包含 end_pc 是 JVM 设计过程中的一个历史性的错误。

因为如果 JVM 中一个方法编译后的代码正好是 65535 字节长,并且以一条 1 字节长的指令结束,那么该指令就不能被异常处理机制所保护。

编译器作者可以通过限制任何方法、实例初始化方法或静态初始化器生成的代码的最大长度来解决这个错误。

上面就是官网的解释,反正就是看的似懂非懂的。

没关系,跑个例子就知道了:

当我代码里面只有一个方法,且长度为 16391 行时,编译出来的字节码长度为 65532。

而通过前面的分析我们知道,一行 a=1/0 的代码,会被编译成 4 行字节码。

那么只要我再加一行代码,就会超出限制,这个时候再对代码进行编译,会出现什么问题呢?

看图:

直接编译失败,告诉你代码过长。

所以你现在知道了一个知识点:一个方法的长度,从字节码层面来说是有限制的。但是这个限制算是比较的大,正常人是写不出这样长度的代码的。

虽然这个知识点没啥卵用,但是要是你在工作中真的碰到了一个方法长度成千上万行,即使没有触发字节码长度限制,我也送你一个字:快跑。

接着说下一个参数 handler_pc,对应的是 Exception table 里面的 target。

其实它非常好理解,就是指异常处理程序开始的那条指令对应的索引。

比如这里的 target 是 7 ,对应的就是 astore_1 指令:

也就是告诉 JVM,如果出异常了,请从这里开始处理。

最后,看 catch_type 参数,对应的是 Exception table 里面的 type。

这里就是程序捕获的异常。

比如我把程序修改为这样,捕获三种类型的异常:

那么编译后的字节码对应的异常表所能处理的 type 就变成了这三个:

至于我这里为什么不能写个 String 呢?

别问,问就是语法规定。

具体是啥语法规定呢?

就在异常表的这个地方:

编译器会检查该类是否是 Throwable 或 Throwable 的子类。

关于 Throwable、Exception、Error、RuntimeException 就不细说了,生成一个继承关系图给大家看就行了:

所以,上面的消息汇总一下:

  • from:可能发生异常的起始点指令索引下标(包含)
  • to:可能发生异常的结束点指令索引下标(不包含)
  • target:在from和to的范围内,发生异常后,开始处理异常的指令索引下标
  • type:当前范围可以处理的异常类信息

知道了异常表之后,可以回答这个问题了:异常怎么被抛出的?

JVM 通过异常表,帮我们抛出来的。

异常表里面有啥?

前面我说了,不再赘述。

异常表怎么用呢?

简单描述一下:

1.如果出现异常了,JVM 会在当前的方法中去寻找异常表,查看是否该异常被捕获了。<br>2.如果在异常表里面匹配到了异常,则调用 target 对应的索引下标的指令,继续执行。

好,那么问题又来了。如果匹配不到异常怎么办呢?

我在官网文档的这里找到了答案:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html#jvms-3.12

它的示例代码是这样的:

然后下面有这样的一句描述:

意思就是如果抛出的值与 catchTwo 的任何一个 catch 子句的参数不匹配,Java虚拟机就会重新抛出该值,而不调用 catchTwo 的任何一个 catch 子句中的代码。

什么意思?

说白了就是反正我处理不了,我会把异常扔给调用方。

这是编程常识,大家当然都知道。

但是当常识性的东西,以这样的规范的描述展示在你面前的时候,感觉还是挺奇妙的。

当别人问你,为什么是这样的调用流程的时候,你说这是规定。

当别人问你,规定在哪的时候,你能把官网文档拿出来扔他脸上,指着说:就是这里。

虽然,好像没啥卵用。

稍微特殊的情况

这一趴再简单的介绍一下有 finally 的情况:

public class MainTest {
   public static void main(String[] args) {
       try {
           int a = 1 / 0;
       } catch (Exception e) {
           e.printStackTrace();
       } finally {
           System.out.println("final");
       }
   }
}

经过 javap 编译后,异常表部分出现了三条记录:

第一条认识,是我们主动捕获的异常。

第二三条都是 any,这是啥玩意?

答案在这:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html#jvms-3.13

主要看我画线的地方:

一个带有 finally 子句的 try 语句被编译为有一个特殊的异常处理程序,这个异常处理程序可以处理在 try 语句中抛出的(any)任何异常。

所有,翻译一下上面的异常表就是:

  • 如果 0 到 4 的指令之间发生了 Exception 类型的异常,调用索引为 15 的指令,开始处理异常。
  • 如果 0 到 4 的指令之间,不论发生了什么异常,都调用索引为 31 的指令(finally 代码块开始的地方)
  • 如果 15 到 20 的指令之间(也就是 catch 的部分),不论发生了什么异常,都调用索引为 31 的指令。

接着,我们把目光放到这一部分:

怎么样,发现了没?就问你神不神奇?

在源码中,只在 finally 代码块出现过一次的输出语句,在字节码中出现了三次。

finally 代码块中的代码被复制了两份,分别放到了 try 和 catch 语句的后面。再配合异常表使用,就能达到 finally 语句一定会被执行的效果。

以后再也不怕面试官问你为什么 finally 一定会执行了。

虽然应该也没有面试官会问这样无聊的问题。

问起来了,就从字节码的角度给他分析一波。

当然了,如果你非要给我抬个杠,聊聊 System.exit 的情况,就没多大意义了。

最后,关于 finally,再讨论一下这个场景:

public class MainTest {
    public static void main(String[] args) {
        try {
            int a = 1 / 0;
        } finally {
            System.out.println("final");
        }
    }
}

这个场景下,没啥说的, try 里面抛出异常,触发 finally 的输出语句,然后接着被抛出去,打印在控制台:

如果我在 finally 里面加一个 return 呢?

可以看到,运行结果里面异常都没有被抛出来:

为什么呢?

答案就藏在字节码里面:

其实已经一目了然了。

右边的 finally 里面有 return,并没有 athrow 指令,所以异常根本就没有抛出去。

这也是为什么建议大家不要在 finally 语句里面写 return 的原因之一。

冷知识

再给大家补充一个关于异常的冷知识吧。

还是上面这个截图。你有没有觉得有一丝丝的奇怪?

夜深人静的时候,你有没有想过这样的一个问题:

程序里面并没有打印日志的地方,那么控制台的日子是谁通过什么地方打印出来的呢?

是谁干的?

这个问题很好回答,猜也能猜到,是 JVM 帮我们干的。

什么地方?

这个问题的答案,藏在源码的这个地方,我给你打个断点跑一下,当然我建议你也打个断点跑一下:

java.lang.ThreadGroup#uncaughtException

而在这个地方打上断点,根据调用堆栈顺藤摸瓜可以找到这个地方:

java.lang.Thread#dispatchUncaughtException

看方法上的注释:

This method is intended to be called only by the JVM.

翻译过来就是:这个方法只能由 JVM 来调用。

既然源码里面都这样说了,我们可以去找找对应的源码嘛。

https://hg.openjdk.java.net/jdk7u/jdk7u/hotspot/file/5b9a416a5632/src/share/vm/runtime/thread.cpp

在 openJdk 的 thread.cpp 源码里面确实是找到了该方法被调用的地方:

而且这个方法还有个有意思的用法。

看下面的程序和输出结果:

我们可以自定义当前线程的 UncaughtExceptionHandler,在里面做一些兜底的操作。

有没有品出来一丝丝全局异常处理机制的味道?

好了,再来最后一个问题:

我都这样问了,那么答案肯定是不一定的。

你就想想,发挥你的小脑袋使劲的想,啥情况下 try 里面的代码抛出了异常,外面的 catch 不会捕捉到?

来,看图:

没想到吧?

这样处理一下,外面的 catch 就捕捉不到异常了。

是不是很想打我。

别慌,上面这样套娃多没意思啊。

你再看看我这份代码:

public class MainTest {
    public static void main(String[] args) {
        try {
            ExecutorService threadPool = Executors.newFixedThreadPool(1);
            threadPool.submit(()->{
               int a=1/0;
            });
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

你直接拿去执行,控制台不会有任何的输出。

来看动图:

是不是很神奇?

不要慌,还有更绝的。

把上面的代码从 threadPool.submit 修改为 threadPool.execute 就会有异常信息打印出来了:

但是你仔细看,你会发现,异常信息虽然打印出来了,但是也不是因为有 catch 代码块的存在。

具体是为啥呢?

参见这篇文章,我之前详细讲过的:《关于多线程中抛异常的这个面试题我再说最后一次!》

最后说一句

好了,看到了这里安排个关注吧。

感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

点赞收藏
why技术
请先登录,查看2条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

随机一门技术分享之Netty

随机一门技术分享之Netty

从 Linux 内核角度探秘 JDK MappedByteBuffer

从 Linux 内核角度探秘 JDK MappedByteBuffer

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

10
2