Nodejs开发进阶4-V8

本来的规划中,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有所助益。

相关推荐
小墨宝25 分钟前
js 生成pdf 并上传文件
前端·javascript·pdf
丘山子1 小时前
一些鲜为人知的 IP 地址怪异写法
前端·后端·tcp/ip
CopyLower1 小时前
在 Spring Boot 中实现 WebSockets
spring boot·后端·iphone
www_pp_1 小时前
# 构建词汇表:自然语言处理中的关键步骤
前端·javascript·自然语言处理·easyui
YuShiYue1 小时前
pnpm monoreop 打包时 node_modules 内部包 typescript 不能推导出类型报错
javascript·vue.js·typescript·pnpm
天天扭码2 小时前
总所周知,JavaScript中有很多函数定义方式,如何“因地制宜”?(ˉ﹃ˉ)
前端·javascript·面试
一个专注写代码的程序媛2 小时前
为什么vue的key值,不用index?
前端·javascript·vue.js
장숙혜2 小时前
ElementUi的Dropdown下拉菜单的详细介绍及使用
前端·javascript·vue.js
.生产的驴2 小时前
SpringBoot 封装统一API返回格式对象 标准化开发 请求封装 统一格式处理
java·数据库·spring boot·后端·spring·eclipse·maven
火柴盒zhang2 小时前
websheet之 编辑器
开发语言·前端·javascript·编辑器·spreadsheet·websheet