写在前面:学JVM的人十有八九会被类加载这几个概念绕晕:符号引用、直接引用、解析、动态分派。我之前也是,看一遍忘一遍,后来终于搞明白了,今天一次性写清楚。本文适合有一定Java基础想深入JVM的同学,如果你是第一次接触类加载,建议先收藏回头再看。
目录
- 先抛出三个问题
- 符号引用到底是啥
- 直接引用又是什么
- 解析到底在干什么
- 为什么解析了还会动态分派
- 总结
先抛出三个问题
在开始讲之前,先问大家三个问题,看看你能不能答上来:
- class文件里为什么不直接写内存地址?
- 解析完为什么还会有动态分派?
- invokestatic和invokevirtual区别到底在哪?
如果你答不上来,或者答得模模糊糊,那这篇文章就是写给你的。
符号引用到底是啥
先记住一句话:符号引用就是写名字 。class文件里为什么只能写名字呢?因为类还没加载,地址根本不知道。你总不能在编译的时候就去写一个内存地址吧?这次写完下次改了就全乱了。所以class文件里只能写一些"描述性"的信息,比如java/lang/String、age:I、println:(Ljava/lang/String;)V这些看起来很奇怪的东西。这些就是符号引用。
你可能会问,这些奇怪的东西有什么用?举几个例子你就懂了。常量池里常见的符号引用有几种:第一种是类符号引用,比如com/example/User,就是告诉你这个类叫什么;第二种是字段符号引用,属于哪个类加字段名加描述符,比如User age:I;第三种是方法符号引用,属于哪个类加方法名加描述符,比如println:(Ljava/lang/String;)V。本质上都是用"名字+描述信息"的方式来定位目标,而不是用地址。
这就是为什么class文件能跨平台的原因。因为它不依赖任何具体的内存布局,只依赖这些"符号信息"。等JVM实际加载类的时候,再把这些符号变成真正能定位的东西。
直接引用又是什么
直接引用就是拿到这个东西在JVM里的真实落点。等到类加载的时候,JVM内部有了真实的数据结构,这时候就可以把名字变成能直接定位目标的东西了。可能是指针,可能是偏移量,也可能是个句柄。总之这个时候就不再是"名字"了,而是"能找到人的地址",这就是直接引用。
你可能见过一些书上说直接引用就是"内存地址",这个说法不够准确。更准确的理解应该是:能让JVM直接或间接快速定位到目标的内部表示。不同JVM实现可能不一样,有的是指针,有的可能是句柄。所以别死记"地址"这个词,理解本质就行。
解析到底在干什么
解析就是把这个名字查成真实落点。简单说就是:class文件常量池里原来记的是符号引用,JVM在需要用的时候把它查成直接引用,然后缓存起来。这个过程就是解析。
具体怎么查呢?咱们用一个例子来说明。假设字节码里有个A.foo()调用,class文件里并不是写"foo方法在内存地址0x1234",而是写类名A、方法名foo、描述符()V这么三条信息。解析的时候JVM会做以下几件事:
第一步,找到类A的元数据。如果类A还没加载,那就触发类加载流程把它加载进来。
第二步,在类A的方法表里按"方法名+描述符"去找foo()这个方法。如果找不到或者方法签名不对,那就报错。
第三步,校验访问权限。比如foo()是private方法,但你是个外部类想访问,那就抛异常。
第四步,找到对应的方法元数据,可能是方法表的某个偏移量,也可能是某个指针。
第五步,把常量池里这项从"未解析"标记为"已解析",下次再用就不需要重复解析了。
这个过程听起来复杂,但实际上JVM优化做得很好,很多情况下都是即时解析,用到才解析,不一定类一加载就把所有符号引用都解析完。
为什么解析了还会动态分派
很多人到这里就会问了:既然解析完了都已经知道方法在哪里了,为什么还会有动态分派这回事?这正是最容易搞混的地方。
先说结论:解析不等于最终调用哪个版本已经完全确定。因为不同字节码指令,确定程度不一样。
先说解析后就定了的。静态方法、私有方法、构造方法、super调用这些都算"早绑定",解析完基本上就定了。比如你用invokestatic调用一个静态方法,符号引用解析后直接就找到唯一那个方法,运行时直接调就完事,没有任何悬念。再比如私有方法,private方法不能被子类重写,所以解析完也唯一。构造方法也是同理。
再说解析后还没定的。重写方法调用和接口调用就不一样了。典型的例子就是下面这段代码:
java
class Animal {
void eat() {
System.out.println("Animal eating");
}
}
class Dog extends Animal {
@Override
void eat() {
System.out.println("Dog eating");
}
}
public class Test {
public static void main(String[] args) {
Animal a = new Dog();
a.eat();
}
}
字节码里只知道调用的是Animal.eat:()V这个符号形式,解析的时候JVM可以知道这是对eat方法的虚调用,方法签名是eat(),起点类型是Animal。但最终执行的时候a指向的到底是Dog还是Cat,只有运行时才知道。静态类型是Animal,但运行时类型是Dog啊,到底调哪个eat方法,总得有个机制去决定吧?
这个决定的过程就是动态分派。JVM通过虚方法表或者接口方法表来快速定位到实际应该调用的方法。简单说就是:解析先把"这次调用涉及哪条方法引用"搞清楚,动态分派再根据接收者实际类型决定最终实现。解析是确定"规则",动态分派是确定"最终落点"。
你也可以这样理解:解析解决的是"你说的是谁",动态分派解决的是"最终调谁的实现"。这两个真不是一回事。
invokestatic和invokevirtual的区别
顺着这个话题,再讲讲四条指令的区别:
- invokestatic:调用静态方法,解析后目标唯一,早绑定
- invokespecial:调用私有方法、构造方法、super调用,解析后目标也唯一
- invokevirtual:调用实例方法,要走动态分派,看运行时类型
- invokeinterface:调用接口方法,同样要动态分派
这是JVM层面的区别,面试经常会被问到。
总结
最后再帮你总结一下。符号引用就是class文件里的"名字版",直接引用是JVM内部可以直接定位的"定位版",解析就是名字到定位这个转换过程。静态绑定是解析后目标就唯一了,动态分派是解析后还得看运行时类型。
记最简单的一句话:class文件里只能写名字,类加载后把名字查成真实地址,静态方法查完就定了,重写方法查完还得运行时再挑一个。
参考资料:
- 《深入理解Java虚拟机(第3版)》- 周志明
- JVM规范文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html