当前位置: 首页 > 后端技术 > Node.js

JavaScript深入浅出第4课:V8引擎是如何工作的?

时间:2023-04-03 18:42:55 Node.js

摘要:强大的V8引擎。《JavaScript深入浅出》系列:简单JavaScript第一课:箭头函数里的this是什么鬼?JavaScript深入浅出第二课:函数是一等公民是什么意思?JavaScript深入浅出第三课:什么是垃圾回收算法?JavaScript深入浅出第4课:V8是如何工作的?最近,JavaScript生态系统新增了2个非常硬核的项目。大神FabriceBellard发布了一个新的JS引擎QuickJS,可以将JavaScript源代码转换为C语言代码,然后使用系统编译器(gcc或clang)生成可执行文件。Facebook为ReactNative开发了一个新的JS引擎Hermes来优化Android的性能。它可以在构建APP时将JavaScript源代码编译成Bytecode,从而减少APK大小,减少内存占用,提高APP启动速度。作为JavaScript程序员,只有极少数人有机会和能力去实现一个JS引擎,但是了解JS引擎还是很有必要的。本文将介绍V8引擎的原理,希望能给大家一些帮助。JavaScript引擎当我们写的JavaScript代码直接交给浏览器或者Node执行时,底层CPU是不知道的,无法执行。CPU只知道自己的指令集,指令集对应汇编代码。写汇编代码是一件很痛苦的事情。比如我们要计算N阶乘,只需要一个7行的递归函数:functionfactorial(N){if(N===1){return1;}else{returnN*factorial(N-1);}}代码逻辑也非常清晰,完美契合阶乘的数学定义,即使不会写代码的人也能看懂。但是,如果用汇编语言写N阶乘,就需要300+行代码n阶乘。二进制与二进制之间的转换需要用多个字节来保存大整数,最多可以计算出500左右的N阶乘。还有一点就是不同型号的CPU指令集不一样,也就是说每个CPU都要重写汇编代码,非常崩溃。..幸运的是,JavaScirpt引擎可以将JS代码编译成对应不同CPU(Intel、ARM、MIPS等)的汇编代码,这样我们就不用去阅读每个CPU的指令集手册了。当然,JavaScript引擎的工作不仅仅是编译代码,它还负责执行代码、分配内存和垃圾回收。虽然浏览器很多,但主流的JavaScript引擎其实很少。毕竟,开发一个JavaScript引擎是一件非常复杂的事情。知名的JS引擎有这些:V8(Google)SpiderMonkey(Mozilla)JavaScriptCore(Apple)Chakra(Microsoft)IOT:duktape,JerryScript还有最近发布的QuickJS和Hermes也是JS引擎,它们都超出了浏览器的范畴,阿特伍德定律再次被证明:任何可以用JavaScript编写的应用程序,最终都会用JavaScript编写。V8:强大的JavaScript引擎在为数不多的JavaScript引擎中,V8无疑是最受欢迎的,Chrome和Node.js都在使用V8引擎,Chrome的市场份额高达60%,Node.js是事实上的标准用于JS后端编程。国内很多浏览器其实都是基于Chromium浏览器开发的,而Chromium相当于开源版的Chrome,自然是基于V8引擎的。令人惊奇的是,就连在浏览器界独树一帜的微软也加入了Chromium阵营。另外,Electron基于Node.js和Chromium开发桌面应用,也是基于V8的。V8发动机于2008年发布,其名称的灵感来源于一款超高性能汽车的V8发动机。敢这么起名字,还真是需要点力气。它的性能确实一直在稳步提高。以下是使用Speedometerbenchmark的测试结果:图片来源:https://v8.dev/V8在业界非常成功。同时,也得到了学术界的肯定。它获得了ACMSIGPLAN颁发的ProgrammingLanguagesSoftwareAward:V8的成功在很大程度上归功于它生成的高效机器码。由于JavaScript是一种高度动态的面向对象语言,因此许多专家认为无法达到这种性能水平。V8的性能突破对JavaScript的采用产生了重大影响,JavaScript现在用于浏览器、服务器,并且可能明天用于物联网的小型设备。JavaScript是一种动态类型语言,会给编译器增加很大的难度,所以高手觉得它的性能很难提升,但是V8居然做到了,生成了非常高效的机器码(实际上是汇编代码),这让JS可以应用于Web、APP、桌面、服务器、IOT等各个领域。严格来说,V8生成的代码是汇编代码而不是机器码,但是V8相关的文档、博客等资料都将V8生成的代码称为机器码。汇编代码和机器码是一一对应的,很容易互相转换。这也是反编译的原理,所以他们调用的是V8MachineCode生成的代码,但是并不严谨。V8引擎的内部结构V8是一个非常复杂的工程。使用cloc统计可以看出它有超过100万行的C++代码。V8由许多子模块组成,其中这四个模块是最重要的:Parser:负责将JavaScript源代码转换为抽象语法树(AST)Ignition:interpreter,也就是解释器,负责将AST转换为Bytecode,解释和执行字节码;同时收集TurboFan优化编译需要的信息,比如函数参数的类型;TurboFan:编译器,即编译器利用Ignitio收集的类型信息,将Bytecode转换为优化后的汇编代码;Orinoco:garbagecollector,垃圾回收模块,负责回收程序不再需要的内存空间;其中Parser、Ignition和TurboFan可以将JS源码编译成汇编代码,流程图如下:简单来说,Parser将JS源码转成AST,然后Ignition将AST转成Bytecode,最后TurboFan将Bytecode转成优化的机器代码(实际上是汇编代码)。如果函数没有被调用,V8将不会编译它。如果该函数只被调用一次,Ignition会将其编译成字节码并直接解释。TurboFan不会执行优化编译,因为它需要Ignition在函数执行时收集类型信息。这就要求函数至少需要执行一次,TurboFan才能进行优化编译。如果该函数被多次调用,可能会被识别为热函数,如果Ignition收集到的类型信息证明它可以被优化编译,那么TurboFan会把Bytecode编译成OptimizedMachineCode来提高函数的执行性能代码。图中的红线是反的,确实有点奇怪。优化后的机器码将恢复为字节码。这个过程称为去优化。这是因为Ignition收集的信息可能是错误的。比如add函数的参数,之前是整数,后来变成了字符串。生成的OptimizedMachineCode已经假定add函数的参数是整数,这当然是错误的,所以需要Deoptimization。functionadd(x,y){returnx+y;}add(1,2);add("1","2");C、C++、Java等程序在运行前需要编译,不能直接执行源代码;但是对于JavaScript,我们可以直接执行源代码(例如:nodeserver.js),编译后在运行时执行。这种方式称为即时编译(Just-in-timecompilation),简称JIT。因此,V8也属于JIT编译器。Ignition:解释器Node.js是基于V8引擎实现的,所以node命令为V8引擎提供了很多选项。使用节点的--print-bytecode选项打印出Ignition生成的字节码。factorial.js如下,由于V8不会编译没有被调用的函数,所以需要在最后一行调用factorial函数。函数阶乘(N){如果(N===1){返回1;}else{returnN*factorial(N-1);}}阶乘(10);//V8不会编译没有被调用的函数,因此这一行不能省略node命令的--print-bytecode选项(node版本12.6.0)打印出Ignition生成的Bytecode:node--print-bytecodefactorial.js控制台输出非常多,最后一部分是阶乘函数的字节码:[generatedbytecodeforfunction:factorial]Parametercount2Registercount3Framesize2418E>0x3541c2da112e@0:a5StackCheck28S>0x3541c2da112f@1:0c01@LdaSmi[1]341E>680200TestEqualStricta0,[0]0x3541c2da1134@6:9905JumpIfFalse[5](0x3541c2da1139@11)51S>0x3541c2da113cL:0x3541cL:[1]60S>0x3541c2da1138@10:a9返回82S>0x3541c2da1139@11:1b04LdaImmutableCurrentContextSlot[4]0x3541c2da113b@13:26faStarr10x3541C2DA113D@15:2502LDARA0105E>0x3541C2DA113F@17:410102Subsmi[1],[2]0x3541c2da1142@20:26F9STARR293E>0xRen2:5DF903Callundem[3]91E>0x3541c2da1148@26:360201Mula0,[1]110S>0x3541c2da114b@29:a9ReturnConstantpool(size=0)HandlerTable(size=0)生成的Bytecode其实很简单:使用LdaSmi命令保存整数1到寄存器;使用TestEqualStrict命令比较参数a0和1的大小;如果a0等于1,则JumpIfFalse命令不会跳转,继续执行下一行代码;如果a0不等于1,则JumpIfFalse命令会跳转到内存地址0x3541c2da1139...不难发现,Bytecode在某种程度上是一种汇编语言,但它并不对应于具体的CPU,或者它对应一个虚拟CPU。这样的话,不用针对不同的CPU产生不同的代码,生成Bytecode就简单多了。要知道V8支持9种不同的CPU,引入一个中间层Bytecode可以简化V8的编译过程,提高可扩展性。如果我们在不同的硬件上生成Bytecode,我们会发现生成代码的指令是相同的:图片来源:RossMcIlroyTurboFan:编译器使用了node命令的--print-code和--print-opt-code选项打印出TurboFan生成的汇编代码:node--print-code--print-opt-codefactorial.js我在Mac上运行,结果如下图:对比Bytecode,真正的汇编代码可读性差得多。而且机器的CPU类型不同,生成的汇编代码也不同。不用担心这些汇编代码,因为最重要的是了解TurboFan如何优化生成的汇编代码。我们可以通过add函数梳理整个优化过程。functionadd(x,y){returnx+y;}add(1,2);add(3,4);add(5,6);add("7","8");由于JS变量没有类型,所以add函数的参数可以是任意类型:Number、String、Boolean等,也就是说add函数可能是数字的相加(V8也区分整数和浮点数)或字符串拼接。其他更复杂的操作也是可能的。如果直接编译,生成的代码会有很多if...else分支,伪代码如下:if(isInteger(x)&&isInteger(y)){//添加整数}elseif(isFloat(x)&&isFloat(y)){//浮点数相加}elseif(isString(x)&&isString(y)){//字符串拼接}else{//其他各种情况}我只写了4个分支,其实还有是更多的分支机构。例如,当参数类型不一致时,必须进行类型转换。您可能希望看一下ECMASCript是如何定义加法的:12.8.3加法运算符(+)。如果直接根据伪代码生成汇编代码,生成的代码一定很冗长,会占用大量的内存空间。Ignition在执行add(1,2)时,已经知道add函数的两个参数都是整数,所以TurboFan在编译Bytecode的时候,可以假设add函数的参数都是整数,这样可以大大简化生成的程序集代码。伪代码如下:if(isInteger(x)&&isInteger(y)){//整数加法}else{//反优化}当然,这也是有风险的,因为如果add函数参数不是整数,那么生成的汇编代码也不能执行,只能对Bytecode执行Deoptimize。也就是说,如果TurboFan编译优化了add函数,那么add(3,4)和add(3,4)可以执行优化后的汇编代码,而add("7","8")只能DeoptimizeasBytecode执行。当然,TurboFan所做的不仅仅是基于类型信息来简化代码执行过程,还可以进行其他的优化,比如减少冗余代码等更复杂的东西。从这个简单的例子我们可以看出,如果我们的JS代码中变量的类型发生变化,会给V8引擎增加很多麻烦。为了提高性能,我们可以尽量不改变变量的类型。对于性能要求比较高的项目,使用TypeScript也是一个不错的选择。理论上,如果严格遵守类型化编程方法,同样可以提高性能。类型化代码有利于V8引擎编译出汇编代码。当然还需要这个测试数据来证明。Orinoco:垃圾回收强大的垃圾回收功能是V8性能提升的关键之一,因为它可以回收内存空间,提高内存利用效率,同时避免影响JS代码的执行。关于垃圾回收,学过JavaScript第三课:什么是垃圾回收算法?里面有详细介绍,这里不再赘述。JS引擎未来的V8引擎确实很强大,但也不是万能的。简单分析一下就能找到一些可以优化的点。我有一个新的想法,还没有定好名字,不妨叫它OptimizedTypeScriptEngine:用TypeScript来编程,遵循严格的类型化编程规则,不要写成AnyScript;构建时直接将TypeScript编译成Bytecode而不是生成JS文件,省去了运行时Parse和生成Bytecode的过程;运行时需要将Bytecode编译成CPU对应的汇编代码;由于采用类型化的编程方式,有利于编译器优化生成的汇编代码,省去了很多额外的操作;这个想法其实是可以基于V8引擎来实现的,技术上应该是可行的:将Parser和Ignition拆分为构建阶段;删除TurboFan处理JS动态特性的相关代码;这样一来,JS引擎就可以简化很多。一方面,不再需要解析和生成字节码,另一方面,由于JavaScript的动态特性,编译器不再需要做很多额外的工作。因此,可以减少CPU、内存和电源的使用,并优化性能。唯一的问题可能是必须使用严格的TS语法进行编程。你为什么要这样做?因为对于IOT硬件来说,需要节省CPU、内存、电量。不是每款智能家电都需要搭载骁龙855,想要将JS应用到IOT领域,必须从JS引擎的角度来看。做优化,光做上层框架是没用的。事实上,Facebook的Hermes几乎可以做到这一点,但它不需要TS编程。这应该是JS引擎的未来,你会看到越来越多的这种趋势。关于JS,我打算用一年的时间写一个系列的博客《JavaScript深入浅出》,你还有什么不明白的?不妨留言,我可以研究一下,然后分享给大家。欢迎加我个人微信(KiwenLau),我是Fundebug的技术总监,一个对JS又爱又恨的程序员。请参阅庆祝V8LaunchingIgnition和TurboFanJavaScript引擎10周年——它们如何?V8的SpeculativeOptimization简介看懂V8的Bytecode2018,JavaScript经历了什么?JavaScript深入浅出第三课:什么是垃圾回收算法?FabriceBellard是什么级别的程序员?如何评价FabriceBellard发布的QuickJSJS引擎?关于FundebugFundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、ReactNative、Node.js和Java在线应用的实时BUG监控。自2016年双十一正式上线以来,Fundebug累计处理了10亿+错误事件。付费客户包括阳光保险、核桃编程、荔枝FM、掌门1对1、微麦、青团社等众多品牌企业。欢迎大家免费试用!转载版权声明请注明作者Fundebug及本文地址:https://blog.fundebug.com/2019/07/16/how-does-v8-work/