- 8.1 概述
- 虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式
- 从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果
- 8.2 运行时栈帧结构
- Java虚拟机以方法作为最基本的执行单元,“栈帧”是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息
在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
- 8.2.1 局部变量表
- 存放方法参数和方法内部定义的局部变量
- 在在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量
- 局部变量表的容量以变量槽为最小单位,但没有规定一个变量槽占用的内存空间大小,只是很说到每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化
- 对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间
- 索引定位使用局部变量表
- 如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽
- 如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个
- 局部变量表中的变量槽是可以重用的,不过可能会影响垃圾回收,回不回收得看有没有覆盖那个槽位
- 注意不同于类变量在准备阶段赋予了初始值,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的
- 8.2.2 操作数栈
- 同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中
- 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作
- 在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠,可以节约空间,共用数据减少额外的参数复制传递
- 8.2.3 动态连接
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
- 静态解析:符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用
- 动态连接:另外一部分将在每一次运行期间都转化为直接引用
- 8.2.4 方法返回地址
- 无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置
- 正常调用完成:执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定
- 另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理
- 无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置
- 8.2.5 附加信息
- 例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现
- Java虚拟机以方法作为最基本的执行单元,“栈帧”是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息
- 8.3 方法调用
- 方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程
- Java 在执行一个方法调用时,首先会尝试“解析”。如果这个方法在编译时就能确定唯一的版本(比如是 static 或 private 方法),那就在类加载时直接把调用关系定死,这叫“解析”。
- 如果不行(比如是一个可以被子类重写的方法),那就必须等到程序运行时,看看到底是哪个对象在调用这个方法,再根据对象的实际类型来决定执行哪个版本,这个过程叫“分派”。
- 8.3.1 解析
- 解析指的是,在程序真正运行之前,JVM就已经百分之百确定了要调用的具体是哪个方法。这个方法的"地址"在编译和类加载时就被确定下来,写死了。是一个静态的过程,在程序运行前就完成了。效率高,因为不用临时做决定。
- 有
- 1. 静态方法:用static修饰。它属于类,不属于任何对象。比如Math.random(),调用它时永远都是那个固定的方法,不可能有第二个版本。(对应指令:invokestatic)
- 2. 私有方法:用private修饰。因为它只在自己的类内部可见,子类无法继承也无法重写它,所以也不可能有第二个版本。(对应指令:invokespecial)
- 3. 构造器(<init>):new一个对象时调用的方法。new哪个类,调用的就是哪个类的构造器,这是确定无疑的。(对应指令:invokespecial)
- 4. 父类方法:用super明确指定了要调用父类的版本,JVM不会再有疑问。(对应指令:invokespecial)
- 5. final方法:用final修饰的方法。final的意思就是"不许重写"。所以子类也无法提供新版本,负责人也是固定的。
- 8.3.2 分派
- 可能是一个动态的过程,需要在程序运行时根据对象的实际类型来确定到底调用哪个方法。这是实现多态的关键。
假设公司有一个任务叫"接待客户”。今天来的是一位讲英语的客户,总指挥就要派擅长英语的员工A去接待。明天来的是讲日语的客户,就要派擅长日语的员工B去。
总指挥手里的指令是"接待客户”,但具体派谁去(调用哪个方法),不能提前定死,必须在客户到来时(程序运行时),根据客户的类型(对象的实际类型)来临时决定。
- 可能被重写(Override)的方法,就称为"虚方法"(VirtualMethod)。
- 静态类型和动态类型:以Father son = new Son();举例
- 静态类型(Static Type):也叫“外观类型”,就是在代码中声明这个变量时用的类型。在这里,Father 就是它的静态类型。这个类型在编译期就已经确定,并且不会再改变。无论 son 后来指向了哪个子类对象,编译器眼中的 son 永远是 Father。
- 实际类型(Actual Type):也叫“运行时类型”,指的是在程序运行时,实际指向内存中的对象的类型。在这里,new Son() 创建的是 Son 对象,所以 son 的实际类型是 Son。这个类型在运行时才能确定,并且可能改变 (比如后面可以再写 son = new AnotherSon();)
- 1. 静态分派
- 所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派发生在编译期,编译器就像一个“脸盲”,它看不见对象真正的样子(实际类型),它只能看到变量的“标签”(静态类型)。
编译器看到sr.sayHello(man),它分析:“嗯,变量man的静态类型是Human。”
于是,它就在三个重载方法中,选择了参数为Human的那一个:public void sayHello(Human guy)。
这个决定在编译成.class文件时就已经被写死了。
同理,当编译器看到sr.sayHello(woman)时,它分析:“变量woman的静态类型也是Human。”于是它也选择sayHello(Human guy)这个版本。
所以,在main方法的两句调用中,编译器选择的都是同一个方法。
- 静态分派的最典型应用表现就是方法重载(同一个类中,方法名相同,参数列表不同(类型、个数、顺序))
- 可能会自动类型转换,按照char>int>long>float>double的顺序转型进行匹配
- 所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派发生在编译期,编译器就像一个“脸盲”,它看不见对象真正的样子(实际类型),它只能看到变量的“标签”(静态类型)。
- 2. 动态分派
- 方法重写(Override)的多态表现叫做动态分派:JVM会检查实际类型是什么,根据实际类型选择重写的方法(父子类中,方法签名完全一样)
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
输出的是:man say hello
woman say hello
woman say hello
- 方法有多态,而字段没有多态
- 输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是Son类重写的showMeTheMoney()方法,所以输出的是“I am Son”。而这时候虽然父类的money字段已经被初始化成2了,但Son类showMeTheMoney()方法中访问的却是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的money,输出了2。
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
输出:I am Son, i have $0
I am Son, i have $4
This gay has $2
- 方法重写(Override)的多态表现叫做动态分派:JVM会检查实际类型是什么,根据实际类型选择重写的方法(父子类中,方法签名完全一样)
- 3. 单分派与多分派
- 方法的接收者与方法的参数统称为方法的宗量
- 单分派:根据一个宗量对目标方法进行选择,Java语言的动态分派属于单分派类型
- 多分派:根据多于一个宗量对目标方法进行选择,Java语言的静态分派属于多分派类型。
- 单分派和多分派描述的是Java的工作“阶段”,而不是某一行代码的“属性”。
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
father.hardChoice(new _360()); son.hardChoice(new QQ()); 它们都经历了“先静态分派,后动态分派”的完整过程。
第 1 阶段:编译时 (静态多分派)
编译器处理这两行代码时,都是多分派。
对于 father.hardChoice(new _360());
宗量1 (接收者静态类型): father 的声明类型是 Father。
宗量2 (参数静态类型): new _360() 的类型是 _360。
决策: 编译器根据 Father 和 _360 这两个宗量,确定了要调用 Father 类里 hardChoice(_360 arg) 这个版本的方法。
对于 son.hardChoice(new QQ());
宗量1 (接收者静态类型): son 的声明类型是 Father。
宗量2 (参数静态类型): new QQ() 的类型是 QQ。
决策: 编译器根据 Father 和 QQ 这两个宗量,确定了要调用 Father 类里 hardChoice(QQ arg) 这个版本的方法。
在这个阶段,因为编译器总是同时考虑“接收者的声明类型”和“参数的类型”,所以这个阶段是多分派。
第 2 阶段:运行时 (动态单分派)
JVM 实际执行这两行代码时,都是单分派。
对于 father.hardChoice(new _360());
JVM拿到的指令: “去调用 hardChoice(_360) 这个方法”。(方法签名已由编译器定死)
唯一的决策依据 (宗量1): father 变量指向的实际对象是什么类型?是 Father 类型。
决策: 在 Father 类型中执行 hardChoice(_360) 方法。因为只根据一个宗量(对象的实际类型)做决策,所以这是单分派。
对于 son.hardChoice(new QQ());
JVM拿到的指令: “去调用 hardChoice(QQ) 这个方法”。(方法签名已由编译器定死)
唯一的决策依据 (宗量1): son 变量指向的实际对象是什么类型?是 Son 类型。
决策: 检查到 Son 类型重写了 hardChoice(QQ) 方法,因此去执行 Son 类的版本。因为只根据一个宗量(对象的实际类型)做决策,所以这也是单分派。
- 4. 虚拟机如何实现动态分派:建立一个虚方法表,像查数组一样快,不需要搜索,效率高
- 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
比喻:可以把虚方法表想象成每个类的一张“方法快捷拨号清单”。
1.Object类有一张清单:
快捷键1:toString()
快捷键2:equals()
2.Father类继承了Object,它的清单是:
快捷键1:toString()(指向Object的)
快捷键2:equals()(指向Object的)
快捷键3:hardChoice(QQ)(指向Father自己的)
快捷键4:hardChoice(_360)(指向Father自己的)
3.Son类继承了Father,并且重写了方法,它的清单是:
快捷键1:toString()(指向Object的)
快捷键2:equals()(指向Object的)
快捷键3:hardChoice(QQ)(指向Son自己的,覆盖了Father的)
快捷键4:hardChoice(_360)(指向Son自己的,覆盖了Father的)
当JVM要执行son.hardChoice(new QQ())时,它已经通过编译器的信息知道了要按“快捷键3”。它只需要:
1. 查看son对象的实际类型是Son。
2. 找到Son类的“快捷拨号清单”(vtable)。
3. 直接按“快捷键3”。
- 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
- 可能是一个动态的过程,需要在程序运行时根据对象的实际类型来确定到底调用哪个方法。这是实现多态的关键。
- 方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程
《深入理解Java虚拟机》第八章学习笔记:虚拟机字节码执行引擎
张小明
前端开发工程师
计算机基础科普:一文讲透“CPU核心”到底是个啥
1. 大背景:CPU是什么?核心又是什么?在买电脑或看手机参数时,你一定会看到“CPU”这个词。它全称是中央处理器(Central Processing Unit)。CPU(中央处理器):它是整台电脑的…
从入门到精通:小白程序员必备的AI前端Agent学习路线图
本文为前端开发者提供了学习AI前端Agent的完整路线图,涵盖从基础HTML/CSS/JavaScript到现代框架React/Vue,再到前后端交互、AI辅助开发及自动化,最后深入AI Agent智能体的进阶学习。文章强调循序渐进的学习方法,通过实战项目巩固知…
RoCE V2 Read Request 数据包全解剖:ACK、NAK 与握手流程一文讲透
RoCE V2 Read Request 数据包全解剖:ACK、NAK 与握手流程一文讲透 做 RDMA 绕不开 RoCE V2,做 RoCE V2 绕不开 Read Request。 本文从数据包结构出发,把 Read Request、ACK、NAK 三种包的字段逐一拆解,再结合 FPGA 硬件实现讲清楚…
SenseNova 6.8 Code2Video 快速上手教程:10分钟从0到第一部代码生成短视频(含环境清单)
SenseNova 6.8 Code2Video 快速上手教程:10分钟从0到第一部代码生成短视频(含环境清单) 【免费下载链接】SenseNova-6.8-Code2Video 项目地址: https://ai.gitcode.com/SenseNova/SenseNova-6.8-Code2Video SenseNova 6.8 Code2Video …
AdMergeX 获信通院多项认可,市场部负责人受聘铸基计划技术专家
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
Docker Image-- 镜像命令详解与使用示例
目录 1. Docker 镜像是什么 2. 镜像命令详解 2.1 docker images 2.2 docker tag 2.3 docker pull 2.4 docker push 2.5 docker rmi 2.6 docker save 2.7 docker load 2.8 docker image inspect 2.9 docker history 2.10 docker image prune 2.11 docker build 2…