一文看懂编程语言虚拟机

一文看懂编程语言虚拟机

虚拟机简介

虚拟机,顾名思义,就是虚拟的机器。再详细的解释就是,用软件模拟硬件资源(也就是硬件功能)。虚拟机其实只是一个普通的进程而已,但是这个进程的功能却是模拟了硬件的资源。

虚拟机的主要分类有两种,一种是我们常见的如,VMware、virtualBox这样的虚拟机,主要作用是在其上面安装不同的操作系统。

一种就是我们今天要讲的主题:编程语言虚拟机。主要作用就是模拟硬件CPU的功能,运行解释出来的中间代码 - 字节码。就比如我们常说的java的虚拟机jvm,ruby的虚拟机YARV等。

正文

虚拟机做了什么?

废话不多说,现在正式开始。为了方便,我们下面提到的虚拟机都专指编程语言虚拟机

CPU主要做了哪三件事?

正如我们刚说到过,虚拟机主要功能就是通过软件去模拟CPU的功能。因此,为了搞懂虚拟机到底做了什么, 我们就不得不先搞懂CPU到底主要做了什么事情!

CPU其实主要工作是做了三件事:1.取指, 2. 译码, 3.执行

1.取指

取指的意思就是从程序计数器(PC)中取出下一条待执行的指令,也就是二进制编码。

这里解释下程序计数器(PC),程序计数器也叫做PC,即Program Counter,用于指向下一条待执行指令的地址。由于CPU每次只执行一条指令,执行了多个指令后,就相当于给程序中的指令统“计” 了 “数”量 , 因此称为程序计数器。程序计数器只是个概念,在各个 CPU 中都有自己的实现 ,比如ARM体系的CPU存储下一条指令地址的寄存器就叫 PC,而 X86体系CPU存储下一条指令的 寄存器称为 IP,即 Instruction Pointer。

2.译码

译码也就是解码,意思就是分析指令的编码,判断指令中的操作码和操作数。这里顺便说下,指令是由操作码和操作数组成。

3.执行

执行的意思就是CPU用行动去展示指令的意图。指令是一种思想意图,思想意图需要行动去执行才能落实。

虚拟机要做的事

我们一直在说,虚拟机就是用软件模拟CPU功能,也就是说,虚拟机要能成功模拟CPU的功能,其实就是要做到CPU主要要做的三件事情: 取指、译码和执行

1.取指

CPU在取指阶段,是从内存中将二进制指令取出来的,二进制指令流是程序被系统的程序加载器加载进内存后的映像。CPU是直接认识二进制指令的,它知道一条指令需要多少内存,需要多少操作数,并且知道这条指令是干什么的,因为CPU会自动从指令中获取操作码和操作数,并且也知道多长的二进制流是操作码,这个操作码需要多少的操作数以及操作数在指令流中的长度和位移。也就是说,指令直接是CPU的菜,CPU懂这个指令流的格式。

也就是说,虚拟机其实肯定也要涉及到CPU的取值操作,那么CPU取到的指又是什么呢?

由于虚拟机只是一个程序,被加载到内存中之后,就是个运行的进程,那么这个运行的虚拟机进程要读取的代码指令肯定就不是CPU认识的二进制流了。当然,也可以将源代码最终解析成二进制流,但是这样子的用虚拟机取解析二进制流的开发成本就非常之高。因此,实际上,虚拟机取指阶段读取到的指令就是我们常说的字节码

字节码

字节码是什么呢,要明白字节码的话,就得先了解下编程语言源代码的编译流程。

我们这里主要只说解释型语言的编译流程。源代码只是文本信息,主要会经历以下几个阶段(这里只说前几个阶段):

  • 词法分析

    词法分析就是将源代码文本信息分析成一个一个的token。比如:

    int a = 10;
    

    这里的经过词法分析之后,会分析出5个token: ‘int’、‘a’、‘=’、‘10’、‘;’

    分别对应着:关键字、ID、赋值运算符、数字、分号

    词法分析的输出token会作为语法分析的输入

  • 语法分析和语义分析

    语法分析和语义分析的结果将识别分析句子的语法和语义,生成语法分析树(AST),在这个阶段就能知道语法有没出错,以及明白每一句代码的语义是干嘛的

  • 生成中间代码

    语义分析的最后,会生成中间代码,也就是字节码

在这里的字节码就会作为虚拟机的指令输入。也就是说,这里的字节码其实就相当于是CPU的二进制指令流,但是字节码不是二进制流,只是文本流,但是它此刻已经具备指令的功能,你可以把它理解成汇编代码(但是并不是汇编代码,并且和汇编代码还是有差异)。

字节码也称byte code,是一种虚拟机语言中经常采用的中间代码,比如PHP、 Python等。字节码是语义分析的输出 ,同时也是虚拟机的输入,也就是说语义分析的目标代码就是字节码 。字节码只绑定于特定的虚拟机, 即仅适用于特定虚拟机, 井不绑定到特定的CPU硬件, 因此字节码是自定义的,形式不限 。指令是由操作码和操作数组成的,操作码称为opcode,操作数称为operand。为提升虚拟机的效率,操作码种类尽量要少 ,基本上都小于 256 个,因此操作码用 1 个字节便能表示,这也正是字节码中 字节 二字的缘由

CPU取指的指令流是二进制流,虚拟机取值的指令流就是字节码流。字节码将作为虚拟机的输入,虚拟机将解析输入的字节码,执行字节码的意图

2.译码
  • 判断指令的类型,进入相应的处理流程
3.执行
  • 按照指令的意图,执行相应的历程函数
虚拟机的分类

CPU在完成以上三个步骤之后,如果要输出结果的话,会将输出的结果存放在寄存器或者内存中的,但是绝大部分情况是直接存放在寄存器中的,因为寄存器的访问性能远高于内存的访问性能。但是,我们已经知道,虚拟机只是个运行中的进程,它完成以上三个步骤后,如果要存储结果的话,是不可能直接存放在寄存器中的,因此就只能存放在内存中,因为虚拟机本身就只是一个在内存中的程序。

虚拟机按照输出结果的存储结构可以分为两种类型:基于寄存器的虚拟机基于栈的虚拟机

基于寄存器的虚拟机
  • 实现 - 说白了,就是在虚拟机内部也完全模拟寄存器的功能,在虚拟机内部实现寄存器

这种情况通常是用一个数组来实现一个寄存器结构,数组中一个个的元素便是一个个的寄存器

指令是由操作码和操作数组成的,操作数可以放在模拟的寄存器中,指令执行的结果也可存储到模拟的寄存器中

这种类型的虚拟机代表就是: lua

  • 缺点 - 实现难度较大

    指令是由操作码和操作数组成的,两类虚拟机的操作数分别用不同的结构来存储,因此这涉及指令生成的难度不同

    基于寄存器的虚拟机所模拟的寄存器和真实系统一样,因为寄存器的个数是有限的,要把无限的变量分配到有限的寄存器中,如何避免不冲突,这涉及寄存器分配的算法,比如经典的图着色算法,因此难度会较大一些。

基于栈的虚拟机
  • 实现 - 不模拟寄存器,指令的执行结果存储在模拟的运行时栈当中,操作数可以存储在运行时栈中也可以直接存储在运行时栈当中

    栈本身是个后进先出的数据结构,执行结果一般会以压栈的形式 push 到栈顶,为维护栈平衡,需要马上被取出,即通过 pop 操作获得结果。这里所说的“模拟的运行时栈”并不是只有基于栈的虚拟机才有,基于寄存器的虚拟机也要模拟运行时栈,因为这是执行函数或方法所必需的。栈一般用线性结构来模拟,比如数组、链表

    这种类型的虚拟机代表: jvm

  • 缺点 - 基于栈的虚拟机性能稍低

    首先 栈是由内存结构模拟的,压栈和出栈都要分两步,先移动栈顶指针再获取数据 ,传递数据时需要把操作数压栈,然后再由接收方出栈,因此多了一次操作

    其次 操作数放在寄存器中还有优化的余地,放在栈中的话位置就不能动了,一般是栈顶和次栈顶(位于栈顶之上,仅次于栈顶的已用 slot),因此整体上优化潜力略低

为什么要采用虚拟机

众所周知,虚拟机主要是给那些解释型语言使用的,就比如,PHP、 Perl、 Python、Java等。编译型语言是不需要虚拟机的,因为编译型语言是直接将代码生成二进制文件的,因此能直接运行在硬件CPU上的。

其实吧,虚拟机比真实物理机是要更慢的

虚拟机比物理机慢,是因为虚拟机这个软件服务的对象不同,也就是角色不同,它的客户从没发现它是虚拟机,一直就把它当作真实的物理机来对待。在细节上虚拟机为什么慢?

程序最终会被编译为指令,几乎每条指令中的操作数都会涉及寄存器,在虚拟机中模拟的寄存器是某种数据结构(比如数组,但形式不限),该数据结构必然是在内存当中,内存操作比真实的寄存器操作要慢上几个数量级, CPU更愿意用寄存器是因为寄存器位于CPU 内部,和 CPU 速度是一致的,一个操作仅需要一个时钟周期。而内存是在外部,而且内存操作要通过总线的方式,等上几十个时钟周期后内存才收到读写的消息,最要命的是,内存读写过程中要花费掉的时间对 CPU 来说是天文数字,等拿到数据时 CPU 都老了。在虚拟机中,为了维护好所模拟的寄存器,必然要额外写一些相关的维护指令,比如为寄存器计数,以此来判断该寄存器的使用频率,这可作为下次分配寄存器时的判断依据。还有更恼火的是,如果所有寄存器都被使用了,要考虑先置换出 一 个,先腾出空间来供本次指令的执行等等,这都要一系列算法,算法本身位于解释器中(也许是编译器中,也许是虚拟机中), 其指令数细化到物理CPU上是指数级的, 这对单纯的寄存器读写操作来说,产生了大量不可忍受的冗余,因此虚拟机寄存器一个读写涉及更多更腥的内存操作,而物理机寄存器操作仅是直接一步,必然要比虚拟机快太多 。不可避免的是几乎每一条指令都包含寄存器,虚拟机执行每条指令都有这样的拖沓,这对于十几万条指令的程序来说,速度上的差别是显而易见的。而且,如果一些处理算法是在虚拟机中被调用,也就是在运行时阶段随机应变、临场发挥,这比起在编译阶段就搞定这些问题的二进制可执行程序来说,更加降低了效率。

既然虚拟机要比物理机慢,那么为何还是很多语言要采用虚拟机呢?

主要有以下几点原因:

  • 1.可移植性强

    解释器生成的中间代码(也就是字节码),不仅仅是为了作为虚拟机的输入和模拟二进制指令流,还有一个更重要的原因是,这套中间代码可以被虚拟机解析执行,因此,只要每个操作系统都有这个虚拟机的话,那么这套中间代码就能实现跨平台的功效

    真正做到了,Write once, Run everywhere

  • 2.大部分情况下,虚拟机并不是整个系统慢的真正瓶颈所在

    虚拟机是为脚本语言服务的,尽管在执行脚本语言时显现了速度上的不足,但这并不重要,

    虚拟机执行脚本语言毕竟是属于内存中的操作,有比它还慢的,而且还慢得很多,比如系统中免不了的磁盘操作和网络传输,无论是编译语言还是脚本脚本语言在执行这类操作时都要被操作系统阻塞,而这个过程的时间可是要比虚拟机执行字节码指令程序慢上好几个数量级的,因此脚本语言还是相对很快很快很快的了。

  • 3.更易于开发出功能强大的脚本语言

    脚本语言比编译型语言的语法更简洁,能用更短的代码实现较多的功能,对于开发人员来说更省事儿,使用起来更方便。主要原因如下:

    • 虚拟机多数是用高级语言开发的,高级语言的功能较强大,而且还有很多现成的软件包可用。比如脚本语言中“ hello,”+“world“ 就可以把两个字符串连接成“ hello, world。”,脚本语言要实现这样的功能,在其背后的高级语言(如 C 语言)可以调用 strcat 函数来直接实现两个字 符串的连接,要是用底层汇编的话就麻烦多了,因此用高级语言编写的解析器(包括词法分析器、 语法及语义分析器、虚拟机)容易构建出功能强大的脚本语言
    • 虚拟机只是执行操作码,一个操作码在虚拟机层面往往是由多个函数来实现的,这意味着一个操作码的功能是非常强大的,而操作码是由脚本语言生成的,因此脚本语言的功能非常强大也是必然的
    • CPU执行指令的 3 个步骤是取指、译码和执行。其中前两个步骤是非常耗时的在虚拟机中也会有这 3 个步骤,同样这 3 个步骤中的取指和译码是虚拟机中最耗时的部分,不过与 CPU不同的是,虚拟机可以规避这些耗时的部分,它不提供这样的指令,由前端语法和语义分析直接 处理,不需要经过虚拟机,比如对于表达式 1+2 来说, CPU 要用指令 add 来做 , 这涉及“取指, 译码,执行”,然而虚拟机可以没有 add 操作码(当然也可以有,取决于实现),可以在语义分析 阶段直接生成开发语言的表达式“ 1+2飞避免了“取指,译码”,相当于直接“执行”了

因此,综上所述,虚拟机的选择还是很有必要的

猜你喜欢

转载自blog.csdn.net/qq_20255275/article/details/131532449