跳到主要内容

js 引擎原理

解释性语言和编译型语言的区别

解释性语言和编译型语言是两种不同的编程语言类型,它们在代码的执行方式和运行过程中的一些特点上存在区别。

  1. 编译型语言:
  • 编译型语言的代码在运行之前需要经过编译器的处理,将源代码一次性地转换成机器语言的可执行文件(通常是二进制文件)。
  • 编译器将源代码转换为目标代码的过程包括词法分析、语法分析、语义分析、优化和代码生成等步骤。
  • 在运行时,编译型语言的可执行文件直接在计算机上执行,无需再次进行翻译或解释。
  • 典型的编译型语言包括 C、C++、Java(虚拟机字节码编译)、Go等。
  1. 解释性语言:
  • 解释性语言的代码在运行时逐行被解释器解释执行,无需预先编译为可执行文件。
  • 解释器逐行读取源代码,将其解析并直接执行,将源代码翻译为机器指令并逐行执行。
  • 解释性语言的执行过程通常边解释边执行,每次运行都需要经过解释器的处理。
  • 典型的解释性语言包括 JavaScript、Python、Ruby、PHP等。

区别:

  • 编译型语言在运行之前需要将代码转换为可执行文件,而解释性语言在运行时逐行解释执行。
  • 编译型语言的执行速度通常较快,因为代码已经被预先编译成机器语言,无需再进行解释。解释性语言的执行速度较慢,因为每次运行都需要经过解释器的处理。
  • 编译型语言一般需要根据目标平台进行编译,可执行文件通常与特定的操作系统和硬件相关。解释性语言通常是跨平台的,只需要相应的解释器即可运行。
  • 编译型语言在代码运行之前会进行全面的语法和类型检查,可以在编译过程中发现一些潜在的错误。解释性语言在运行时进行解释,错误可能会在运行过程中被发现。

需要注意的是,实际上很多语言不是严格的编译型语言或解释性语言,而是在两者之间存在折中的方式。例如,Java 语言将源代码编译为字节码(中间形式),然后在虚拟机中解释执行。因此,这些概念并不是绝对的,语言的执行方式可能有所不同。

v8

染引擎与网页渲染

编程语言分为 编译型语言和解释型语 言两类。 编译型语言在执行之前要先进行完全编译,而 解释型语言一边编译一边执行, 很明显解释型语言的执行速度是慢于编译型语言的,而JavaScript就是一种解释型脚本语言, 支持动态类型、弱类型、基于原型的语言,内置支持类型。

渲染引擎

就是将HTML/CSS/JavaScript等文本或图片等信息转换成浏览器上可见的可视化图像结果的转换程序。 WebKit,一个由苹果发起的一个开源项目,如今它在移动端占据着垄断地位,更有基于WebKit的web操作系统不断涌现(如:Chrome OS、Web OS)。

WebKit内部结构大体如下 01

上图中实线框内模块是所有移植的共有部分,虚线框内不同的厂商可以自己实现。由上图可知,WebKit主要有操作系统、WebCore 、WebKit嵌入式接口和第三方库组成。

操作系统: 是管理和控制计算机硬件与软件资源的计算机程序。 WebCore: JavaScriptCore是WebKit的默认引擎,在谷歌系列产品中被替换为V8引擎。 WebKit嵌入式接口: 该接口主要供浏览器调用,与移植密切相关,不同的移植有不同的接口规范。 第三方库: 主要是诸如图形库、网络库、视频库、数据存储库等第三方库。

网页渲染流程简析

首先,系统将网页输入到HTML解析器,HTML解析器解析,然后构建DOM树,在这期间如果遇到JavaScript代码则交给JavaScript引擎处理; 如果遇到CSS样式信息,则构建一个内部绘图模型。该模型由布局模块计算模型内部各个元素的位置和大小信息,最后由绘图模块完成从该模型到图像的绘制。

对于网页的绘制过程,大体可以分为3个阶段:

1、从输入URL到生成DOM树 在这个阶段中,主要会经历一下几个步骤: 地址栏输入URL,WebKit调用资源加载器加载相应资源; 加载器依赖网络模块建立连接,发送请求并接收答复; WebKit接收各种网页或者资源数据,其中某些资源可能同步或异步获取; 网页交给HTML解析器转变为词语; 解释器根据词语构建节点,形成DOM树; 如果节点是JavaScript代码,调用JavaScript引擎解释并执行; JavaScript代码可能会修改DOM树结构; 如果节点依赖其他资源,如图片、视频等,调用资源加载器加载它们,但这些是异步加载的,不会阻碍当前DOM树继续创建;如果是JavaScript资源URL(没有标记异步方式),则需要停止当前DOM树创建,直到JavaScript加载并被JavaScript引擎执行后才继续DOM树的创建。

2、从DOM树到构建WebKit绘图上下文 CSS文件被CSS解释器解释成内部表示; CSS解释器完成工作后,在DOM树上附加样式信息,生成RenderObject树; RenderObject节点在创建的同时,WebKit会根据网页层次结构构建RenderLayer树,同时构建一个虚拟绘图上下文。

3、绘图上下文内容并呈现图像内容 绘图上下文是一个与平台无关的抽象类,它将每个绘图操作桥接到不同的具体实现类,也就是绘图具体实现类; 绘图实现类也可能有简单的实现,也可能有复杂的实现,软件渲染、硬件渲染、合成渲染等; 绘图实现类将2D图形库或者3D图形库绘制结果保存,交给浏览器界面进行展示。

  • books/专题知识库/05、基础知识点专题/02_01、进阶知识部分1-10.md#no01-%E6%B8%B2%%9F%93%E6%9C%BA%E5%88%B6

avaScript引擎

JavaScript这种解释性语言来讲,如何提高解析速度就是当务之急。JavaScript引擎和渲染引擎的关系如下图所示 02

为了提高性能,JavaScript引入了Java虚拟机和C++编译器中的众多技术。 而一个完整JavaScript引擎的执行过程大致流程如下:源代码-→抽象语法树-→字节码-→JIT-→本地代码。一个典型的抽象语法树如下图所示:

题外话 关于 JIT: JIT 编译 (JIT compilation),运行时需要代码时。 JIT具体的做法是这样的:当载入一个类型时,CLR为该类型创建一个内部数据结构和相应的函数,当函数第一被调用时,JIT将该函数编译成机器语言.当再次遇到该函数时则直接从cache中执行已编译好的机器语言.

为了节约将抽象语法树通过JIT技术转换成本地代码的时间,V8放弃了生成字节码阶段的性能优化。而通过Profiler采集一些信息,来优化本地代码。 在2017年4月底,v8 发布了5.9 版本,在此版本中新增了一个 Ignition 字节码解释器,并默认开启。 做出这一改变的原因为:(主要动机)减轻机器码占用的内存空间,即牺牲时间换空间; 提高代码的启动速度;对 v8 的代码进行重构,降低 v8 的代码复杂度(详细介绍请查阅:JS 引擎与字节码的不解之缘

8引擎

前面,我们介绍了V8引擎的一些历史,下面我们重点来看看V8项目一些知识。首先,V8项目的结构如下: 03

数据解析

JavaScript作为一种无类型的语言,在编译时并不能准确知道变量的类型,只可以在运行时确定。因而JavaScript运行效率比C++或Java低。 而对于JavaScript 来说,并不能像C++那样在执行时已经知道变量的类型和地址,所以在代码解析过程中,会产生很多的临时变量,而变量的存取是非常普遍和频繁的。 在JavaScript中,除boolean,number,string,null,undefined这个五个简单变量外,其他的数据都是对象,V8使用一种特殊的方式来表示它们,进而优化JavaScript的内部表示问题。

JavaScript对象在V8中的实现包含三个部分:隐藏类指针,这是v8为JavaScript对象创建的隐藏类;属性值表指针,指向该对象包含的属性值;元素表指针,指向该对象包含的属性。

在V8中,数据的内部表示由数据的实际内容和数据的句柄构成。数据的实际内容是变长的,类型也是不同的;句柄固定大小,包含指向数据的指针。 这种设计可以方便V8进行垃圾回收和移动数据内容,如果直接使用指针的话就会出问题或者需要更大的开销, 使用句柄的话,只需修改句柄中的指针即可,使用者使用的还是句柄,指针改动是对使用者透明的。

除少数数据(如整型数据)由handle本身存储外,其他内容限于句柄大小和变长等原因,都存储在堆中。 整数直接从value中取值,然后使用一个指针指向它,可以减少内存的占用并提高访问速度。 一个句柄对象的大小是4字节(32位设备)或者8字节(64位设备),而在JavaScriptCore中,使用的8个字节表示句柄。 在堆中存放的对象都是4字节对齐的,所以它们指针的后两位是不需要的,V8用这两位表示数据的类型,00为整数,01为其他。

V8引擎渲染过程

V8引擎在执行JavaScript的过程中,主要有两个阶段:编译和运行。 在V8引擎中,源代码先被解析器转变为抽象语法树(AST),然后使用JIT编译器的全代码生成器从AST直接生成本地可执行代码。 但由于缺少了转换为字节码这一中间过程,也就减少了优化代码的机会。

V8引擎编译本地代码时使用的主要类如下所示: Script:表示JavaScript代码,即包含源代码,又包含编译之后生成的本地代码,即是编译入口,又是运行入口; Compiler:编译器类,辅组Script类来编译生成代码,调用解释器(Parser)来生成AST和全代码生成器,将AST转变为本地代码; AstNode:抽象语法树节点类,是其他所有节点的基类,包含非常多的子类,后面会针对不同的子类生成不同的本地代码; FullCodeGenerator:AstVisitor类的子类,通过遍历AST来为JavaScript生成本地可执行代码。

JavaScript代码编译过程

Script类调用Compiler类的Compile函数为其生成本地代码; Compile函数先使用Parser类生成AST,再使用FullCodeGenerator类来生成本地代码; 本地代码与具体的硬件平台密切相关,FullCodeGenerator使用多个后端来生成与平台相匹配的本地汇编代码。

大体的流程图如下所示: 04

在执行编译之前,V8会构建众多全局对象并加载一些内置的库(如math库),来构建一个运行环境。 但是,在JavaScript源代码中,并非所有的函数都被编译生成本地代码,而是采用在调用时才会编译的逻辑来动态编译。

由于V8缺少了生成中间字节码这一环节,为了提升性能,V8会在生成本地代码后,使用数据分析器(profiler)采集一些信息, 然后根据这些数据将本地代码进行优化,生成更高效的本地代码,这是一个逐步改进的过程。 当发现优化后代码的性能还不如未优化的代码,V8将退回原来的代码,也就是优化回滚。

在这一阶段涉及的类主要有: Script:表示JavaScript代码,即包含源代码,又包含编译之后生成的本地代码,即是编译入口,又是运行入口; Execution:运行代码的辅组类,包含一些重要函数,如Call函数,它辅组进入和执行Script代码; JSFunction:需要执行的JavaScript函数表示类; Runtime:运行这些本地代码的辅组类,主要提供运行时所需的辅组函数,如:属性访问、类型转换、编译、算术、位操作、比较、正则表达式等; Heap:运行本地代码需要使用的内存堆类; MarkCompactCollector:垃圾回收机制的主要实现类,用来标记、清除和整理等基本的垃圾回收过程; SweeperThread:负责垃圾回收的线程。

在V8中,函数是一个基本单位,当某个JavaScript函数被调用时,V8会查找该函数是否已经生成本地代码,如果已经生成,则直接调用该函数。 否则,V8引擎会生成属于该函数的本地代码。 这样,对于那些不用的代码就可以减少执行时间。再次借助Runtime类中的辅组函数,将不用的空间进行标记清除和垃圾回收。

优化回滚

因为V8是基于AST直接生成本地代码,没有经过中间表示层的优化,所以本地代码尚未经过很好的优化。 于是,在2010年,V8引入了新的编译器-Crankshaft,它主要针对热点函数进行优化, 基于JavaScript源代码开始分析而非本地代码,同时构建Hydroger图并基于此来进行优化分析。

Crankshaft编译器为了性能考虑,通常会做出比较乐观和大胆的预测—代码稳定且变量类型不变,所以可以生成高效的本地代码。 但是,鉴于JavaScript的一个弱类型的语言,变量类型也可能在执行的过程中进行改变,鉴于这种情况,V8会将该编译器做的想当然的优化进行回滚,称为优化回滚。

例如,下面的示例:

let counter = 0
function test (x, y) {
counter++
if (counter < 1000000) {
// do something
return 'jeri'
}
const unknown = new Date()
console.log(unknown)
}

该函数被调用多次之后,V8引擎可能会触发Crankshaft编译器对其进行优化,而优化代码认为示例代码的类型信息都已经被确定。 当程序执行到new Date()这个地方,并未获取unknown这个变量的类型,V8只得将该部分代码进行回滚。

优化回滚是一个很耗时的操作,在写代码过程中,尽量不要触发优化该操作。在最近发布的 V8 5.9 版本中,新增了一个 Ignition 字节码解释器, TurboFan 和 Ignition 结合起来共同完成JavaScript的编译。 这个版本中消除 Cranshaft 这个旧的编译器,并让新的 Turbofan 直接从字节码来优化代码, 并当需要进行反优化的时候直接反优化到字节码,而不需要再考虑 JS 源代码。

内存管理

Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4 GB,32位系统下约为0.7 GB), 其深层原因是 V8 垃圾回收机制的限制所致(如果可使用内存太大,V8在进行垃圾回收时需耗费更多的资源和时间,严重影响JS的执行效率)。下面对内存管理进行介绍。

内存的管理组要由分配和回收两个部分构成。V8的内存划分如下: Zone:管理小块内存。其先自己申请一块内存,然后管理和分配一些小内存,当一块小内存被分配之后,不能被Zone回收, 只能一次性回收Zone分配的所有小内存。当一个过程需要很多内存,Zone将需要分配大量的内存,却又不能及时回收,会导致内存不足情况。 :管理JavaScript使用的数据、生成的代码、哈希表等。为方便实现垃圾回收,堆被分为三个部分(这和Java等的堆不一样): 年轻分代:为新创建的对象分配内存空间,经常需要进行垃圾回收。 为方便年轻分代中的内容回收,可再将年轻分代分为两半,一半用来分配,另一半在回收时负责将之前还需要保留的对象复制过来。 年老分代:根据需要将年老的对象、指针、代码等数据保存起来,较少地进行垃圾回收。 大对象:为那些需要使用较多内存对象分配内存,当然同样可能包含数据和代码等分配的内存,一个页面只分配一个对象。

用一张图可以表示如下: 05

垃圾回收

V8 使用了分代和大数据的内存分配,在回收内存时使用精简整理的算法标记未引用的对象,然后消除没有标记的对象,最后整理和压缩那些还未保存的对象,即可完成垃圾回收。 在V8中,使用较多的是年轻分代和年老分代。年轻分代中的对象垃圾回收主要通过 Scavenge 算法进行垃圾回收。在Scavenge的具体实现中,主要采用了 Cheney 算法。

Cheney算法:通过复制的方式实现的垃圾回收算法。 它将堆内存分为两个 semispace(半空间),一个处于使用中(From空间),另一个处于闲置状态(To空间)。 当分配对象时,先是在From空间中进行分配。 当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。 完成复制后,From空间和To空间的角色发生对换。在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。

年轻分代中的对象有机会晋升为年老分代,条件主要有两个:一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制。

对于年老分代中的对象,由于存活对象占较大比重,再采用上面的方式会有两个问题: 一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。 为此,V8在年老分代中主要采用了Mark-Sweep(标记清除)标记清除和Mark-Compact(标记整理) 相结合的方式进行垃圾回收。

快照

在V8引擎启动时,需要构建JavaScript运行环境,需要加载很多内置对象, 同时也需要建立内置的函数,如Array,String,Math等。为了使V8更加整洁, 加载对象和建立函数等任务都是使用JavaScript文件来实现的,V8引擎负责提供机制来支持,就是在编译和执行JavaScript前先加载这些文件。

V8引擎需要编译和执行这些内置的JavaScript代码,同时使用堆等来保存执行过程中创建的对象、代码等,这些都需要时间。 为此,V8引入了快照机制,将这些内置的对象和函数加载之后的内存保存并序列化。经过快照机制的启动时间可以缩减几毫秒。

8 VS JavaScriptCore

JavaScriptCore引擎是WebKit中默认的JavaScript引擎,也是苹果开源的一个项目,应用较为广泛。 最初,性能不是很好,从2008年开始了一系列的优化,重新实现了编译器和字节码解释器,使得引擎的性能有较大的提升。 随后内嵌缓存、基于正则表达式的JIT、简单的JIT及字节码解释器等技术引入进来,JavaScriptCore引擎也在不断的迭代和发展。

JavaScriptCore 的大致流程为:源代码-→抽象语法树-→字节码-→JIT-→本地代码。 JavaScriptCore与V8有一些不同之处,其中最大的不同就是新增了字节码的中间表示, 并加入了多层JIT编译器(如:简单JIT编译器、DFG JIT编译器、LLVM等)优化性能,不停的对本地代码进行优化(在V8 的 5.9 版本中,新增了一个 Ignition 字节码解释器)。

能扩展

JavaScript引擎的主要功能是解析和执行JavaScript代码,往往不能满足使用者多样化的需要, 那么就可以增加扩展以提升它的能力。V8引擎有两种扩展机制:绑定和扩展。

绑定

使用IDL文件或接口文件生成绑定文件,将这些文件同V8引擎一起编译。 WebKit中使用IDL来定义JavaScript,但又与IDL有所不同,有一些改变。定义一个新的接口的步骤大致如下: 1.定义新的接口文件,可以在JavaScript代码进行调用,如mymodule.MyObj.myAttr:

2.按照引擎定义的标准接口为基础实现接口类,生成JavaScript引擎所需的绑定文件。 WebKit提供了工具帮助生成所需的绑定类,根据引擎不同和引擎开发语言的不同而有所差异。 V8引擎会为上述示例代码生成 v8MyObj.h (MyObj类具体的实现代码)和 V8MyObj.cpp (桥接代码,辅组注册桥接的函数到V8引擎)两个绑定文件。

JavaScript引擎绑定机制需要将扩展代码和JavaScript引擎一块编译和打包, 不能根据需要在引擎启动后再动态注入这些本地代码。 在实际WEB开发中,开发者都是基于现有浏览器的,根本不可能介入到JavaScript引擎的编译中, 绑定机制有很大的局限性,但其非常高效,适用于对性能要求较高的场景。

Extension

通过V8的基类Extension进行能力扩展,无需和V8引擎一起编译,可以动态为引擎增加功能特性,具有很强的灵活性。 Extension机制的大致思路就是,V8提供一个基类Extension和一个全局注册函数,要想扩展JavaScript能力,需要经过以下步骤:

class MYExtension : public v8::Extension {
public:
MYExtension() : v8::Extension("v8/My", "native function my();") {}
virtual v8::Handle<v8::FunctionTemplate> GetNativeFunction (
v8::Handle<v8::String> name) {
// 可以根据name来返回不同的函数
return v8::FunctionTemplate::New(MYExtention::MY);
}
static v8::Handle<v8::Value> MY(const v8::Arguments& args) {
// Do sth here
return v8::Undefined();
}
};
MYExtension extension;
RegisterExtension(&extension);

1.基于Extension基类构建一个它的子类,并实现它的虚函数—GetNativeFunction,根据参数name来决定返回实函数; 2.创建一个该子类的对象,并通过注册函数将该对象注册到V8引擎,当JavaScript调用’my’函数时就可被调用到。 Extension机制是调用V8的接口注入新函数,动态扩展非常方便,但没有绑定机制高效,适用于对性能要求不高的场景。

作为一个提高JavaScript渲染的高效引擎,学习V8引擎应该重点掌握以下几个概念:

  • 类型。 对于函数,JavaScript是一种动态类型语言,JavaScriptCore和V8都使用隐藏类和内嵌缓存来提高性能, 为了保证缓存命中率,一个函数应该使用较少的数据类型; 对于数组,应尽量存放相同类型的数据,这样就可以通过偏移位置来访问。
  • 数据表示。 简单类型数据(如整型)直接保存在句柄中,可以减少寻址时间和内存占用, 如果可以使用整数表示的,尽量不要用浮点类型。
  • 内存。 虽然JavaScript语言会自己进行垃圾回收,但我们也应尽量做到及时回收不用的内存, 对不再使用的对象设置为null或使用delete方法来删除(使用delete方法删除会触发隐藏类新建,需要更多的额外操作)。
  • 优化回滚。 在执行多次之后,不要出现修改对象类型的语句,尽量不要触发优化回滚,否则会大幅度降低代码的性能。
  • 新机制。 使用JavaScript引擎或者渲染引擎提供的新机制和新接口提高性能。

参考文章如下: Google V8 引擎【翻】

Strict Mode?

回答要点包括如下几个方面。

  1. Strict Mode 定义 Strict Mode 是 ECMAScript 语言的变种,详见规范 strict-variant-of-ecmascript 。 开启 Strict Mode 后,JS 引擎会基于 ECMAScript 规范中定义的严格模式约束,排除特定的语法或语义,改变特定语义的执行流程。
  2. 如何开启 通过在代码或函数顶部声明 'use strict' 开启 Strict Mode 模式, 开启规则详见 Use Strict Directive
  3. Strict Mode 作用 用户通过开启来解决一些不安全的语言特性带来的安全或者其他问题。开启后的限制,详见 The Strict Mode of ECMAScript
  4. 加分项 理解为什么会有这个特性,为什么会设计成这样的语法。可以阅读 JavaScript二十年 严格模式章节

"use strict" 是 JavaScript 中的一个编译指示(directive),用于启用严格模式(strict mode)。

严格模式是 JavaScript 的一种执行模式,它增强了代码的健壮性、可维护性和安全性,并减少了一些常见的错误。启用严格模式后,JavaScript 引擎会执行更严格的语法检查,提供更好的错误检测和提示。

使用 "use strict" 有以下几个特点和用途:

  1. 严格模式禁止使用一些不安全或不推荐的语法和行为,例如使用未声明的变量、删除变量或函数、对只读属性赋值等。它会抛出更多的错误,帮助开发者发现并修复潜在的问题。

  2. 严格模式禁止使用一些不严谨的语法解析和错误容忍行为,例如不允许在全局作用域中定义变量时省略 var 关键字。

  3. 严格模式对函数的处理更加严格,要求函数内部的 this 值为 undefined,而非在非严格模式下默认指向全局对象。

  4. 严格模式禁止使用一些具有歧义性的特性,例如禁止使用八进制字面量、重复的函数参数名。

使用严格模式可以提高代码的质量和可靠性,并避免一些常见的错误。为了启用严格模式,只需在 JavaScript 文件或函数的顶部添加 "use strict" 即可。严格模式默认不启用,需要显式地指定。例如:

'use strict'

// 严格模式下的代码

需要注意的是,严格模式不兼容一些旧版本的 JavaScript 代码,可能会导致一些旧有的代码产生错误。因此,在使用严格模式之前,需要确保代码中不会出现与严格模式不兼容的语法和行为。

V8 里面的 JIT 是什么?

在计算机科学中,JIT 是“Just-In-Time”(即时编译)的缩写,它是一种提高代码执行性能的技术。具体来说,在 V8 引擎(Google Chrome 浏览器和 Node.js 的 JavaScript 引擎)中,JIT 编译器在 JavaScript 代码运行时,将其编译成机器语言,以提高执行速度。

这里简要解释下 JIT 编译器的工作原理:

  1. 解释执行:V8 首先通过一个解释器(如 Ignition)来执行 JavaScript 代码。这个过程中,代码不会编译成机器语言,而是逐行解释执行。这样做的优点是启动快,但执行速度较慢。

  2. 即时编译:当代码被多次执行时,V8 会认为这部分代码是“热点代码”(Hot Spot),此时 JIT 编译器(如 TurboFan)会介入,将这部分热点代码编译成机器语言。机器语言运行在 CPU 上比解释执行要快得多。

  3. 优化与去优化:JIT 编译器会对热点代码进行优化,但有时候它会基于错误的假设做出优化(例如认为某个变量总是某种类型)。如果后来的执行发现这些假设不成立,编译器需要去掉优化(Deoptimize),重新编译。

JIT 编译器的一个关键优点是它能够在不牺牲启动速度的情况下,提供接近于或同等于编译语言的运行速度。这使得像 JavaScript 这样原本被认为执行效率较低的语言能够用于复杂的计算任务和高性能的应用场景。

随着 V8 和其他现代 JavaScript 引擎的不断进步,JIT 编译技术也在持续优化,以提供更快的执行速度和更高的性能。

JavaScript 如何做内存管理?

js 语言有内置的垃圾回收机制。 内存分配的申明周期如下

  1. 分配内存 (申明语句会触发内存分配操作)
  2. 使用内存 (函数调用,局域执行触发内存读写)
  3. 内存释放 (gc 自动完成)

node 采用标记清除算法。

JavaScript中的内存管理是由垃圾收集器负责的。垃圾收集器会自动追踪不再使用的对象,并在适当的时候释放它们占用的内存。

JavaScript的垃圾收集器使用了一种称为"标记-清除"(mark and sweep)的算法来确定哪些对象是不再需要的。该算法通过标记所有被引用的对象,然后清除未被标记的对象。

以下是JavaScript中的一些内存管理的原则和技巧:

  1. 自动内存管理:JavaScript的垃圾收集器会自动管理内存,不需要手动释放内存。你只需确保不再使用的对象没有被引用,垃圾收集器会在适当的时候自动回收内存。

  2. 避免全局变量:全局变量会一直存在于内存中,直到页面关闭。尽量减少使用全局变量,而是使用函数作用域或模块化的方式来限制变量的作用范围。

  3. 及时释放引用:当你不再需要一个对象时,最好将对它的引用设置为null,这样可以使垃圾收集器更早地释放对象所占用的内存。

  4. 避免循环引用:如果对象之间存在循环引用,即使它们已经不再被使用,垃圾收集器也不会自动释放它们。确保及时断开循环引用,使垃圾收集器能够正确地回收内存。

  5. 避免大量对象的创建和销毁:频繁地创建和销毁大量对象会导致垃圾收集器频繁地执行,影响性能。如果可能的话,尽量重用对象,而不是频繁地创建和销毁它们。

虽然JavaScript的垃圾收集器自动管理内存,但仍然需要开发人员编写高效的代码来避免内存泄漏和浪费,以确保JavaScript应用程序的性能和可靠性。

参考资料

内联缓存

引擎调试与性能分析

  • 如何使用Chrome DevTools分析V8性能
  • 常见性能问题的识别方法
  • 实用的优化技巧

JS 中的数组和函数在内存中是如何存储的?

在JavaScript中,数组和函数在内存中的存储方式有一些不同。

  1. 数组(Array)的存储:
  • 数组是一种线性数据结构,它可以存储多个值,并且这些值可以是不同类型的。在内存中,数组的存储通常是连续的。当创建一个数组时,JavaScript引擎会为其分配一段连续的内存空间来存储数组的元素。数组的每个元素都会被存储在这段内存空间中的相应位置。数组的长度可以动态改变,当向数组添加或删除元素时,JavaScript引擎会重新分配内存空间并移动元素的位置。
  1. 函数(Function)的存储:
  • 函数在JavaScript中被视为一种特殊的对象。函数的定义实际上是创建一个函数对象,并将其存储在内存中。函数对象本身包含了函数的代码以及其他相关信息,例如函数的名称、参数和闭包等。函数对象的代码部分通常是一段可执行的JavaScript代码,它被存储在内存中的某个位置。当调用函数时,JavaScript引擎会查找该函数对象的存储位置,并执行其中的代码。

需要注意的是,数组和函数的存储方式是由JavaScript引擎决定的,不同的引擎可能会有一些微小的差异。此外,JavaScript引擎还会使用一些优化技术,如垃圾回收和内存管理,来优化内存的使用和回收。

在JavaScript中,变量的存储方式是基于所存储值的数据类型。JavaScript有7种内置数据类型:undefined、null、boolean、number、string、symbol和object。

对于基础数据类型(除了object),变量值会直接存储在内存中。具体来说,这些数据类型的变量在内存中的存储形式如下:

  • undefined和null:这两个数据类型都只有一个值,每个值有一个特殊的内存地址。存储它们的变量会被赋予对应的内存地址。
  • boolean:这个数据类型的值只需要存储一个比特位(0或1),它们通常被存储在栈中,而不是堆中。
  • number:根据规范,数字类型在内存中占用8个字节的空间(64位),它们通常被存储在栈中,而不是堆中。
  • string:字符串实际上是一组字符数组,它们通常被存储在堆中,并通过引用地址存储在栈中。
  • symbol:每个symbol对应于唯一的标识符。它们通常被存储在堆中,并通过引用地址存储在栈中。

而对于对象类型(包括对象、数组等),变量存储了一个指向存储对象的内存地址的指针。JavaScript采用引用计数内存管理,因此它会对每个对象进行引用计数,当一个对象不再被引用时,JavaScript会自动回收这个对象的内存空间。

总的来说,在JavaScript中,变量的存储方式基于值类型的数据类型,对于对象型变量,存储指向对象的内存地址的指针以及对象的值,而对于基础类型的变量,直接存储变量的值。

在JavaScript中,对象是一种无序的键值对集合,可以保存和传递信息。对象是一种非常重要的数据类型,在JavaScript中,几乎所有东西都是对象。

在底层,JavaScript对象的数据结构是哈希表(Hash Table),也可以称为散列表。哈希表是一种使用哈希函数将键值映射到数据中的位置的数据结构。它允许效率高且快速地插入、查找和删除数据,这些操作在算法的平均情况下都需要常数时间。哈希表的主要思想是将键值对转换为索引的方式在常数时间内获取值,因此哈希表非常适合用于大量的键值对数据存储。

在JavaScript中,对象的键值对存储使用了类似哈希表的技术。JavaScript引擎使用一个称为哈希表种子的随机数字来计算键的哈希值,然后使用头插法(链表或树)将键和值存储在桶中,以实现高效的插入和查询操作。因此,JavaScript对象在实现上使用了哈希表来存储和访问键值对,从而提供了非常高效的数据存储和查找操作,使之成为了编写JavaScript代码的强大工具。

隐藏类是什么概念?

关键词:JavaScript隐藏类

隐藏类是JavaScript引擎中的一种优化技术,用于提高对象访问的性能。隐藏类是一种数据结构,用于跟踪对象的属性和方法的布局和类型,以便在代码运行时能够快速访问它们。

当JavaScript引擎在执行代码时,会动态地创建对象的隐藏类。隐藏类会跟踪对象的属性和方法,并为它们分配固定的内存偏移量。每当对象的属性和方法发生变化时,隐藏类会根据变化的情况进行更新。

使用隐藏类可以提高代码的执行速度,因为JavaScript引擎可以根据隐藏类的信息来直接定位和访问对象的属性和方法,而不需要进行动态查找或解析。这种优化技术可以减少对象访问的开销,提高代码的性能。

需要注意的是,隐藏类是在运行时动态创建的,因此代码中创建对象的顺序和属性的添加顺序都会影响隐藏类的生成。如果对象的属性添加顺序不一致,可能会导致隐藏类的生成不一致,从而降低代码的性能。

隐藏类是现代JavaScript引擎(如V8、SpiderMonkey等)中的一项重要优化技术,可以显著提高JavaScript代码的执行速度。

下面是一个使用隐藏类的简单示例:

function MyClass (a, b) {
this.prop1 = a
this.prop2 = b
}

MyClass.prototype.method1 = function () {
console.log('Method 1')
}

MyClass.prototype.method2 = function () {
console.log('Method 2')
}

const obj1 = new MyClass(10, 20)
const obj2 = new MyClass(30, 40)

obj1.method1() // 输出 "Method 1"
obj2.method2() // 输出 "Method 2"

在上面的示例中,我们创建了一个名为MyClass的类,它有两个属性prop1prop2,以及两个方法method1method2。我们用new关键字创建了两个实例obj1obj2

当我们使用隐藏类优化的JavaScript引擎运行这段代码时,它会动态地创建隐藏类来跟踪MyClass的属性和方法。每个实例都会有一个关联的隐藏类,它包含了实例的属性和方法的布局和类型信息。

在调用obj1.method1()obj2.method2()时,JavaScript引擎会使用隐藏类的信息来直接定位并执行相应的方法,而不需要进行动态查找和解析,从而提高了代码的执行速度。

需要注意的是,这只是一个简单的示例,实际上隐藏类的优化是更复杂和细致的。不同的引擎可能会有不同的隐藏类实现方式,并且隐藏类的生成和优化过程会受到许多因素的影响,如代码的结构、对象的属性访问模式等。

尾调优化

es6 JavaScript 尾调用 深入理解JavaScript中的尾调用(Tail Call)

1、什么是尾调用

尾调用是函数式编程里比较重要的一个概念,尾调用的概念非常简单, 一句话就能说清楚,它的意思是在函数的执行过程中,如果最后一个动作是一个函数的调用, 即这个调用的返回值被当前函数直接返回,则称为尾调用。

function f (x) {
return g(x)
}

上面代码中,函数 f 的最后一步是调用函数 g ,这就叫尾调用。以下三种情况,都不属于尾调用。

// 情况一
function f (x) {
const y = g(x)
return y
}
// 情况二

function f1 (x) {
return g(x) + 1
}
// 情况三
function f2 (x) {
g(x)
}

上面代码中,情况一是调用函数 g 之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。

function f (x) {
g(x)
return undefined
}

尾调用不一定出现在函数尾部,只要是最后一步操作即可。

function f (x) {
if (x > 0) {
return m(x)
}
return n(x)
}

上面代码中,函数 m 和 n 都属于尾调用,因为它们都是函数 f 的最后一步操作。

2、尾调用优化

尾调用之所以与其他调用不同,就在于它的特殊的调用位置。

我们知道,函数调用会在内存形成一个 “ 调用记录 ” ,又称 “ 调用帧 ” ( call frame ),保存调用位置和内部变量等信息。 如果在函数 A 的内部调用函数 B ,那么在 A 的调用帧上方,还会形成一个 B 的调用帧。 等到 B 运行结束,将结果返回到 A , B 的调用帧才会消失。 如果函数 B 内部还调用函数 C ,那就还有一个 C 的调用帧,以此类推。 所有的调用帧,就形成一个 “ 调用栈 ” ( call stack )。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧, 因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。

function f () {
const m = 1
const n = 2
return g(m + n)
}
f()
// 等同于
function f1 () {
return g(3)
}
f()
// 等同于
g(3)

上面代码中,如果函数 g 不是尾调用,函数 f 就需要保存内部变量 m 和 n 的值、 g 的调用位置等信息。 但由于调用 g 之后,函数 f 就结束了,所以执行到最后一步,完全可以删除 f(x) 的调用帧,只保留 g(3) 的调用帧。

这就叫做 “ 尾调用优化 ” ( Tail call optimization ),即只保留内层函数的调用帧。 如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是 “ 尾调用优化 ” 的意义。

注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行 “ 尾调用优化 ” 。

function addOne (a) {
const one = 1
function inner (b) {
return b + one
}
return inner(a)
}

上面的函数不会进行尾调用优化,因为内层函数inner用到了外层函数addOne的内部变量one。

3、尾递归

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。 递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生 “ 栈溢出 ” 错误( stack overflow )。 但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生 “ 栈溢出 ” 错误。

function factorial (n) {
if (n === 1) return 1
return nfactorial(n - 1)
}
factorial(5) // 120

上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度 O(n) 。

如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。

function factorial (n, total) {
if (n === 1) return total
return factorial(n - 1, ntotal)
}
factorial(5, 1) // 120

还有一个比较著名的例子,就是计算 fibonacci(斐波那契) 数列,也能充分说明尾递归优化的重要性 如果是非尾递归的 fibonacci 递归方法

function Fibonacci (n) {
if (n <= 1) { return 1 }
return Fibonacci(n - 1) + Fibonacci(n - 2)
}
Fibonacci(10) // 89
// Fibonacci(100)
// Fibonacci(500)
// 堆栈溢出了

如果我们使用尾递归优化过的 fibonacci 递归算法

function Fibonacci2 (n, ac1 = 1, ac2 = 1) {
if (n <= 1) { return ac2 }
return Fibonacci2(n - 1, ac2, ac1 + ac2)
}
Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

由此可见, “ 尾调用优化 ” 对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。 ES6 也是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署 “ 尾调用优化 ” 。这就是说,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存。

4、递归函数的改写

尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。 比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total ,那就把这个中间变量改写成函数的参数。 这样做的缺点就是不太直观,第一眼很难看出来,为什么计算 5 的阶乘,需要传入两个参数 5 和 1 ?

两个方法可以解决这个问题。 方法一是在尾递归函数之外,再提供一个正常形式的函数。

function tailFactorial (n, total) {
if (n === 1) return total
return tailFactorial(n - 1, ntotal)
}
function factorial (n) {
return tailFactorial(n, 1)
}
factorial(5) // 120

上面代码通过一个正常形式的阶乘函数 factorial ,调用尾递归函数 tailFactorial ,看起来就正常多了。

函数式编程有一个概念,叫做柯里化( currying ),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。

function currying (fn, n) {
return function (m) {
return fn.call(this, m, n)
}
}
function tailFactorial (n, total) {
if (n === 1) return total
return tailFactorial(n - 1, ntotal)
}
const factorial = currying(tailFactorial, 1)
factorial(5) // 120

上面代码通过柯里化,将尾递归函数 tailFactorial 变为只接受 1 个参数的 factorial 。

第二种方法就简单多了,就是采用 ES6 的函数默认值。

function factorial (n, total = 1) {
if (n === 1) return total
return factorial(n - 1, ntotal)
}
factorial(5) // 120

上面代码中,参数 total 有默认值 1 ,所以调用时不用提供这个值。

总结一下,递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。 对于其他支持 “ 尾调用优化 ” 的语言(比如 Lua , ES6 ),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。

5、严格模式

ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。 这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。 func.arguments:返回调用时函数的参数。 func.caller:返回调用当前函数的那个函数。 尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。

function restricted () {
'use strict'
restricted.caller // 报错
restricted.arguments // 报错
}
restricted()

6、尾递归优化的实现

尾递归优化只在严格模式下生效,那么正常模式下,或者那些不支持该功能的环境中,有没有办法也使用尾递归优化呢?回答是可以的,就是自己实现尾递归优化。 它的原理非常简单。尾递归之所以需要优化,原因是调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。 怎么做可以减少调用栈呢?就是采用 “ 循环 ” 换掉 “ 递归 ” 。

下面是一个正常的递归函数。

function sum (x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
}
sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)

上面代码中,sum是一个递归函数,参数x是需要累加的值,参数y控制递归次数。 一旦指定sum递归 100000 次,就会报错,提示超出调用栈的最大次数。 蹦床函数(trampoline) 可以将递归执行转为循环执行。

function trampoline (f) {
while (f && f instanceof Function) {
f = f()
}
return f
}

上面就是蹦床函数的一个实现,它接受一个函数f作为参数。只要f执行后返回一个函数,就继续执行。 注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。

然后,要做的就是将原来的递归函数,改写为每一步返回另一个函数。

function sum (x, y) {
if (y > 0) {
return sum.bind(null, x + 1, y - 1)
} else {
return x
}
}

上面代码中,sum函数的每次执行,都会返回自身的另一个版本。 现在,使用蹦床函数执行sum,就不会发生调用栈溢出。

// eslint-disable-next-line
trampoline(sum(1, 100000))
// 100001
// 蹦床函数并不是真正的尾递归优化,下面的实现才是。
function tco (f) {
let value
let active = false
const accumulated = []
return function accumulator () {
accumulated.push(arguments)
if (!active) {
active = true
while (accumulated.length) {
value = f.apply(this, accumulated.shift())
}
active = false
return value
}
}
}
var sum = tco(function (x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
})
sum(1, 100000)
// 100001

上面代码中,tco函数是尾递归优化的实现,它的奥妙就在于状态变量active。 默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。 然后,每一轮递归sum返回的都是undefined,所以就避免了递归执行; 而accumulated数组存放每一轮sum执行的参数,总是有值的,这就保证了accumulator函数内部的while循环总是会执行。 这样就很巧妙地将 “ 递归 ” 改成了 “ 循环 ” ,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。

for 循环的性能远远高于 forEach 的性能?

首先问题说"for循环优于forEach"并不完全正确

循环次数不够多的时候, forEach 性能优于 for

// 循环十万次
const arrs = new Array(100000)

console.time('for')
// eslint-disable-next-line
for (let i = 0; i < arrs.length; i++) {}
console.timeEnd('for') // for: 2.36474609375 ms

console.time('forEach')
arrs.forEach((arr) => {})
console.timeEnd('forEach') // forEach: 0.825927734375 ms

循环次数越大, for 的性能优势越明显

// 循环 1 亿次
const arrs = new Array(100000000)

console.time('for')
// eslint-disable-next-line
for (let i = 0; i < arrs.length; i++) {}
console.timeEnd('for') // for: 72.7099609375 ms

console.time('forEach')
arrs.forEach((arr) => {})
console.timeEnd('forEach') // forEach: 923.77392578125 ms

先做一下对比

对比类型forforEach
遍历for循环按顺序遍历forEach 使用 iterator 迭代器遍历
数据结构for循环是随机访问元素forEach 是顺序链表访问元素
性能上对于arraylist,是顺序表,使用for循环可以顺序访问,速度较快;使用foreach会比for循环稍慢一些对于linkedlist,是单链表,使用for循环每次都要从第一个元素读取next域来读取,速度非常慢;使用foreach可以直接读取当前结点,数据较快

结论

for 性能优于 forEach , 主要原因如下:

  1. foreach相对于for循环,代码减少了,但是foreach依赖IEnumerable。在运行的时候效率低于for循环。
  2. for循环没有额外的函数调用栈和上下文,所以它的实现最为简单。forEach:对于forEach来说,它的函数签名中包含了参数和上下文,所以性能会低于 for 循环。

参考文档

22%