什么是虚拟机?
“虚拟机”是个非常大的概念,从字面意思理解,“虚拟机”就是“虚拟的计算机”,我们在学习服务端编程时,相信大部分同学都接触过虚拟机。有这样一种场景,由于我们日常使用的计算机大部分是Windows操作系统,但绝大多数的服务端软件却都运行在Linux系统上,假设我们在Windows上进行编程,就无法直接在Windows上进行测试,非常不方便。基于这样的场景于是就有了虚拟机,它的作用是可以在windows系统的基础上运行Linux系统,然后我们就可以很方便的在windows系统上测试Linux系统的程序。这个Linux操作系统是通过某种技术手段虚拟出来的,中间的过程非常复杂,无法用三言两语来描述。
今天想聊的虚拟机和上面说的虚拟机略有不同,但是它们要解决的问题是一样的。上面说的虚拟机,它虚拟出了一个完整的操作系统,我把它称之为“操作系统级虚拟机”。而我们今天要聊的虚拟机它是针对编程语言的,它能达到的效果是,同一份代码运行在不同的操作系统上输出相同的结果,可以实现一次编写到处运行,我把它称为“语言级虚拟机”。我们非常熟悉的Java、PHP、Python等编程语言,实际上都是基于虚拟机的语言,它们都具备跨平台性,我们只需要编写一次代码,就可以运行在不同的操作系统上,并且输出几乎完全相同的结果。
了解过系统编程的同学应该都知道,不同操作系统对于同一个功能所提供的“系统API”可能是不一样的。例如 Windows和Linux系统都提供了网络监听的API,但是它们对应的SOCKET API却不同,假设我们使用平台相关的编程语言(例如:C、C++),我们在编程时就必须要注意这样的区别,并且针对不同的操作系统做相应的兼容处理,否则程序在Linux系统上能正常运行,但是Windows就会报错。这种类似的区别非常多,具体细节要看对应的系统编程手册才知道。有的系统API完全不同,而有的仅仅是个别参数不同,方法名完全相同,程序员在编写代码时需要时刻注意这些,才能编写出健壮的跨平台代码,这对新手来讲是非常困难的,并且这样一来,程序员就需要把很大一部分精力花费在兼容性问题上,而不能专注于实际功能的开发。
有了虚拟机之后,上面的问题就不复存在了。虚拟机的作用简单来说就是中介代理,好比我们初来乍到大城市要租房子,北上广等大城市的房东那么多,如果没有房产中介(虚拟机),我们就需要和N个房东对接,然后才能租到合适的房子;有了房产中介(虚拟机),我们只需要告诉房产中介(虚拟机)我们要租什么样的房子,由房产中介(虚拟机)去协调各个房东,我们就能租到合适的房子,过程不同,最后的结果是相同的。同理,以Socket API调用为例,我们把编写好的代码交给虚拟机,再由虚拟机来负责调用系统API,相当于中间加了一层中介代理,虚拟机将根据操作系统选择正确的Soekct API,来帮我们完成最终的功能。这样的好处是程序员不再需要关注底层API的细节,可以专注于真正功能的编写,虚拟机帮我们屏蔽底层系统API的细节,并且编程的门槛也大大降低,代码健壮性也大大提高。
PHP的执行过程
PHP解释执行过程
了解PHP的同学都知道,PHP是一种解释型语言,也称作脚本语言,它的特点就是轻量、简单易用。传统的编程语言在运行前都需要进行编译、链接,然后才能执行并输出结果。而脚本语言(PHP)则省略了这个过程,直接通过shell命令就能执行执行并输出对应的结果,非常轻量、直观、易上手。不瞒大家说,我在入坑编程时也学过Java,为什么最后入了PHP的坑呢,可能就是这些特点吸引的我。
刚才我们只说了PHP的优点,但是大多数时候都是有得必有失,我想编程语言也一样,PHP非常轻量、易上手那么它必然是牺牲了某种优点为代价的,否则为什么其它编程语言不这么做呢。接下来我们就聊一聊PHP的执行过程,我想了解了PHP的执行过程,就能理解PHP语言设计上的取舍了。
以下是PHP在开启了Opcache缓存后程序运行的主要过程。
图-1
从 图-1 中可以看到,载入PHP代码文件后,首先通过词法分析器(re2c/lex),从代码中提取出 单词符号(token),然后再经过 语法分析器(yacc/bison),从token中发现语法结构后,生成抽象语法树(AST),再经由静态编译器生成Opcode,最后由解释器模拟机器指令来执行每一条Opcode。
另外,当PHP开起了Opcache后,ZendVM会对Opcode进行缓存处理,缓存在共享内存中。不仅如此,ZendVM还会对编译后的Opcode进行优化,编译的优化技术包括 方法内联、常量传播、重复代码删除 等。有了Opcache后,不仅可以省略掉 词法分析、语法分析、静态编译等步骤,同时Opcode也被额外优化了,程序的执行效率比首次执行时的速度更快。
以上就是PHP解释执行的过程,虽然解释执行对程序员非常友好,省略了静态编译的步骤,但实际上这个过程并没有省略,只是由虚拟机帮我们完成了,以牺牲一部分性能为代价,换来了轻量、易用性、灵活性。其中 词法分析、语法分析、静态编译、解释执行 这些流程都是在执行时完成的。
编译型语言执行过程
了解过解释型语言的执行过程后,作为对比我们再来看下编译型语言的执行过程,来看看它相比比解释型语言有什么不同。
图-2
从图-2中我们可以看到,虚线框中的执行过程包括:词法分析、语法分析、编译,这3步在PHP解释执行时也同样有,唯一的区别是,C/C++这3步是提前由编译器在编译过程中完成的,这样可以在运行时节省大量的时间和开销。生成汇编代码后,第4步是链接汇编文件,并生成可执行文件,这里的可执行文件指的是二进制的机器码,CPU可以直接执行不需要再额外翻译,这4个步骤合起来称为静态编译。可以很明显的看到,编译型语言相对解释型语言在前期需要做更多的工作,但换来的是更高的性能和执行效率。因此,一般在大型的项目中,由于对性能要求比较高,代码量也很大,如果采用解释型语言会大大降低执行效率,使用静态编译型能够获得更好的执行效率,降低服务器采购成本。
什么是JIT?
JIT可以说是虚拟机中最有技术含量的技术,刚才我们分别讲了解释型语言和编译型语言执行的过程,也分析了它们各自的优势和劣势,我们可以思考一下,有没有一种技术,既有解释型语言轻量、易上手的优点,同时也拥有编译型语言的高性能,结论就是JIT。下面我们要介绍的就是编程语言中的JIT技术,它的全称是“即时编译”,具体指的是什么呢?我们先来看下维基百科对即时编译的定义。
在计算机技术中,即时编译(英语:just-in-time compilation,缩写为JIT;又译及时编译、实时编译),也称为动态翻译或运行时编译,是一种执行计算机代码的方法,这种方法涉及在程序执行过程中(在运行期)而不是在执行之前进行编译。通常,这包括源代码或更常见的字节码到机器码的转换,然后直接执行。实现JIT编译器的系统通常会不断地分析正在执行的代码,并确定代码的某些部分,在这些部分中,编译或重新编译。
刚才我们说了,JIT既拥有解释型语言的轻量易用性,同时拥有高性能,那么它是如何实现的呢?以PHP8中加入了JIT的特性为例,下图描述了PHP开启了JIT特性后的执行流程,PHP8-JIT是在Opcache优化的基础上更进一步,将Opcache中保存的Opcode优化后再进行编译,将Opcode编译成CPU可识别的可执行文件,也就是二进制文件,相当于C++编译后的可执行文件,只不过这个过程不需要在运行前完成,而是在运行时,虚拟机开启后台线程,将Opcode转换成二进制文件,有了二进制文件缓存后,当下次执行该逻辑时,CPU就可以直接执行,不需要再经过解释,理论性能和C++一样。这样的好处就是既保留了PHP语言的易用性、灵活性,同时也获得了高性能。
JIT的触发条件
JIT实际上就是把运行时的一部分代码,转换成可执行文件并缓存起来,加速下次代码的执行。那么JIT是程序启动后就会触发吗?
JIT在程序初次启动时并不会起作用,可以理解为PHP/Java代码在首次执行时,其实仍然是以解释的形式运行的,JIT需要在程序运行一段时间后才能真正触发。说到这里,大家有没有跟我有一样的疑问,为什么JIT不在程序启动时,就把所有的代码都转换成可执行文件缓存起来,就像C++一样,这样岂不是效率更高。在Java语言中确实有少部分这样的应用,但并不是主流。主要有以下几方面的原因
- 全部编译成二进制文件需要耗费很多时间,程序启动会非常慢,这对于大型项目来说是不可接受的
- 并不是所有的代码都有必要进行性能优化,大部分代码在实际场景中用的并不多
- 编译成二进制会占用很大的容量
- 提前编译好相当于是静态的编译,JIT编译相对于静态编译有很多不可替代的优势
JIT的触发条件,主要是基于“计数器的热点探测”,虚拟机会为每个方法(或者代码块)建立计数器,如果执行次数超过一定的阈值就认为它是“热点方法”,在达到阈值后,虚拟机会开启后台线程将该代码块编译成可执行文件,缓存在内存中,加速下次执行的速度。以上只是简单描述了热点代码的触发规则,实际的虚拟机采用的规则,会比这个更复杂。
JIT&提前编译的优劣势
JIT编译器是在运行时进行的,我们很容易发现,它和提前编译相比有几个很明显的劣势。首先,JIT编译需要消耗运行时的计算资源,原本这些资源可以用来执行程序,不管JIT编译器如何优化(例如:分层编译),这是始终没办法回避的问题,其中最消耗资源的一步是“过程间分析”,比如分析这个方法是否永远不可能被调用,抽象方法是否永远只会调用单一版本的结论,这些信息对生成高质量的代码有非常高的价值,但是要精确的得到这些信息,必须要经过大量的耗时计算,消耗大量运行时的计算资源。反过来,如果这些耗时的工作的提前编译时就完成了,运行时就只需享受高质量代码带来的高性能,最多就是提前编译时稍微慢一点,但这都是可以接受的。
说了这么多,那JIT编译和提前编译相比,在性能优化上就真的没什么优势了吗?结论是不是的,JIT编译有很多提前编译不可替代的优势。正是因为JIT编译器是在运行时进行的,所以JIT编译器能获取到程序真实的数据,通过不断收集程序运行时的监控信息,并对这些数据进行分析,JIT编译器可以对程序做一些激进的优化,这是提前的静态编译器做不到的。
首先是,性能分析制导优化。比如说JIT编译器在运行时,通过程序运行的监控数据,如果发现某些代码块被执行的特别频繁,那可以集中优化这一块代码,例如:给这段代码分配更好的寄存器、缓存等。
然后是激进预测优化。比如说有一个接口,它的实现类有3个,但在真实运行过程中,95%以上的时间都在运行A这个实现类,通过数据的分析,那就可以激进的对它进行预测,每次都执行A,如果发现有几次预测错误了,可以退回到解释状态再次执行,但只是小概率事件,并且不影响程序执行的结果。
最后是链接时优化,传统的编译器的步骤是编译优化和链接是分开的,什么意思呢?加入某个程序需要用到A、B、C 3个库,编译器先各自编译这3个类库,并且进行各种手段的优化,转换成汇编代码保存到文件中,最后一步是将这3个汇编文件链接起来,最终转换成可执行文件。这里存在一个问题,A、B、C 3个库在编译时是分别进行优化的,假设A和B中有些方法是重复执行的,或者可以方法内联来优化,那是无法做到的。但是JIT编译器是的不同之处在于,它是运行时动态链接的,可以针对整个程序的调用栈进行优化,这样的优化更加彻底。
总结
写这篇博客的主要目的,是对自己这段时间学习虚拟机相关技术的一个总结,在我谷歌搜索PHP虚拟机相关文章时,发现可参考的文章寥寥无几。由于Java和PHP的执行原理很相近,我想可以通过学习Java虚拟机来了解ZendVM的工作原理,Java虚拟机非常成熟,可以说是虚拟机的鼻祖,JVM世面上的优秀书籍非常多,JVM打开了我的新世界,让我对虚拟机有了全新的认识,JIT技术更是惊艳到我。
最后,PHP是世界上最好的语言!