JVM 基本原理(2)
JVM 是如何处理异常的
异常处理的两大组成要素是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移。
抛出异常可分为显式和隐式两种;
- 显式抛异常的主体是应用程序,指在程序中使用“throw”关键字,手动将异常实例抛出。
- 隐式抛异常的主体是 Java 虚拟机,指 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。
捕获异常则涉及了如下三种代码块;
- try 代码块:
用来标记需要进行异常监控的代码。 - catch 代码块:
跟在 try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理器。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会从上至下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。 - finally 代码块:
跟在 try 代码块和 catch 代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。
在程序正常执行的情况下,这段代码会在 try 代码块之后运行。否则,也就是 try 代码块触发异常的情况下,如果该异常没有被捕获,finally 代码块会直接运行,并且在运行之后重新抛出该异常。
如果该异常被 catch 代码块捕获,finally 代码块则在 catch 代码块之后运行。在某些不幸的情况下,catch 代码块也触发了异常,那么 finally 代码块同样会运行,并会抛出 catch 代码块触发的异常。在某些极端不幸的情况下,finally 代码块也触发了异常,那么只好中断当前 finally 代码块的执行,并往外抛异常。
如果 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是后者。也就是说原本的异常便会被忽略掉。
异常的基本概念
在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类;
- Error
涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。 - Exception
涵盖程序可能需要捕获并且处理的异常。
Exception 有一个特殊的子类 RuntimeException,用来表示“程序虽然无法继续执行,但是还能抢救一下”的情况。
RuntimeException 和 Error 属于非检查异常。其他的 Exception 皆属于检查异常,在触发时需要显式捕获,或者在方法头用 throws 关键字声明。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。
Java 虚拟机是如何捕获异常的?
在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。
其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。
当程序触发异常时,Java 虚拟机将查找异常表,并依此决定需要将控制流转移至哪个异常处理器之中。Java 代码中的 catch 代码块和 finally 代码块都会生成异常表条目。
Java 7 的 Supressed 异常以及语法糖
Java 7 引入了 Supressed 异常、try-with-resources,以及多异常捕获。后两者属于语法糖,能够极大地精简我们的代码。
JVM 是如何实现反射的
反射是 Java 语言中一个相当重要的特性,它允许正在运行的 Java 程序观测,甚至是修改程序的动态行为。
反射调用的实现
方法的反射调用(Method.invoke),它实际上委派给 MethodAccessor(一个接口) 来处理,它有两个已有的具体实现:
- 一个通过本地方法来实现反射调用
- 另一个则使用了委派模式
在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用(当进入了 JVM 内部后,便拥有了 Method 实例所指向方法的具体地址。这时,反射调用无非就是将传入的参数准备好,然后调用进入目标方法)。在调用超过 15 次(阈值)之后(仅调用一次的话,本地实现的运行效率更快,否则动态实现更快),委派实现便会将委派对象切换至动态实现(Inflation)。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。
反射调用的开销
Class.forName(会调用本地方法),Class.getMethod(会遍历该类的公有方法。如果没有匹配到,它还将遍历父类的公有方法),这两个操作都非常费时。
方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object 数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。
JVM 是怎么实现 invokedynamic 的
方法句柄是一个强类型的、能够被直接执行的引用。它仅关心所指向方法的参数类型以及返回类型,而不关心方法所在的类以及方法名。方法句柄的权限检查发生在创建过程中,相较于反射调用节省了调用时反复权限检查的开销。
方法句柄可以通过 invokeExact 以及 invoke 来调用。其中,invokeExact 要求传入的参数和所指向方法的描述符严格匹配。方法句柄还支持增删改参数的操作,这些操作是通过生成另一个充当适配器的方法句柄来实现的。
方法句柄的调用和反射调用一样,都是间接调用,同样会面临无法内联的问题。
invokedymaic 指令抽象出调用点的概念,并且将调用该调用点所链接的方法句柄。在第一次执行 invokedynamic 指令时,Java 虚拟机将执行它所对应的启动方法,生成并且绑定一个调用点。之后如果再次执行该指令,Java 虚拟机则直接调用已经绑定了的调用点所链接的方法。
Lambda 表达式到函数式接口的转换是通过 invokedynamic 指令来实现的。该 invokedynamic 指令对应的启动方法将通过 ASM 生成一个适配器类。
对于没有捕获其他变量的 Lambda 表达式,该 invokedynamic 指令始终返回同一个适配器类的实例。对于捕获了其他变量的 Lambda 表达式,每次执行 invokedynamic 指令将新建一个适配器类实例。
不管是捕获型的还是未捕获型的 Lambda 表达式,它们的性能上限皆可以达到直接调用的性能。其中,捕获型 Lambda 表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作。
链接
参考资料:
极客时间-深入拆解Java虚拟机