2026/11/3 19:32:16

《深入理解Java虚拟机》第八章学习笔记:虚拟机字节码执行引擎

《深入理解Java虚拟机》第八章学习笔记:虚拟机字节码执行引擎 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和N1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽虚拟机不允许采用任何方式单独访问其中的某一个局部变量表中的变量槽是可以重用的不过可能会影响垃圾回收回不回收得看有没有覆盖那个槽位注意不同于类变量在准备阶段赋予了初始值如果一个局部变量定义了但没有赋初始值那它是完全不能使用的8.2.2 操作数栈同局部变量表一样操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中当一个方法刚刚开始执行的时候这个方法的操作数栈是空的在方法的执行过程中会有各种字节码指令往操作数栈中写入和提取内容也就是出栈和入栈操作在概念模型中两个不同栈帧作为不同方法的虚拟机栈的元素是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理令两个栈帧出现一部分重叠可以节约空间共用数据减少额外的参数复制传递8.2.3 动态连接每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用持有这个引用是为了支持方法调用过程中的动态连接静态解析符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用动态连接另外一部分将在每一次运行期间都转化为直接引用8.2.4 方法返回地址无论采用何种退出方式在方法退出之后都必须返回到最初方法被调用时的位置正常调用完成执行引擎遇到任意一个方法返回的字节码指令这时候可能会有返回值传递给上层的方法调用者调用当前方法的方法称为调用者或者主调方法方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定另外一种退出方式是在方法执行的过程中遇到了异常并且这个异常没有在方法体内得到妥善处理8.2.5 附加信息例如与调试、性能收集相关的信息这部分信息完全取决于具体的虚拟机实现8.3 方法调用方法调用并不等同于方法中的代码被执行方法调用阶段唯一的任务就是确定被调用方法的版本 即调用哪一个方法暂时还未涉及方法内部的具体运行过程Java 在执行一个方法调用时首先会尝试“解析”。如果这个方法在编译时就能确定唯一的版本比如是 static 或 private 方法那就在类加载时直接把调用关系定死这叫“解析”。如果不行比如是一个可以被子类重写的方法那就必须等到程序运行时看看到底是哪个对象在调用这个方法再根据对象的实际类型来决定执行哪个版本这个过程叫“分派”。8.3.1 解析解析指的是在程序真正运行之前JVM就已经百分之百确定了要调用的具体是哪个方法。这个方法的地址在编译和类加载时就被确定下来写死了。是一个静态的过程在程序运行前就完成了。效率高因为不用临时做决定。有1. 静态方法用static修饰。它属于类不属于任何对象。比如Math.random调用它时永远都是那个固定的方法不可能有第二个版本。对应指令invokestatic)2. 私有方法用private修饰。因为它只在自己的类内部可见子类无法继承也无法重写它所以也不可能有第二个版本。对应指令invokespecial3. 构造器init)new一个对象时调用的方法。new哪个类调用的就是哪个类的构造器这是确定无疑的。对应指令invokespecial4. 父类方法用super明确指定了要调用父类的版本JVM不会再有疑问。对应指令invokespecial5. 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方法的两句调用中编译器选择的都是同一个方法。静态分派的最典型应用表现就是方法重载同一个类中方法名相同参数列表不同类型、个数、顺序可能会自动类型转换按照charintlongfloatdouble的顺序转型进行匹配2. 动态分派方法重写Override的多态表现叫做动态分派JVM会检查实际类型是什么根据实际类型选择重写的方法父子类中方法签名完全一样Human man new Man();Human woman new Woman();man.sayHello();woman.sayHello();man new Woman();man.sayHello();输出的是man say hellowoman say hellowoman 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 $0I am Son, i have $4This gay has $23. 单分派与多分派方法的接收者与方法的参数统称为方法的宗量单分派根据一个宗量对目标方法进行选择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类有一张清单快捷键1toString()快捷键2equals()2.Father类继承了Object,它的清单是快捷键1toString()指向Object的快捷键2equals()指向Object的快捷键3hardChoice(QQ)指向Father自己的快捷键4hardChoice(_360)指向Father自己的3.Son类继承了Father,并且重写了方法它的清单是快捷键1toString()指向Object的快捷键2equals()指向Object的快捷键3hardChoice(QQ)指向Son自己的覆盖了Father的快捷键4hardChoice(_360)指向Son自己的覆盖了Father的当JVM要执行son.hardChoice(new QQ())时它已经通过编译器的信息知道了要按“快捷键3”。它只需要1. 查看son对象的实际类型是Son。2. 找到Son类的“快捷拨号清单”(vtable)。3. 直接按“快捷键3”。