【从编译器的角度看多态的底层实现原理】

2023-12-14 18:29:00

系列文章目录

????????欢迎读者订阅《计算机底层原理》、《从JVM看Java》系列文章、能够帮助到大家就是对我最大的鼓励!


文章目录


前言

? ? ? ?这篇文章我重点要讲解的是有关动静态绑定的具体概念以及多态实现的底层原理、因为这其中涉及到了编译器的工作原理、所以我在这篇文章当中花费了较长的篇幅、来为大家讲解编译器的具体工作内容,我相信一个优秀的程序员对自己程序当中的编译的过程应该是很熟悉的。


一、编译器做了什么?

1.词法分析

????????词法分析是编译过程当中的第一个阶段,也被称为是扫描或者词法扫描,它的主要目的是将源代码进行分割变成一系列的词法单元,每一个词法单元都代表源代码当中的一个基本元素,这些基本的元素包括关键字、标识符、运算符、常量等,而我们的词法分析这一过程是由我们的词法分析器来完成的。

  1. 识别词法单元????????
    ? ? ? ?词法分析器会识别源代码当中的词法单元,每个词法单元都具有特定的语法和含义、例如我们的关键字int、double、或者循环语句、分支语句、if、else,包括运算符加减乘除、或者常量,都会被词法分析器识别成为一个词法单元。

  2. 去除空白和注释
    ? ? ? ?这个其实很好理解、词法分析器通常会忽略掉空白字符像空格、制表符、注释等无用字符,因为他们对程序的语法结构没有贡献、这有助于减小后续阶段的处理复杂性。

  3. 生成词法单元流
    ? ? ? ?在经过了识别词法单元和去除空白字符和注释以后,词法分析器就会将经过以上这些操作的源代码生成词法单元流,词法单元流当中当中包含了他的类型和相应的属性信息。为了方便大家理解我为大家写了一个词法单元流,注意这只是抽象的表示,方便大家都理解。
    ?

    词法单元流
    词法单元类型例子
    关键字if、else、while
    标识符variable_name
    运算符+、-、*、/
    数字123、756
    字符串"Hello World"
    分号
    括号( ,),{ }
    等号=
    逗号
    冒号
    注释//This is a comment

    这些只是示例、大家理解即可

  4. 错误检测? ? ? ??
    ? ? ? ?词法分析也负责将源代码当中的一些简单的语法错误进行检测、例如非法字符、或者字符串未闭合、这有助于提前发现一些常见的编码错误。词法分析通常使用有限状态自动机或者正则表达式来描述词法单元的模式、并且使用这些模式来识别和提取词法单元、整个过程为后续的语法分析和语义分析提供了一个清晰的输入、帮助编译器理解程序的基本结构、这里要注意、编译器所做的每一个操作都是为了方便后续的操作进展顺利。

    这里为大家展示一下词法单元流是什么样子的
    ?

    源代码: 
    int main() {
        if (x > 0) {
            return 1;
        } else {
            return 0;
        }
    }
    
    词法单元流:
    [关键字:int] [标识符:main] [(] [)] [{]
        [关键字:if] [(] [标识符:x] [运算符:>] [数字:0] [)] [{]
            [关键字:return] [数字:1] [;]
        [}] [关键字:else] [{]
            [关键字:return] [数字:0] [;]
        [}]
    [}]
    

2.语法分析

  1. 语法规则检查? ? ? ??
    ? ? 首先在这里我要先为大家讲解一个概念叫做文法和产生式,首先什么是文法呢?文法是一种广泛的概念、用于描述语言之间的结构规则、它包括了一组规则、这些规则定义了语言的合法结构和形式、文法可以包括多种类型的规则例如产生式、终结符、非终结符、语法规则等等,这其中就涉及到了编译原理的概念、在这里就不为大家详细地叙述了。
    ? ? ?那么产生式呢只是文法当中的一种语言规则、它是文法的组成部分、产生式描述了如何通过替换一些符号来产生另一些符号串。

  2. 构建语法树
    ? ? ?语法分析器会根据产生式(语法规则)遍历词法单元流来形成语法树,如下图:
    程序 -> 类声明
    类声明 -> class 标识符 { 成员声明列表 }
    成员声明列表 -> 成员声明 成员声明列表 | ε
    成员声明 -> 变量声明 | 方法声明
    变量声明 -> 类型 标识符 ;
    方法声明 -> 返回类型 标识符 ( 参数列表 ) { 方法体 }
    参数列表 -> 参数 参数列表 | ε
    参数 -> 类型 标识符
    类型 -> int | float | boolean
    

    上面就是我为大家展示的一个产生式,他是用来规范代码的一种规则。
    ?

    [class, Main, {, public, static, void, main, (, String, [, ], args, ), {, int, a, =, 5, ;, }, }]
    

    这个是词法单元流,语法分析器遍历词法单元流,并且一句产生式形成语法树。
    ?

    Program
      └── ClassDeclaration
          ├── Identifier: Main
          ├── MethodDeclaration: public static void main(String[] args)
              ├── VariableDeclaration: int a = 5
    

    语法分析树的格式大致如上图。

  3. 错误检测

    ? ? ?当语法分析器在对词法单元流进行遍历的时候就会进行错误检测,如果在这个过程当中遇到了无法匹配的单元序列或者违反了语法规则的部分的时候,语法分析器就会做出如下操作。

    ? ? ?1. 错误报告:一旦发现语法的错误,语法分析器就会生成错误报告、其中会包含错误的位置、类型、以及可能的修复建议、例如我们在使用各种集成开发环境编写代码的时候,我们写了一些违反语法规则的代码的时候,编译器就会在这些代码的下面显示红线、来提示我们编译不通过、并且会提供可能的修复意见、这有助于开发者快速定位并且修改问题。

    ? ? ?2. 错误恢复:有些语法分析器在发现错误以后会尝试进行错误恢复、以便继续分析源代码并且检测可能的后续错误、错误恢复的方法可以包括插入或者删除一些词法单元,是的语法分析可以继续进行。

    ? ? ?3. 提供多个错误:语法分析器在遇到问题的时候不会在发现一个错误之后立即停止检测、而是会继续检测以发现更多的错误、这样可以提高开发者修复代码的效率、因为他们可以一次性地解决多个问题。

  4. 优化准备
    ? ? ?注意这里的优化,只是语法分析器在工作时的副产品,并不是他的主要职责、这些优化主要是针对词法单元流当中一些冗余的括号或者一些无用的语句进行删除。
    代码在运算 2 * (3 + 4)的时候语法树的逻辑表达 
       乘法表达式 (*)
          /         \
       数字(2)   加法表达式 (+)
                     /     \
                  数字(3)  数字(4)
    

    ? ? ?我们进行语法树的构建之后在逻辑上我们就可以这样表示语法树,这只是简单的一个操作,实际语法树的构成及其复杂,这里展示只是为了方便大家理解。

3.语义分析

  1. 类型检查:这个不仅是语义分析当中的重点更是一个我们在进行变成的时候的一个重要知识点、希望大家能够彻底掌握这些知识(提醒:这些知识的掌握一定要依靠我们对于计算机底层的理解、千万不要死记硬背)!
    ? ? ??1)类型定义:首先编程语言通常由一组基本类型(整数、浮点数、字符等)以及用户定义的符合类型(结构体、类等)编译器需要了解这些类型及其规定。
    ? ? ? 2)类型推断:在某些情况下,编译器可以通过上下文推断变量或者表达式的类型、例如对于赋值语句 ‘x = 5’,编译器可以推断变量x的类型为整数。
    ? ? ? 3)类型匹配:编译器检查表达式当中的操作符和操作数的类型是否匹配,例如加法操作通常要求两侧的操作数是相同的类型。? ? ? ?
    ? ? ? 4)强制类型转换:在某些情况下、编译器可能允许或者要求进行显示的类型转换以确保表达式的类型匹配。
    ? ? ? 5)函数调用检查:对于函数的调用,编译器会检查函数的参数类型和返回类型是否与函数的声明相匹配。
    ? ? ? 6)数组和指针检查:对于数组和指针、编译器会检查索引操作的合法性、以及指针的指向是否正确。例如我们在使用数组的时候经常会有这样的操作array[5]? = 5;如果这个数组一共只有5个元素,下表只到4,那么这样的操作无疑是违法的,再例如ptr ->或者ptr. 操作的时候就需要进行这样的指针检查。
    ? ? ? 7)用户定义类型检查:如果编程语言支持用户定义的类型(如类或者结构体)编译器会检查对这些类型的使用是否规范。
    ? ? ? 8)生成错误报告:如果在这个时候编译器发现了类型的错误、就会生成相关的错误信息、知识具体的错误位置和类型不匹配的原因。

  2. 符号解析
    ? ?
    ?符号解析主要负责对程序当中的符号(变量、函数、类型)进行解析和处理、确保这些处于不同位置的符号能够得到正确的处理和使用、同时还会处理作用域的问题、例如这个变量的使用是否超过了他的作用域、方法内的局部变量放到方法外去使用等等操作。接下来我为大家讲解符号解析的详细内容。
    ? ? ?1)符号表的使用:这里的符号表为大家买一个伏笔、各位读者先不要纠结这个符号表是什么东西,我们只需要记住这个符号表是从词法分析阶段就开始填充,在整个过程当中不断进行完善的这个一个表单即可。符号表是一个数据结构、用于存储程序当中的符号及其相关信息、例如变量的类型、地址、作用域等、在符号解析阶段、编译器会使用符号表来查找、验证和更新符号的信息。也就是说每一个符号(变量、函数、类型)都会存储在这个符号表当中、编译器在进行语义分析的时候就会使用符号表来进行查找和解析。
    ? ? ?2)变量和常量解析:对于变量和常量,符号解析器会检查他们是否已经进行了声明,检验变量的使用是否符合其声明类型、以及是否进行了正确的初始化。
    ? ? ?3)函数解析:对于函数、符号解析器会检查函数的声明和定义、确保函数的参数类型和返回值类型与调用时相匹配、还会处理函数的作用域问题、确保函数内正确引用外部的变量。
    ? ? ?4)类型解析:符号解析器也负责处理类型信息、他会检查类型的合法性、比如确保使用的类型是必须是已经定义过的类型、在一些静态类型语言当中,符号解析也包括进行类型推断和类型检查。
    ? ? ?5)作用域管理:确保变量在声明的作用域当中是可见的,并且符号解析器还会处理作用域的嵌套的情况,这包括处理作用域、函数作用域等。
    ? ? ?6)错误检测和报告:如果符号解析器这个时候发现了符号引用的错误、例如变量未声明、类型不匹配等、它会生成错误信息并且报告给程序员、帮助调试和修复代码。

  3. 语义错误检测和报告
    ? ?
    ?语义错误检测和报告是编译器或解释器在语义分析阶段的一个关键任务、语义错误指的是源代码在语法上是合法的但是语义上存在问题,说白了就是编译不会报错,但是一运行就崩,或者导致程序在运行时产生的不正确的行为。
    ? ? ?1)检测范围:语义错误通常涉及更高层次的语言结构、而不是基本的语法错误、这包括变量的使用、表达式的计算、函数调用和复杂的语言特性。
    ? ? ?2)类型不匹配:一种常见的错误时类型不匹配、即在表达式当中使用了不兼容的数据类型、例如将一个字符串赋值给了一个整形变量、或者试图对不同类型的变量进行算数运算等等都属于类型不匹配的语义错误问题。
    ? ? ?3)未声明的变量:使用未声明的变量或者标识符时另一个常见的语法错误、编译器或者解释器在语法上可能允许变量的使用、但是如果该变量没有声明那么就会报错误报告。
    ? ? ?4)作用域问题:语义错误还可能涉及变量的作用域、例如用在某个作用域内重新声明已经存在的变量、或者在作用域外使用未被声明的变量等。因为作用域外已经声明过的变量他的生命周期还没有结束就再一次声明就会引发作用域冲突。
    ? ? ?5)函数调用错误:在函数调用的时候、参数的变量或者类型与函数的声明不匹配会导致语义错误、同样对非函数类型的对象进行函数调用也是一种错误。
    ? ? ?6)数组和指针错误:对于数组越界、对空指针进行擦欧总等问题也属于语义错误的范畴。
    ? ? ?7)不合理的操作:尝试对不支持的操作进行操作、例如对非数值类型的变量执行算数运算、例如对一个字符串进行加减乘除运算显然是不合理的、这就可能导致语义错误。
    ? ? ?8)错误报告:当编译器检测到语义错误的时候,它会产生相应的错误信息、还包括错误的类型、位置和可能的修复建议。这有助于程序员和纠正潜在的问题。

4.中间代码生成

源代码:

x = 5
y = 10

if x > y:
    z = x + y
else:
    z = x - y


生成的语法树:

    Program
     /     \
Assign    IfStatement
  |        /     \
  x       >       =
         / \     / \
        x   y   z   +
                / \
               x   y


生成的中间表示(简化的三地址码):

1. x = 5
2. y = 10
3. if x > y goto 6
4. z = x + y
5. goto 8
6. z = x - y

? ? ? ? 中间代码我已经给大家展示出来了,下面我来解释一下为什么编译器要将语法树转换成为中间代码来表示。
? ? ? ? 可移植性:首先中间代码是独立于硬件平台和目标指令集的抽象表示形式,上面的代码大家也看到了,这也就意味着编译器可以在不同的平台上使用相同的中间代码,从而提高了程序的可移植性。
? ? ? ? 便于分析:中间代码提供了一个更简单、更结构化的表示形式、便于编译器分析程序的控制流、数据流等特性,这种简化使得编译器能够更容易地实施复杂的优化算法。
? ? ? ? 我之前提到过编译当中的每一步操作都是在为后面的操作打基础,每一步都是在尽可能地优化代码为后续的操作和目标代码的生成提供一个更灵活的基础。

5.优化

? ? ? ? 编译器当中的优化通常是指修改程序的中间表示(中间代码)以提高程序的性能、减少资源消耗或者改进其他方面的目标、优化是编译过程当中的一个重要步骤、他的目的是为了生成更有效、更紧凑的目标代码、以便在运行的时候高效地执行。下面我为大家列举一下编译器当中的常见的优化操作。
? ? ? ? 1)常量折叠:表达式当中的常量经过编译之后会全部被折叠得到一个常量结果,例如3 + 5 = 8,这里会把运算过程全部省略直接保留 8 这个结果。
? ? ? ? 2)死代码消除:死代码就是程序当中永远都不会执行的代码,以减少程序的大小和提高执行效率。

public static void main(String[] args){
    int count = 10;
    if(count > 5){、、、}
    else{、、、}
}

????????3) 复制传播:将变量的值替换为其在程序当中的实际值,这有助于减少不必要的变量赋值操作。
?

复制传播之前
x = 5
y = x + 10
z = x * 2

复制传播之后
x = 5
y = 15
z = x * 2

????????这样子就可以减少一次对x的不必要的赋值操作。
? ? ? ? 4)循环优化:针对循环结构进行优化、例如循环展开、循环合并、循环变量替换等等、以减少循环的开销。
?

循环展开
for (int i = 0; i < N; i += 2) {
    result[i / 2] = array[i] + array[i + 1];
}

for (int i = 0; i < N; i += 4) {
    result[i / 2] = array[i] + array[i + 1];
    result[i / 2 + 1] = array[i + 2] + array[i + 3];
}

循环合并
for (int i = 0; i < N; ++i) {
    array1[i] = array1[i] + constant;
}

for (int i = 0; i < N; ++i) {
    array2[i] = array2[i] + constant;
}

for (int i = 0; i < N; ++i) {
    array1[i] = array1[i] + constant;
    array2[i] = array2[i] + constant;
}

? ? ? ? 5)内联函数:将函数调用替换为函数的实际代码以减少函数调用的开销,因为当我们提到函数的调用时,有一些开销与之相关、比如保存和恢复现场、跳转到函数体等等、内联函数是一种优化技术、它通过将函数调用处用函数体的实际代码替换来减少这些开销。我在这里为大家补充一下关于函数调用的相关知识(提示:这里涉及到C语言当中的知识、不感兴趣的可以直接划走。)
? ? ? ? 1.保存和恢复现场:当一个函数被调用的时候、当前函数的执行状态、包括他的寄存器的值、栈指针等都需要保存起来、以便在函数执行完毕之后能够正确地恢复到调用点、这涉及到寄存器的值保存到栈上、以及在函数返回的时候从栈上恢复这些值。因为我们在不管是C语言还是Java或者是别的语言,我们在直接调用函数或者使用对象调用函数都是通过函数名也就是函数的引用来调用函数的,而不是直接调用我们定义的函数体,那么这里就涉及到通过函数引用调用函数需要跳转到函数体、那么既然要涉及到跳转也就必须提前保存和恢复现场。
? ? ? ? 2.跳转到函数体:程序执行需要跳转到被调用函数的代码段、这通常涉及到一条跳转指令、将程序的控制流从调用点转移到函数体的入口点。
? ? ? ? 3.参数传递:将参数传递给函数也是开销的一部分,参数通过寄存器或者压栈出栈等方式传递给函数(这个过程是通过CPU和主存、系统总线来完成的,感兴趣的小伙伴可以去看我《计算机底层原理专栏》的文章)。
? ? ? ? 4.栈操作:函数的调用通常会涉及到对栈的一些操作、如果将返回地址推入栈中、分配局部变量的空间等。
? ? ? ? 5.返回地址管理:在函数调用的时候、需要将调用点地址作为返回地址保存、以便在函数执行完成后能够正确返回到调用点。
? ? ? ? 好了现在我们回归正题:我来为大家解释一下内联函数,这个是C语言当中的概念,有兴趣的小伙伴可以去了解一下。
? ? ? ? 首先我先使用代码的形式为大家展示一下内联函数。
?

int add(int a, int b) {
    return a + b;
}

inline int add(int a, int b) {
    return a + b;
}

? ? ? ? 我这里为大家提供了一个简单的函数、用于计算两个数的和,如果我们在代码当中多次调用这个函数、会有一些额外的开销涉及到函数调用的操作(这些操作我在上文当中也已经提到过了),那么这个时候我们为了节省开销就可以使用内联函数对这些函数进行函数体展开,什么意思呢?就是字面意思,在函数调用的地方将函数的函数体展开,避免频繁多次地执行函数调用操作,我们的关键字inline会直接告诉编译器在函数调用点将函数体直接插入、而不是进行常规的函数调用,因此如果我们在代码当中多次调用add函数,编译器会尽量地将函数体插入到每个调用点、从而减少函数调用的开销、当然我这里还要提一句、我们这里的inline关键字对于编译器来说也仅仅是一个建议、不代表我们只要使用这个关键字编译器就一定会将这个函数展开,具体是否展开还是要由编译器决定的。
? ? ? ? 这样的优化有助于提高程序的执行效率、尤其是对于短小并且频繁调用的函数、因为他们的开销较高、然而需要注意的是内联函数的过度使用可能导致代码膨胀,。增加程序的大小、因此、在进行内联优化的时候需要权衡代码大小和执行效率。否则好好的程序,因为内联函数多次展开,会变得极其冗余。
? ? ? ? 6)常量传播:这个很好理解比如说我定义一个常量。
?

#include <iosream>
using namespace std;
int main(){
    String str = "Hello World";
    return 0;
}

? ? ? ? 之后程序在用到这个str的时候,就会直接将其转换为"Hello World",这就是常量传播。
? ? ? ? 7)最后优化的环节还涉及到很多的内容,例如数组和指针优化、代码块合并、指令调度、寄存器分配等等操作,这不是我们今天所讲的重点,我就不为大家详细展开讲解了。

6.目标代码生成

? ? ? ? 首先我在这里问各位一句:什么是目标代码?我可以很明确地告诉大家目标代码就是要在目标机器上面执行的代码就叫做目标代码,因为我们运行的程序可能在不同的硬件平台上,所以我们的目标机器是不一样的所以目标代码也是不一样的。

? ? ? ?接下来我为大家解释一下,为什么需要生成目标代码,其实这个我刚才已经提到过了,

? ? ? ? 1.可执行性:目标代码是机器上可以直接执行的代码形式、与高级源代码相比更接近计算机硬件的语言、通过目标代码生成、编译器将源代码翻译成为机器能够理解和执行的指令、使程序可以在特定的硬件平台上运行。
? ? ? ? 2.性能优化:目标代码生成阶段可以应用各种优化技术、提高程序的执行效率、这些优化包括指令调度、寄存器分配、循环展开等等、以确保生成的机器代码在运行的时候能够更快地执行。

? ? ? ? 3.硬件适配性:不同的计算机体系结构和处理器有不同的指令集和架构、目标代码生成确保编译后的程序能够在目标机器上面正确运行、充分利用该机器的特性和性能。

? ? ? ? 4.代码生成抽象层次:编译器通过目标代码生成实现了源代码到底层硬件的抽象层次转换,这使得程序员可以使用高级语言编写程序、而不必担心底层硬件的细节、同时确保程序在不同的平台上的可移植性。

? ? ? ? 下面我为大家详细地讲解一下,目标代码生成的过程(注意:这里不是重点,如果想进一步深入了解编译的过程的话可以直接去看汇编,如果没有兴趣了解的话,请直接跳过!)

? ? ? ? 1.中间代码准备:在前面的编译阶段、编译器会生成中间代码,这个中间代码是抽象的中间表示、与机器指令没有半毛钱关系、它可以是三地址码、抽象语法树或者其他中间表现形式、这些中间代码捕捉了源代码的语义信息、但是与具体的机器架构无关。

????????2.选择目标指令集:在目标代码生成阶段之前、编译器首先要选择目标机器的指令集架构。即目标机器上支持的指令集、不同的硬件平台有不同的指令集、因此编译器需要根据目标机器的特性来选择相应的指令集。

? ? ? ? 3.寄存器分配:编译器决定如何分配中间代码当中的变量到目标机器的寄存器、这包括选择哪些寄存器存储变量、以及何时将变量存储到内存当中去,至于为什么这么干呢?因为CPU对于寄存器的访问速度远远快于CPU访问内存的速度、所以对于频繁使用的变量一定要放到寄存器当中去,否则CPU开销会很大,关于寄存器的具体知识我下文当中会讲到。
? ? ? ? 4.指令选择:对于每个中间代码操作、编译器选择相应的目标机器指令、这可能涉及到将高级操作(如加法、乘法)映射到目标机器的具体指令、同时考虑寄存器的使用和目标机器的特性。

? ? ? ? 5.地址计算:如果程序涉及到数组、结构体符合等数据结构、编译器需要生成代码来计算这些数据的地址、这可能包括对数组的索引的计算、结构体成员的偏移量计算等等,因为涉及到将中间代码转换成为目标代码,那么转换后的代码总归是要放到内存当中的,这些数据在内存当中是有地址的,所以需要进行地址计算。

? ? ? ? 6.代码优化:优化可能大家都要猜到了、常量折叠、死代码消除、循环展开以提高生成代码的效率和性能。

? ? ? ? 7.生成目标代码:最后编译器生成目标机器代码、并将其写入输出文件或者存储在内存当中、这里面会涉及到生成汇编代码、调用汇编器将其转换为机器码或者直接生成二进制码(注意红色的那句话,调用汇编器生成机器码或者直接生成二进制码,这里的或者是一个伏笔,我在后面的汇编当中会讲到)

? ? ? ??


拓展:汇编

这部分内容虽然是拓展内容,但对于真正理解编译却至关重要

? ? ? ?首先我要来解释一下我上面埋下的那个伏笔。我们必须知道从计算机的角度来看,汇编代码在程序执行的过程当中是可有可无的,这也就是为什么我将汇编这部分的知识作为拓展知识来进行讲解,因为计算机最终执行的是目标代码,这是由编译器或者汇编器生成的,直接由计算机硬件执行的机器码。

? ? ? ? 汇编代码的存在主要是为了人类程序员而设计的、以便容易理解、调试和优化程序、对于计算机硬件而言、它更关注目标代码、因为它是能够直接执行的二进制指令。从计算机的角度来看、汇编代码可以提供更好的可读性和调试性、使得程序在开发的过程当中更容易理解和干预程序的执行。

? ? ? ? 所以这也就是为什么我在上面提到了那个或者、因为汇编器将目标代码翻译成为汇编代码这一步并不是必须的。汇编代码本身是基于目标代码、为了方便程序员调试所翻译的机器语言。

????????在这里的中间代码和优化之间的步骤我还要给大家讲解一个非常重要的知识、那就是汇编。
? ? ? ? 这里我要提一句、各位不要把中间代码和汇编代码混为一谈、中间代码是编译过程当中的一个阶段、它将源代码翻译成为一种抽象的表示形式、这个中间表示形式通常更接近机器语言、但仍然比汇编语言更抽象,具体的中间代码的表现形式我已经给大家在上文当中呈现出来了。
? ? ? ? 生成的中间代码可以进一步翻译成为汇编代码,这是编译器的下一个阶段、汇编代码更接近计算机体系结构的语言、是机器语言的一种低级表示。
? ? ? ? 所以我们可以认为中间代码的生成结果最终会被转化为汇编代码、但中间代码本身不是汇编代码、中间代码就是为了在编译过程当中为了方便处理和优化的一种抽象表示。
? ? ? ? 接下来我为大家列举一下中间代码翻译成为汇编代码都有哪些过程,(注意:这里涉及到了计算机系统当中最底层的知识,如果对这方面不了解的小伙伴可以直接跳过。)
? ? ? ? 1.寄存器分配

????????编译器将中间代码的临时变量和值会映射到物理寄存器或者栈上,这是为了在生成汇编代码的时候能够有效利用计算机的寄存器。编译器进行寄存器分配的原因主要时为了优化程序的性能,那么为什么要进行寄存器分配呢?
? ? ? ? 寄存器是高速存储器:

????????相比于访问主存或者缓存,访问寄存器速度更快,因为寄存器当中含有更多的硅元素(至于为什么硅元素更快这个就不探讨了啊,反正他不尽快而且贵,所以数量有限),将频繁使用的变量或者值放入寄存器当中,否则的话总是访问内存这对于CPU开销太大了,而且耗时,所以将这些常用的数据加载如寄存器当中,可以减少对内存的读写操作。
? ? ? ? 2.指令选择:

? ? ? ? 指令选择是编译器当中的一个关键阶段,它将中间代码翻译成目标机器的汇编代码、这个阶段涉及到高级语言中的抽象操作、映射到底层硬件的具体指令集。以下我为大家列出指令选择的一些关键步骤。

? ? ? ? 操作符映射:编译器需要将中间代码中的操作符映射到目标机器的汇编指令,例如将中间代码当中的加法操作映射到目标机器汇编代码的加法指令。

? ? ? ? 寻找最佳指令序列:不同的机器指令集可能有不同的指令来执行相似的操作、编译器需要在性能和代码大小之间找到平衡,选择最适合的目标机器指令序列,就例如刚才所说的加法指令、能够执行加法运算的指令有很多、那么编译器应该选择什么样的指令呢?那么这个时候就涉及到寻找最佳指令序列了,既要找到能够完成加法操作的又要高效。

? ? ? ? 寄存器分配:在指令选择阶段、编译器还需要考虑寄存器的使用情况、它决定哪些数据应该存储到寄存器当中、以及合适将数据从内存当中加载到寄存器当中,以保证高效执行。

? ? ? ? 处理复杂操作:有时候、高级语言当中的一条简单语句可能需要多条底层指令来实现、例如一条高级语言当中的循环操作可能需要转化为条件分支、跳转和递增指令的序列。
? ? ? ? 优化:指令选择阶段也是进行一些优化的时机、编译器可以通过选择适当的指令序列、或者通过一些代数优化、来提高生成的汇编代码的效率。

????????

? ? ? ? 3.地址计算:

????????地址计算是编译器当中的一个重要任务、它涉及将高级语言中的变量和内存地址映射到底层的汇编指令当中,以下是地址计算的一些关键步骤:
? ? ? ? 变量到内存地址的映射:编译器首先确定高级语言中的每个变量在目标机器当中的具体位置、这通常包括局部变量、全局变量和参数。每个变量都有一个相对于某个参考点(例如函数栈帧或者数据段)的偏移量。
? ? ? ? 寻找基址:在地址计算当中、寻找基址是关键的一步、基址是一个相对于参考点的地址、它用于计算其他变量的地址、例如函数的栈帧指针可以作为基址(就是栈顶地址也叫做栈顶指针),用于访问局部变量和函数参数。
? ? ? ? 计算偏移量:一旦有了基地址,编译器可以计算变量相对于基址的偏移量、这个偏移量是根据变量在数据结构当中的位置和大小来计算的。

? ? ? ? 生成汇编指令:编译器根据计算出的地址信息生成对应的汇编指令(这个很好理解,如果我都不知道这条语句在哪里的话,我怎么在这个地址处生成相应的汇编指令呢?)、这些指令包括加载指令(load)和存储 (store)操作、用于将数据从内存当中加载到寄存器当中获奖寄存器当中的数据存储到内存当中。(这里明确一个概念数据从内存到寄存器叫做加载,寄存器返回内存叫存储、其实意思都一样只是专业的叫法不一样)

? ? ? ? 处理复杂数据流:在高级语言当中,数据结构可以包含复杂的嵌套和指针引用,编译器需要处理这些复杂情况,确保计算正确地址、这可能涉及到递归地计算嵌套结构的地址。

? ? ? ? 考虑优化:在地址计算阶段、编译器会考虑一些优化策略、例如通过寄存器间接寻址来减少内存访问、或者通过使用常量折叠等方式来简化地址。

? ? ? ??

? ? ? ? 4.控制流转移:

????????首先我要先说明一下这个控制流转移具体是一个什么样的东西或者说是什么过程。
? ? ? ? ? ? ? ? 我们要知道控制流转移的存在是因为程序的执行并不是一条线性的执行流,这里面涉及到了中断处理或者跳转等其他操作,程序当中包含了条件分支、循环分支和函数调用结构。控制流转移允许程序在执行时根据条件或者需要跳转到不同的代码段,实现了程序的灵活性和功能性。

? ? ? ? 条件执行:控制流转移允许程序在满足或者不满足某个条件时执行不同的代码块、知识的程序能够根据输入、状态或者其他条件做出不同的决策、提高了程序的灵活性。

? ? ? ? 循环结构:控制流转移时是心啊循环结构的关键、循环程序允许程序多次执行相同的代码块,而不需要显示重复相同的指令,通过控制流转移程序能够回到程序最开始的地方、实现迭代进行。

? ? ? ? 函数调用和返回:控制流转移用于在程序中调用函数和返回函数、函数调用时、控制流跳转到函数的入口;函数返回时控制流回到调用点,继续执行后续的指令。

? ? ? ? 异常处理:控制流转移用于处理异常情况、当程序发生错误或者异常的时候、控制流可以被转移到相应的异常处理代码、以采取适当的措施。

? ? ? ? 代码结构组织:控制流转移有助于组织代码的结构、通过合理的控制流转移、程序员可以实现清晰的逻辑结构、提高代码的可读性和可维护性。


? ? ? ? 好的接下来我为大家具体的演示一下,编译器是怎么进行控制流转移的。
? ? ? ? 条件语句的转译:对于高级语言中的条件语句(if - else)编译器会生成对应的判断指令、例如在汇编当中可以使用条件跳转指令(如 ‘JZ’ ‘JNZ’)来实现,根据条件的成立与否跳转到不同的代码块当中,
????????

if (condition) {
    // code block A
} else {
    // code block B
}

汇编伪代码
; Assuming condition is in register R1
CMP R1, 0 ; Compare condition with 0
JZ  code_block_B ; Jump if zero (condition is false)
; code block A
JMP end_of_if ; Jump over code block B if condition is true
code_block_B:
; code block B
end_of_if:

? ? ? ?这里的代码大家理解就好,控制流转移就为大家介绍到这里。

????????5.数据传送:

????????数据传送阶段是编译器将高级语言中的数据操作(如赋值语句)翻译成为相应的汇编指令、确保数据正确地从一个位置传送到另一个位置。数据传送阶段就是编译器将高级语言当中的数据操作(如赋值语句)翻译成底层的汇编指令的过程、该阶段主要涉及将数据从一个位置传送到另一个位置、这包括寄存器、内存或者其他数据区域。数据传送无非就是数据在计算机当中的流动、从寄存器到另一个寄存器或者到内存,只要确保他们能够正确赋值即可。这里没有什么特别难理解的,就为大家介绍到这里了。

? ? ? ? 6.优化:

????????优化是编译器在生成汇编代码阶段进行的重要工作、旨在提高程序的执行效率、优化涉及到对生成的汇编代码进行改进、以减少执行时间、降低内存消耗等等、优化涉及到对生成的汇编代码进行改进、以减少执行时间、降低内存消耗、以下是一些常见的优化技术。

? ? ? ? 寄存器优化:编译器尝试最大限度地使用寄存器、减少对内存的访问,这里面包括寄存器的分配和重用(寄存器的分配具体的内容我在上文当中已经讲过了,这里就不提了)。以减少数据在寄存器和内存之间的传输。
? ? ? ? 常量折叠:编译器尝试将常量表达式在编译的时候进行计算这样就可以减少运行时的开销。

? ? ? ? 循环展开:如果循环次数较少的话,例如只循环4次循环体直接被展开为4次,以减少循环控制的开销,这可以提高程序运行的并行性,加快循环的执行。
? ? ? ? 条件分支优化:预测分支的方向以减少分支错误的影响,也有可能会进行条件移动等优化。

? ? ? ? 死代码消除:编译器删除不会被执行的代码、以减少可执行文件的大小、并提高执行效率。

? ? ? ?

7.符号表生成

? ? ? ? 这部分的内容是重中之重,虽然我这篇文章花费了大量的篇幅去讲解编译器,但是不要忘了我们这篇文章的主题是基于编译器理解继承和多态,加油啊铁铁们,铺垫知识很快就要结束了,千万别走神!

????????当编译器在处理源代码的时候、符号表是一个非常关键的数据结构、用于存储程序当中的标识符(如变量名、函数名等)以及这些标识符相关联的信息、不过各位一定要注意,虽然我的文章到了这里才开始分析符号表的生成,但是我还是要提醒大家,符号表其实词法分析的过程就已经开始了,每一个阶段都会进行不断地填充并且完善、知道可执行文件生成才会停下来,这一点我们务必要牢记!

? ? ? ? 现在我为大家详细讲解符号表生成的具体过程。
? ? ? ? 1.初始化符号表:
? ? ? ? 在编译器的符号表生成阶段开始的时候、会初始化一个空的符号表数据结构、这通常是一个哈希表、树形结构或者其他适合快速检索的数据结构。通常编译器会使用哈希表来进行初始化。

? ? ? ? 哈希表的键值就是标识符的名称、值是包含信息的数据结构、在初始化阶段、这个表是空的、随着编译进行,会不断地向其中添加新的条目。

? ? ? ? 2.·识别标识符:

? ? ? ? 编译器通过词法分析阶段从源代码当中识别出词法单元、这可能包括变量名、函数名、类型名等。词法分析的具体过程我已经在上文当中讲到了,这里就不再进行过多的赘述了。

? ? ? ? 3.处理标识符属性:

? ? ? ? 对于每个识别到的标识符、编译器收集相关的属性信息、如标识符的名称、类型、作用域等。如果已经存在于符号表当中、可能需要更新已有的条目、如果不存在、则创建一个新的符号表条目。

? ? ? ? 对于每个识别到的标识符、编译器收集相关的属性信息、如标识符的名称、类型、作用域等。如果已经存在符号表中、可能需要更新已有的条目;如果不存在,则创建一个新的符号表。

? ? ? ? 编译器处理标识符属性的时候、它必须对每个识别到的标识符进行处理、并且更新或者创建符号表当中的相应条目、以下是一个详细的步骤。

? ? ? ? 1)识别标识符并收集属性信息:对于每一个标识符,编译器要收集相关的属性信息,这可能包括。

? ? ? ? 名称:标识符的名字。

? ? ? ? 类型:标识符的数据类型、例如整数、字符串、浮点数等等。

? ? ? ? 作用域:标识符所在的作用域 、例如全局作用域、函数作用域等等。

? ? ? ? 地址:在内存中的地址或者偏移量、用于访问标识符的存储位置。

? ? ? ? 大小:标识符所占用的内存大小、对于数组或者结构体等符合类型很重要。

? ? ? ? 值:标识符的当前值、对于常量或者变量而言。

? ? ? ? 其他属性:可能的其他属性、如是否是常量是否被初始化等等。

? ? ? ? 2)检查符号表:这个时候编译器需要检查是否已经存在了相同名称的标识符。

? ? ? ? 如果存在:

? ? ? ? ? ? ? ? 更新信息:如果已经存在了相同名称的标识符,编译器更新符号表中该条目的信息、确保它反映源代码当中的最新属性。例如可能需要更新类型、作用域、地址大小等信息。

? ? ? ? ? ? ? ? 错误检查:进行必要的错误检查、例如用重复定义的变量或者函数。

? ? ? ? 如果不存在:

? ? ? ? ? ? ? ? 创建新条目:如果符号表当中没有找到相同的名称的标识符、编译器创建一个新的符号表条目,并且将收集到的属性信息插入到表中。

? ? ? ? 3)继续分析:编译器继续分析源代码、处理下一个标识符或者语句。

? ? ? ? 这个过程确保符号表保持更新、反映源代码当中标识符的最新状态。符号表的正确性对于后续的语义分析、代码生成和优化阶段都至关重要。

? ? ? ? 4.作用域处理:编译器会跟踪程序的作用域、并且在符号表当中记录标识符作用域信息、这有助于处理同名但在不同作用域的标识符。我们应该都知道、作用域就是程序中变量、函数和其他标识符的可见性和访问范围。作用域处理对于确保程序中的标识符不会产生冲突或者混淆非常重要、接下来我为大家详细介绍一下这个环节。

? ? ? ? 1)全局作用域:全局作用域是整个程序的最外层作用域、其中定义的变量和函数在整个程序当中都是可见的。全局作用域由编译器负责跟踪和管理。

? ? ? ? 2)局部作用域:局部作用域是在函数或者某个代码块内部定义的作用域。在局部作用域中定义的变量通常只在该作用域内可见、每当定义一个新的函数或者代码的时候、编译器会创建一个新的作用域。

? ? ? ? 3)作用域链:作用域链是指在嵌套的作用域结构中,一个标识符查找的路径。在某个作用域内引用一个标识符时、编译器会按照作用域链逐级查找、直到找到匹配的标识符或者达到全局作用域。这有助于处理同名但在不同作用域的标识符。(这个概念大家理解就好,具体实现涉及到复杂的算法,这里就不详细解释了,但是作用域链这个概念大家一定要清楚)

? ? ? ? 4)纳入符号表:编译器会将标识符的作用域等信息记录入符号表以方便管理。

? ? ? ? 5)作用域解析规则:编译器需要遵循一定的作用域解析规则,确保在不同作用域当中使用相同名称的标识符时不会发生冲突。一般来说内存作用域的标识符会覆盖外层作用域的同名标识符。作用域处理是编译器确保程序中标识符正确可见性和访问性的关键步骤。

? ? ? ? 5.类型处理:对于变量、函数等标识符、编译器会确定其类型、并在符号表当中记录这些信息、这对后续的类型检查和中间代码生成是至关重要的。类型处理是编译过程当中确定标识符的数据类型并在符号表当中记录相关信息、这一过程对于确保程序的类型安全、惊醒类型检查以及生成有效的中间代码都只管重要。

????????6.链接处理(可选):如果编程语言支持模块化编程、编译器可能需要处理符号的链接信息、以确保在不同的模块之间正确引用标识符。

? ? ? ? 7.错误处理:编译器在处理标识符的过程当中可能会遇到一些错误、例如重复定义、未声明等在这一阶段、他需要识别并且报告这些错误。

? ? ? ? 8.最终符号表:符号表生成阶段结束的时候、编译器会得到一个最终的符号表、其中包含了源代码当中所有的标识符信息。

? ? ? ? 符号表生成是编译器前端的关键部分、它为整个编译阶段提供了必要的信息,符号表的生成贯穿了整个编译的过程,它确保了源代码当中的标识符能够被正确地识别、管理和使用。

8.错误处理

? ? ? ? 负责检测、报告和处理源代码当中的错误,主要包括错误检测、错误报告、错误定位、错误恢复、错误编码、警告处理等等信息、这个不是我们要研究的重点。

?拓展:

? ? ? ? 在生成可执行文件之前、编译当中还有一个重要的任务就是链接,这个过程是由链接器来完成的。那么链接器的目的是什么呢?明确地告诉大家,链接器的任务就是将所有的编译工作整合在一起,在这个过程当中符号引用被解析、地址被重定位、各个目标文件和库全部都被整合在一起,变成一个可以在特定平台上执行的程序。说白了就是将生成的目标文件组合成一个可执行文件,就这么简单。所以如果把我们上文当中讲到过的有关编译的知识串联起来的话很简单就是以下这些过程。

词法分析 -> 语法分析 -> 语义分析 -> 中间代码 -> 优化 -> 目标文件 -> 链接器 -> 可执行文件。符号表贯穿了整个过程。

? ? ? ? 在这里我就不对链接这个过程做详细地介绍了,否则这篇文章战线拉得太长了😂,在农历年前我会将整个编译阶段的知识点,重新整合成一套完整的知识体系,到那个时候我会详细介绍链接的整个过程,这里对链接就暂时先略过了。

9.生成可执行文件

? ? ? ? 最终的阶段是将链接后的代码生成一个可执行文件、这个文件包含了程序的完整机器代码、可以由操作系统加载和执行,这个文件经过最后的优化会生成调试信息、以便程序员在程序出现错误的时候进行调试,这些信息包括源代码行号、变量名称等等。总体而言、可执行文件生成是编译过程的最后一步、将所有的编译、优化和链接工作整合在一起、生成可以在特定的平台上执行的程序。

总结:

? ? ? ? 到这里,编译器的初步知识我就介绍完了,洋洋洒洒已经写了一万五千多字了,下面就要进入我们的正题——从编译器的角度看多态,前面的都只是铺垫?。我将用我自己的方法带领大家从虚方法表和运行时类型标识,带大家深度理解多态实现的底层逻辑。


二、基于编译理解继承和多态

1.细谈动静态绑定

? ? ? ? 1.纠正一个误区:

? ? ? ? ? ? ? ? 我相信很多小伙伴在第一次听到动态绑定和静态绑定这个概念的时候应该是学习多态的时候,这个时候老师们总是会拿出重载和重写来举例子,然后就会很敷衍地告诉我们静态绑定是在编译的时候确定方法调用目标的机制、而动态绑定是在运行时确定方法调用目标的机制。好了就到了这里以后,我们听到了静态绑定和动态绑定的概念,所以从此之后我们认识了静态绑定和动态绑定,我们就形成了一个刻板的印象,好像多态之所以是多态就是因为他采用的是动态绑定,而动态绑定好像成了多态的标签、静态绑定好像也就成为了重载的标签,从此以后我们的刻板印象也就和重载和多态牢牢地绑定到了一起。当然我没有说这个概念就是错的,大家可以看一下我是怎么分析的。

? ? ? ? 2.明确一个概念:

? ? ? ? ? ? ? ? 我刚才也提到了,静态绑定是在编译的时候就能够确定方法调用的目标(就是编译器就知道要调用哪个方法),而动态绑定是在运行时能够确定方法调用的目标,好!我问一个问题,各位准备接招!请问!!!什么时编译时什么是运行时?可能有些小伙伴要懵圈了,不对呀,平常这个代码我写完之后直接Ctrl + F5 或者 Ctrl + shift + \ 直接运行出结果了呀?我哪知道什么是运行时什么是编译时呢?这就是我为什么在这篇文章的前面长篇大论的讲了半天编译,就为这里铺垫,我可以明确地告诉大家,生成可执行文件及其之前时编译时,可执行文件加载到内存当中之后进行解析的过程叫做运行时,这个过程都是由编译器来完成的,一定要记住了各位铁铁们,运行时和编译时的这个概念是相对于编译器来说的。可不是相对于程序员来说的。

? ? ? ? 3.静态绑定的使用场景:

? ? ? ? 1)静态方法调用:静态方法是与类相关联的而不是与对象相关联的、因此他们通常通过类名来进行调用,具有静态绑定的特性。

class Example {
    static void staticMethod() {
        System.out.println("Static method");
    }
}

Example.staticMethod();  // 静态绑定,编译时确定调用的目标

? ? ? ? 2)final 方法:final 方法是无法被子类重写的方法,因此他们在编译的时候就能够确定方法要调用的目标。

class Example {
    final void finalMethod() {
        System.out.println("Final method");
    }
}
? ? ? ? 4.动态绑定的使用场景

? ? ? ? 这里可以明确地告诉大家之所以采用多态往往都是与方法的重写有关、因为方法需要重写,所以导致编译器在编译的时候无法确定具体执行哪一个方法,也就是说编译器无法再生成可执行文件之前确定具体调用哪个方法,必须讲可执行文件加载到内存之后,JVM利用虚方法表、运行时类型标识和符号表动态地确认,才可以知道具体要调用的是哪个方法,所以我们可以在这里具体地明确一个概念

? ? ? ? 1)重写方法调用:大多数情况下,实例方法的调用涉及到动态绑定,因为方法的具体调用目标是在运行时基于对象的实际类型动态确认的。

class Animal {
    void makeSound() {
        System.out.println("Animal makes a sound");
    }
}

class Cat extends Animal {
    void makeSound() {
        System.out.println("Cat meows");
    }
}

Animal myCat = new Cat();
myCat.makeSound();  // 动态绑定,根据实际类型确定调用目标

? ? ? ? 2)抽象方法和接口方法:在抽象类和接口中定义的为实现方法,它们子类或实现类中被具体实现时涉及到动态绑定。

abstract class Animal {
    abstract void makeSound();
}

class Cat extends Animal {
    void makeSound() {
        System.out.println("Cat meows");
    }
}

? ? ? ? 静态绑定和动态绑定的根本区别就在于静态绑定在编译时就能够确定调用目标、而动态绑定在运行时才能够确定调用目标,适用于需要在运行时基于实际类型进行灵活调整的场景。

? ? ? ? 好了到了这里我只是介绍了一些场景,我现在又要问?动静态绑定当中的绑定是指什么意思,是谁和谁进行绑定呢?
? ? ? ? 我在这里可以非常负责人地告诉大家这里的绑定指的是我们在调用方法的时候的方法名和具体的方法实现之间的一种绑定,注意了这个概念看似好像很好理解,能把这一点说清楚的人真不多,我问过很多学编程的学习学妹们,一说起动静态绑定都知道,能够在编译时确定方法调用目标的就是静态绑定,反之亦然。但是当我问起那具体是谁和谁进行绑定呢?很多人都回答不上来。

? ? ? ? 所以我在这里为大家明确了这个概念,而且要想彻底掌握编程当中这些核心的思想、学会计算机底层的知识是必要的,包括编译原理、我这篇文章当中第一部分的内容希望大家好好消化,为我后面讲到多态的具体实现的时候,打好基础。


?2.虚方法表与多态的关联

? ? ? ? 虚方法表(VTable)是一种用于实现动态绑定的机制,每个类都有一个对应的虚方法表、虚方法表是一个存放类当中所有虚方法地址的表格,每个对象实例都包含一个指向这个虚方法表的指针(Java当中是引用)。

? ? ? ? 当我们在调用虚方法的时候、实际上是通过虚方法表来查找并且调用相应的方法,这使得在代码运行的时候能够动态地确定调用的具体方法,实现了动态绑定。

? ? ? ? 1.什么是虚方法

? ? ? ? 在Java当中虚方法是一种支持动态绑定的方法,具体来说虚方法是指非静态的实例方法,并且没有被声明为static 或者 static。那么具体哪些方法被称为是虚方法呢?我为大家列举一些。

? ? ? ? 非静态方法:虚方法必须要是实例方法、不可以是类方法(静态方法)。

? ? ? ? 可继承:虚方法可以被子类继承。

? ? ? ? 可覆写:子类可以使用@Override 注解来覆写方法的虚方法。

? ? ? ? 动态绑定:虚方法支持动态绑定,即在运行时根据对象的实际类型来确定要调用的方法。

? ? ? ?大家应该也发现了,我在论述多态的时候正好是反着来的,多态为什么能够实现,因为它能够实现动态绑定因此实现了多态、那么动态绑定又为什么会发生呢?因为有着虚方法表,虚方法表的调用是动态绑定实现的基础,那么虚方法表又是为什么会调用呢?因为只有以上三种(非静态、可继承、可重写)的方法才能够调用虚方法表。所以只有这三种方法才可以实现多态。我相信当我说到这里的时候,大家就可以明白了。

????????2.虚方法表如何建立

? ? ? ? 当一个类加载到Java当中的虚拟机当中的时候、会经历以下这些步骤:

? ? ? ? 加载类:JVM讲类的文件加载到内存当中、这一步通过JRE当中的类加载器来实现,类的加载器会解析类的结构信息其中包括类的字段、方法信息等。(这部分内容我在《从JVM看Java》系列当中的第一篇文章当中又详细讲解,感兴趣的小伙伴可以去看一下。)

? ? ? ? 准备阶段:JVM为类的静态变量分配内存、并且初始化为默认值、同时、也会为类中的方法建立符号引用(这个引用就是方法名,这里插一句嘴,学过C语言的小伙伴应该知道函数名就是函数的入口地址,当通过方法名来调用函数的时候其实使用了函数的地址,其实这里也一样的,首先通过对象名调用方法名,这里调用的就是方法的引用,然后对象引用又通过方法当中的this参数也就是this引用来对方法当中的各种变量进行操作,最终得出结果,详细的过程我的上一篇文章当中已经详细讲过了,这里就不再废话了)

? ? ? ? 解析阶段:JVM会将符号引用替换为直接引用,即将方法的符号引用转化为实际内存当中方法的地址。

? ? ? ? 创建虚方法表:在内存当中为类的每个方法创建了一个虚方法表的头目、虚方法表的每个条目包含方法的实际地址。

? ? ? ? 对于非静态、非私有、非final的实例方法:由于这些方法它们可能会被子类重写、所以他们这些方法的虚方法表当中存放的都是当前对象的实际类型所对应的方法的地址,这将会运行在运行时根据对象的实际类型来调用正确的方法。

? ? ? ?

package Yangon;

public class Animal {
    public void Print(){
        System.out.println("Hello World!");
    }
}
class Dog extends Animal{
    @Override
     public void Print(){
        System.out.println("Hello Animal!");
    }

    public static void main(String[] args) {
        Animal animal = new Dog();
    }
}

? ? ? ? 大家请看上面这段代码,Dog类型的虚方法表当中的Print方法会存放Dog类自己的Print

方法,虽然animal对象通过向上转型变成了Animal类型的对象,但是他的虚方法表当中仍然会存放Dog类自己的虚方法,但是编译器在编译阶段只知道animal这个对象是Animal类型的,并不知道它是Dog类型的,这一点只有在运行的时候通过运行时类型标识和虚方法表当中的虚方法调用这个过程,编译器才恍然大悟,原来animal对象并不是Animal类型的而是Dog类型的,这个过程就是动态绑定,这是多态实现的基础。

? ? ? ? 当我说到这里的时候想必大家也应该理解多态真正的含义了。? ? ??

? ??????对于静态方法、私有方法、final方法等:他们属于静态绑定、不参与动态绑定、虚方法表中存储自身的地址。不参与动态绑定。虚方法调用的时候调用的还是自己。

? ? ? ? 设置对象的虚方法表指针:每一个对象实例在创建的时候都包含一个指向自己类的虚方法表引用,这个引用存储在堆当中,虚方法表同类一起存放在方法区当中,JVM可以通过对象的虚方法表引用找到对应类的虚方法表、根据对应的虚方法表索引或者名称在虚方法表中查找实际方法的地址、然后调用该方法。

? ? ? ? 动态绑定:当调用对象的虚方法的时候、JVM通过对象的虚方法表引用找到虚方法表当中对应的虚方法、根据方法的索引或者名称在虚方法表中查找实际方法的地址、然后调用该方法。

? ? ? ? 好了虚方法表的内容我就给大家介绍到这里,相信大家已经有了一个初步的认识,接下来我要为大家介绍另外一个非常重要的知识点,运行时类型标识。

????????


总结

? ? ? ? 到这里这篇文章就算是彻底结束了,从12月1日开始一直到今天12月9日,这篇文章是我对于编译器的初步认识、后续我还会深入学习《编译原理》我会继续为大家讲解关于编译器的更多知识,这篇文章结尾我为大家详细地讲解了多态的底层实现原理,我相信很多编程初学者对于这部分的内容其实了解的都不够深入、这部分的知识我之前也很迷茫、他的定义每一个字我都能够看懂但是连起来看就是不明白是什么意思、我在浏览了很多的视频查阅了很多的资料、才对这部分的内容有了一个粗略的知识、利用这将近10天的时间整理出来展示给大家。很多老师对这部分的讲解实在是不够透彻、我坚持写博客一方面巩固我自己所、另一方面希望能够帮助到正在迷茫的各位。

? ? ? ? 我相信各位程序员朋友今年都很迷茫、今年的就业形势不容乐观、很多小伙伴可能也听说Java不行了、计算机要没落了之类的话、但是我想说如果给我一次重来的机会我仍然会选择计算机这条路、我本人是2024届应届生今年的10月份拿到的offer,我大学期间是主修C++的,包括实习岗位也是C++开发岗位、由于工作需要我决定开始转换语言,我从今年的11月13日开始学习Java,到现在将近一个月的时间了,虽说我是Java的初学者,但是我在大学期间已经系统地学习过《计算机组成原理》、《计算机网络》、《操作系统》、《C语言》、《数据结构》等计算机基础课程、所以我这次在学习Java的时候要比最开始学习C++的时候快得多。

? ? ? ? 如果大家有关于学习或者找工作上不明白的问题可以随时私信我,能够帮助到大家就是对我最好的鼓励!诸君共勉。

文章来源:https://blog.csdn.net/weixin_59658448/article/details/126328643
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。