本来的规划中,JavaScript和V8是在一起阐述的,但没想到内容越来越多,对作者和读者都不太友好,所以干脆划分为两个章节。本章节就先来讨论一下nodejs中,一个重要的组成模块,js的执行引擎:V8。
关于V8
V8有自己独立的官方网站: v8.dev/ , 对其有如此描述:
What is V8? V8 is Google's open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others. It implements ECMAScript and WebAssembly, and runs on Windows 7 or later, macOS 10.12+, and Linux systems that use x64, IA-32, ARM, or MIPS processors. V8 can run standalone, or can be embedded into any C++ application.
什么是V8? V8是谷歌开发的开源高性能JavaScript和WebAssembly引擎,使用C++编写,用于Chrome、Nodejs等多个软件和产品中。V8实现了ECMAScrip和WebAssembly规范,可以运行在Windows7和更新的Windows版本,Mac OS 10.12+,使用x64、IA-32、ARM和MIPS处理器的Linux等多种操作系统平台上。V8可以独立运行,也可以被嵌入到任何C++程序当中。
V8产品名称,取意于V型8缸发动机。图中这款Hennessey Venom F5超级跑车,搭载的6.6升双涡轮增压V8汽油发动机,最大功率1817马力,最大扭矩165公斤米,驱动F5极速可达483公里/小时。V8的设计除了能够提供更大的排量和功率之外,V型的气缸排量,可以使发动机结构更加紧凑,另外气缸越多,引擎的运作更加平稳。美国人痴迷于这种大排量大马力引擎,软件产品取名于此,凸显谷歌对其软件产品的高效紧凑、性能强大和稳定可靠的自信。
V8软件的开发已经日渐成熟,大版本发布的间隔也越来越长。按照其网站的说法,其最新版本(本文成文时)应为2022年1月31日发布的V9.9版。
工作原理
我们在Nodejs系统架构章节中,已经简单的提到了V8作为nodejs技术的基础和核心,在nodejs技术体系中的地位和作用。但其实并没有深入的探讨V8本身的技术原理和机制。本文就试图在这方面稍微深入和展开一下,特别是从JS语言的支持和优化方面,让读者有更立体和理性的理解,这将有助于编写更高效的代码和应用。
我们已经知道,V8是Google开发的高性能JS执行引擎。JS本身是一种解释性或者脚本化的语言,它的运行依赖于去执行的环境就是所谓的解释器程序。通常认为由于运行机制的限制,相比编译型程序而言,解释执行多了一个代码和语法解释的过程,而且基本不能像编译型程序一样进行预处理和直接机器码执行,所以性能和编译型程序是无法相比的。但V8却在这方面进行了有益的尝试和创新,使作为解释性语言的JavaScript能够以接近编译型程序的性能和效率进行执行。
在nodejs环境中,V8执行JS源代码的一般过程如图所示:
我们先看前面,nodejs要执行一个js程序,它会从js文件中加载js代码,然后交给V8引擎来进行执行。执行引擎会先对js代码进行清理和解析,并生成一个AST(Abstract Syntax Tree,抽象语法树)。这时才真正的进入代码执行的阶段。这几个步骤,基本上所有的JS执行引擎都是这么工作的。对用户而言感觉也没什么差别(图为抽象语法树)。
后面的部分就是V8独特的部分了,起码在其发明的那个时间点上,堪称耳目一新。就是它将后面的解释执行分成了两个阶段。首先是用一个程序启动模块(点火器,Ignition)结合JIT编译技术将代码编译成为字节码,就可以在V8提供的自字节码虚拟机中执行了(有点像Java);然后在随后的执行阶段,使用一个程序优化模块(涡轮, Turbofan),在运行中对运行代码进行分析和优化,并且进一步编译成为机器,可以在操作系统平台上直接执行,从而达到最高的执行效率。
这样,我们就可以看到,V8的价值在于,和一般和传统的简单脚本解释器不同,它提出并实现了一种动态持续优化的执行架构。我们这里简单的例举它采用的一些技术,但由于篇幅和技术水平限制,不做深入探讨。主要目的是从概念上了解V8的技术特点,和编写JS代码时,注意和这些特点进行结合,并且避免一些冲突和低效的做法。
- JIT (Just In Time)即时编译器
人们过去对解释性语言运行效率低下的印象,主要来源于其工作原理。传统的解释器,在运行的时候,会读取整个程序并进行解释后执行,这个过程中会有一些代码解释的性能损失。而V8的执行是编译后执行,即它会先将源代码编译成为字节码和机器码,实际执行的是更高效的机器码。为了减少程序加载时编译时间,JIT也不是完全的编译后执行,而是按照执行和调用的次序,动态并且交替的编译执行程序片段,这样可以大大提高程序初始执行的响应速度。
- 动态优化
V8的开发者敏锐的认识到,要提供高质量高性能的Web应用,就不能简单的把JS程序看成一次性使用的临时性代码,而是稳定、高效、健壮的程序系统。因此,他们在JIT的基础上,提出了运行时分析和优化。
首先,JIT的机制,保证了只有真正执行过的代码部分才会被编译并且,整体上节省了执行时间(按需编译)。然后,V8会在程序运行过程中收集其运行的信息和状态,监控并发现代码的热点区域或者函数(热点检测),并进行有针对性的编译优化。最后,这一优化过程还能够用于构建编译链条,后编译的代码可以利用之前代码信息进行进一步优化。在最高的优化级别之上,代码会被最终编译成为机器码,从而获得接近原生代码的执行效率。
有趣的是,虽然V8的这些优化机制是为浏览器环境设计的,但在移植到后端,开发形成nodejs的时候,意外的得到了更好的效果。因为后端程序的设计目标都是长期稳定执行的,JIT和动态优化正好能够更好的发挥作用。
- 垃圾回收(Garage Collection, GC)
高效的自动化内存管理机制,是任何一个现代化语言和程序平台的核心特性。V8在这个方面做了很多有益的努力和改进,其根本目标是减少垃圾回收操作对主程序的影响和运行停顿,达到长时间稳定工作的目的。
V8将内存分配分为新生代和老生代,并有针对性的采用不同的垃圾回收算法。新生代使用高效的标记-清除算法,老生代则使用标记-整理算法来减少内存碎片,并采用渐增标记方式,分多次完成标记以减少停顿的影响。图为V8对象内存划分和原理。
在执行机制方面,V8的编译器会考虑垃圾回收需要进行代码优化,比如函数内联和逃逸分析。V8还实现了写屏障(Write Barrier),可以有效跟踪并维护垃圾回收需要的内存可达性信息,提升回收操作执行效率。V8还会充分利用空闲内存页来存储GC数据,减少Heap内存占用。
为了减少垃圾回收操作的影响,V8还能够使用多线程并发来减少对主程序的干扰,并会在JS执行间隙利用空闲时间进行增量垃圾回收,显著减少单次回收操作的停顿时间。
- 隐藏类(Hidden Class)
从开发的角度而言,JS是非常灵活而容易使用的,比如JS的对象是动态的,可以添加或者删除属性。但另一方面,这一特性也会容易被开发者滥用,写出不当的代码。为了避免或者减轻这个问题,V8使用隐藏类来对JS代码的执行进行透明的优化。
具体而言,为了代码优化的目的,V8会在底层为每个对象维护一个对应的隐藏类(Hidden Class)。它能够以比较优化的方式,来记录相关属性的信息,如名称、类型、偏移等。当需要访问属性时,V8可以跳过普通的对象访问模式,基于隐藏类,通过偏移而非一般寻址的方式,加快访问对象属性。只要对象的结构定义没有变化,这部分的内容就可以重复使用并且进行优化,提升程序执行性能。
- 内联缓存(Inline Caching)
几乎在所有的计算机系统和应用中,缓存都是提升性能的一种非常有效的方式。V8使用内联缓存技术,来改善对于对象属性和函数的查找和访问速度。作为动态编译和优化的一部分,在代码中的对象属性和函数读取点,V8会生成一个快速查找的缓存信息,包括了对象属性的值或函数的内存地址。下次访问时,可以直接使用这个查找结果。而如果缓存未命中,则会重新查找并更新缓存。笔者理解,这里"内联"的意思是在编译和优化级别解决问题,对于代码而言是透明的。
- CPU 优化
JS作为一种高级语言,确实应该是和系统底层硬件无关的。但在执行管理层面,因为V8是使用相对底层的语言和技术开发的,就可以根据硬件平台的特性,来进行有针对性的实现和优化。
例如,V8中广泛使用SIMD来优化执行。SIMD(Single Instruction Multiple Data)即单指令多数据,是一种并行计算模型。它的基本思想是,大量模式相同的数据处理场景,可以使用一个指令同时处理多个数据,可以极大提高计算效率(非常像GPU的工作模式,但是在CPU层面,更加通用)。当然,这种处理可能需要现代CPU和指令集的支持。SIMD的应用范围是非常广泛的,包括浮点数运算、数组/矩阵运算、字符串、正则表达式、图形处理、视频编解码、密码学、WebAssembly等等。
所以,笔者认为,更好的利用现代CPU技术架构和特性,如特定的加速指令,指令并行和向量化,优化内存分配和管理,多核优化等等,js代码的执行的优化措施应该从底层起始,并且由水平更高的开发者来实现。这样对于javascript整体应用的好处,比应用和代码层面的效能提升,可能更加有效。
退优化(Deoptimize)
前面我们讨论了V8的主要工作原理。其实V8中的动态优化,也是在一个比较长的时间内不断调整和演进中逐步发展的。其中,一个比较重要的问题就是如何处理"退优化"。
先来解释一下,何为"退优化"或者取消优化。当V8在运行JS代码时,它会使用很多方式来对代码进行优化,但在某些情况下,却不得不将这些代码和优化措施废弃,重新回到编译前或者优化前的版本,才能保证代码的正确执行,这个过程就是退优化。导致退优化的原因可能很多,包括隐藏类变化、类型和分型反馈失效、监测点失效、内联缓存失效、优化限制等等,都有可能。一般情况下,如果处理不好,退优化会导致性能突然下降,所以也是执行优化的一个很重要的部分。
V8在处理这个问题的过程中,也是经历了一些反复,我们来简单的看一看这个过程。在2014年,V8的编译和优化流程如下:
那时候,还有一个叫做Crankshaft(曲轴)的组件,可以理解成它会根据情况,采取不同的代码优化方式。主要问题在于当退优化发生时,它会退回非编译和优化的状态,并且会比较大的影响执行性能。
之后还有一些改进,到了2017,逐步演进到现在这个流程和状态,如下图所示:
主要改进就是简化流程,引入了Ignition作为解释器,配合字节码和TurboFan,来编译机器码。当退优化发生时,退回到字节码,也能保证一定的性能水平。从而保证系统的总体性能也是比较好的。
从这个过程,我们也可以更好的理解,保证V8执行效率的核心,就是避免不必要的退优化,这就需要开发者在一定程度上理解V8原理的基础上,编写符合语言规范且稳定的代码。
代码编写要点
关于应用程序的代码编写,其实js和nodejs对于开发者的限制是很少的,因为很多问题,V8和nodejs都可以进行处理和优化。这样无疑能够大大降低应用程序开发的门槛,并且提高开发效率。
即便如此,我们作为开发者,编写代码的时候,如果在一定程度上知晓其底层工作原理,可能会编写对于"执行"和"优化"更加友好的代码。而且,在很大程度上,这些原则和实践规范,是广泛有效并且通用的。下面是一些相关常见的场景和建议:
- 注意重用变量和变量类型
我们已经知道,V8会基于变量的结构,使用隐藏类或者内联缓存对其访问进行优化,所以,应当避免同一个变量,在使用中进行重复赋值,并赋予不同的结构。特别是js这种弱类型的解释性语言,使用的时候非常方便和随意,这些问题基本无法在编译阶段发现,需要特别注意。尽量不要在运行过程中转换变量类型。
类型转换还有可能是隐式的,但这个过程通常不利于V8的代码优化,所以一般要予以避免。比如优先使用"==="而非"=="来进行比较(==本质是逻辑值相等,会在比较前进行类型转换),就可以给V8更大的优化依据和信息。
- 小心使用Delete
理由同上,delete方法,可能会改变变量的结构,不利于优化。如果遇到需要大量操作删除对象属性,可以考虑将其赋为空置,让V8来进行处理和优化。
- 初始化次序
使用相同的次序,来初始化对象,可以使用相同的隐藏类,有利于代码执行优化。
- 即时回收
作为一个良好的编程习惯,如果确认某个占用内存较大的对象或者变量(特别是大数组)已经使用完毕,就应该考虑即时释放其所占用的内容,一般使用=null,可以将其设置为可回收,后续由V8的垃圾回收机制进行即时的回收,即时释放其所占用的资源。
- 避免频繁创建对象和函数
基于V8的工作原理,对象和函数创建时,V8可能会生成一些用于优化的额外信息。所以要避免重复的创建对象和函数。一个典型的场景就是递归虽然可以简化代码编写,但由于堆栈结构和函数引用的使用,如果规模很大,是不利于优化的。
- 注意闭包使用
闭包会持有外部对象的引用,会对增大内存开销并且影响垃圾回收机制的有效性,使用时要特别注意不要过度使用。
- 控制代码块规模
过大的代码块会影响编译和优化的效率,应合理划分程序结构,避免过大的单一程序单元。
- 关注调试优化状态
如果特别关注应用的执行性能,可以在调试的时候特别关注V8在运行中的编译状态,定位问题和瓶颈,以利于做出有针对性的优化。
nodejs的V8模块
V8是js的执行引擎,有时我们在nodejs中也希望有一些系统级别的信息和管理功能,这一点可以通过nodejs中的v8模块来实现。这些信息和功能,主要用于获取更底层的执行状态和相关的统计信息,来帮助开发者了解和掌握程序的运行状态,并作为程序优化的依据和基础。
例如,我们可以使用getHeapStatistics函数,获取当前程序执行的堆统计信息:
REPL
> v8.getHeapStatistics()
{
total_heap_size: 8474624,
total_heap_size_executable: 786432,
total_physical_size: 8474624,
total_available_size: 4338908368,
used_heap_size: 7229424,
heap_size_limit: 4345298944,
malloced_memory: 139312,
peak_malloced_memory: 808648,
does_zap_garbage: 0,
number_of_native_contexts: 1,
number_of_detached_contexts: 0,
total_global_handles_size: 8192,
used_global_handles_size: 5664,
external_memory: 1032916
}
作为进一步的程序执行状态,还可以使用v8.getHeapSnapshot()获取堆空间的内存快照。当然作为一般的开发者,我们不会关心如此底层的技术信息。一般只需要了解自己程序的运行状态,并发现潜在的内存占用过大或者泄露的问题而已。
根据官方技术文档,从nodejs v8模块中,可以获得的信息和功能包括:
- 堆代码统计信息
- 堆空间统计信息
- 堆统计信息
- 堆内存快照
- 性能信息采集profile
V8内部模块
V8内部也是模块化的,这里简单了解一下其组成,但由于过于底层且和JS运行关联不大,这里不做深入探讨。
(图为V8产品logo,很形象啊)。
- Ignition(点火器)
V8的代码解释模块,应该是取意于发动机中的火花塞(图)。
- TurboFan(涡轮风扇)
V8的优化编译器,应该是取意于增压发动机中的涡轮风扇。
- Liftoff(发射器)
V8不仅仅是js的执行引擎,也是WebAssembly的执行引擎。所以它还包含WebAssembly的基线编译器,名为LiftOff。意为火箭发射?!
- Orinoco
V8的垃圾回收,也是一个相对独立的功能模块,名为Orinoco(美国的一条河的名字),可能是来自一个已有的外部模块,所以和发动机技术没有直接的关系。
竞争者们
作为一个解释性脚本语言,JS语言是公开的标准。所以名义上任何人都可以开发一个解释器,来执行JS代码。而JS最重要的应用场景,就是在浏览器中运行,来支撑真正的Web应用而不是简单的展示Web页面。再考虑到JS已经是事实上的标准浏览器脚本程序语言标准,JS引擎,也自然成为各个浏览器技术的最重要的核心技术。
V8是谷歌浏览器Chrome(也包括其开源衍生产品Chromium等等)的核心。类似的,如果任何一个厂商试图在浏览器技术上和谷歌开展竞争,他就必须首先和其在JS执行引擎上来开展竞争。虽然,V8也是开源的,但如果你开发的浏览器也使用V8,
所以,早期的主流浏览器软件产品,都具有自己的JS引擎。另一个浏览器的重要组件,是HTML布局和渲染引擎。下图就列举了这些浏览器所使用的这两个核心技术和差别:
在Chrome刚刚发布的时候,V8的表现是非常优异的。通常认为它的性能是同时代产品的2~5倍。所以很快Chrome就成为主流先进浏览器的标杆产品。现在的js引擎格局,大致是Chrome、Opera、Edge都使用V8引擎,Firefox还是使用其原有的SpiderMonkey,Safari使用JavascriptCore技术(已经非图中早期的Nitro,并且有Mac OS系统加成),原来的IE和Edge使用的Chakra已经被基本淘汰。
幸运的是,浏览器和JS引擎技术已经发展的比较成熟,对于用户和开发者而言,这些不同的js引擎,运行一般的js代码的在功能上和兼容性差异是很小的,也就是说,开发者基本上不需要考虑自己的代码,在不同浏览器和执行引擎间执行的兼容性问题,主要参考的是js语言的版本和支持程度。
小结
本文相对较深入的探讨了nodejs的技术基础V8,从基本组成、工作原理、执行过程、主要核心技术特性、代码优化和编写注意事项等。希望对读者理解V8和JS技术,并进一步理解Nodejs有所助益。