聊聊JVM里的符号引用、直接引用与动态分派

写在前面:学JVM的人十有八九会被类加载这几个概念绕晕:符号引用、直接引用、解析、动态分派。我之前也是,看一遍忘一遍,后来终于搞明白了,今天一次性写清楚。本文适合有一定Java基础想深入JVM的同学,如果你是第一次接触类加载,建议先收藏回头再看。

目录

  • 先抛出三个问题
  • 符号引用到底是啥
  • 直接引用又是什么
  • 解析到底在干什么
  • 为什么解析了还会动态分派
  • 总结

先抛出三个问题

在开始讲之前,先问大家三个问题,看看你能不能答上来:

  1. class文件里为什么不直接写内存地址?
  2. 解析完为什么还会有动态分派?
  3. invokestatic和invokevirtual区别到底在哪?

如果你答不上来,或者答得模模糊糊,那这篇文章就是写给你的。

符号引用到底是啥

先记住一句话:符号引用就是写名字 。class文件里为什么只能写名字呢?因为类还没加载,地址根本不知道。你总不能在编译的时候就去写一个内存地址吧?这次写完下次改了就全乱了。所以class文件里只能写一些"描述性"的信息,比如java/lang/Stringage:Iprintln:(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文件里只能写名字,类加载后把名字查成真实地址,静态方法查完就定了,重写方法查完还得运行时再挑一个。


参考资料:

相关推荐
一叶飘零_sweeeet2 小时前
JVM 运行时数据区全解:从底层原理到 OOM 根因定位全链路实战
jvm
傻啦嘿哟4 小时前
爬虫跑了一小时还没完?换成列表推导式,我提前下班了
java·开发语言·jvm
摇滚侠5 小时前
java: Cannot compile module ‘consumer‘ configured for JVM target 17
java·jvm
木易 士心5 小时前
Java中 synchronized 和 volatile 详解
java·开发语言·jvm
JustMove0n7 小时前
互联网大厂Java面试全流程问答及技术详解
java·jvm·redis·mybatis·dubbo·springboot·多线程
Nuopiane17 小时前
关于C#/Unity中单例的探讨
java·jvm·c#
win x17 小时前
JVM类加载及双亲委派模型
java·jvm
bug攻城狮17 小时前
Spring Boot应用内存占用分析与优化
java·jvm·spring boot·后端
今天你TLE了吗18 小时前
JVM学习笔记:第八章——执行引擎
java·jvm·笔记·后端·学习