论类型转换导致 JVM 类加载提前报错的问题
今天下午,一朋友在某群 at 我,神秘兮兮的说道要考我一个问题。题目是这样的:
在 Java 中有 Father 和 Son 类,其中 Son 继承了 Father 类,两类均有
method
方法,现在 Main 类的main
方法有如下调用:Father f = new Son(); f.method();
问题是,编译此代码,完成后删除
Son.class
,请问代码会报错吗?
我嗤之以鼻,这还用问吗?我甚至可以告诉你这个代码会报的错一定是 NoClassDefFoundError
,这也太简单了你拿这个来考我 balabala...
然而朋友鬼魅一笑(?),你别急啊,题还没出完呢:
在上述代码的基础上,加入一个 flag
justFalse
,并环绕到上述代码中:// in Main.java static boolean justFalse = false; // in Main#main method if (justFalse) { Father f = new Son(); f.method(); }
同样编译后删除
Son.class
,请问代码还还会报错吗?
我大笑(?)道,这还用问?justFalse
永远是 false
,也就是说内部代码永远不可能执行到,那么 Son 类也就永远不可能进入初始化阶段,所以这个代码肯定就不会报错了,这也太简单了你拿这个来考我 balabala...
然后朋友发来的一张图让我沉默了:
竟然真的会报错,难道 JVM 虚拟机会提前解析并未执行的代码行中包含的类引用吗?不对啊,这和我以前的实践完全不一样,怎么会这样...... 就在我陷入自我怀疑的时候,下一题来了:
在上述代码的基础上,如果把
Father f = new Son();
修改为Son f = new Son();
,在同样的操作下,请问代码还还会报错吗?
我小心翼翼地问道:不会这样它就不会报错了吧...
朋友淡淡说道:正是。
我的脑海中此时一万匹草泥马奔驰而过,各种名词在我的大脑中穿过:类加载、静态分派、运行时多态、分支预测... 但没有一个能解释这个诡异的现象。
我的天塌了。
深入了解 JVM 类加载机制
当说到 JVM 的类加载机制,很多人可能会脱口而出:加载、验证、准备、解析、初始化。如果你接着问他,他可能还会告诉你,解析这个阶段在某些情况下可以在初始化阶段之后开始,这被 JVM 虚拟机称为“惰性解析("lazy" or "late" resolution)”。那么,出现上述情况的原因可能是因为惰性解析被提前了吗?
然而答案是否定的,在任何情况下,对于一个类,无论其静态分派的类型是什么,其解析都会延迟进行。(即使在 JVM specs 中这种行为是未定义的,虚拟机实现可以选择立刻解析或是延迟解析)
那么问题出在哪里了呢?经过一番查证,我发现这个报错其实是在 JVM 类加载的验证阶段产生的。
注意,这里说的不是验证 Son.class
或是 Father.class
,而是 Main.class
。如果你仔细观察上面给出的堆栈轨迹(在 Oracle JDK 1.8, Hotspot VM 下),其中有一段就是 sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:632)
。
在类加载阶段,JVM 虚拟机会试图校验一个类的某些部分是否是未被破坏以及符合预期的。在对 Main.class
类的加载过程中,对于 Father f = new Son();
和 f.method();
,产生了一个包含向上类型转换的多态函数调用,对于这种调用,JVM 虚拟机会试图进行校验,这就需要加载 Son.class
的类结构,而 Son.class
已经被我们删除了,所以产生了报错。
这种检查需要同时包含 typecast 以及多态函数调用,在上述代码中,无论将变量类型修改为变量的实际类型 Son
,亦或者删去对 method
方法的调用,那么也不会产生报错。
最后,如何验证上述推断是正确的呢?很简单,使用 -noverify
参数关闭 JVM 的类加载校验,你就可以发现上述代码正常运行了。
(上述代码在 Java 1.8 和 Java 21 的 Hotspot 虚拟机上均能复现。为方便行文,对部分内容有所改编。)
哥,别学了哥,你学的我心发慌
java真复杂啊(