补齐v8基础知识v8与JavaScript简介
Posted 腾讯AlloyTeam
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了补齐v8基础知识v8与JavaScript简介相关的知识,希望对你有一定的参考价值。
javascript v8 引擎来自 Google,第一个版本随着第一个版本的 Chrome 于 2008 年 9 月 2 日发布。v8 引擎命名自汽车的“v8 发动机”,这款汽车发动机一般用于中高端的汽车。这也寓意着 JavaScript 版的 v8 引擎追求极高的运行速度。
v8 与宿主环境
执行引擎
v8 是使用 C++开发的 JavaScript 执行引擎(虚拟机)。
v8 的工作就是将 JavaScript 代码转化成 CPU 可以识别的指令执行。但 v8 不完全是一个编译器,因为它除了需要编译代码,还需要负责管理 JavaScript 执行环境、监控代码执行情况并提供优化、跨平台等功能。总的来说,v8 管理和驱动着 JavaScript 代码的运行。同时,还提供了 GC、coroutine 等需要配合宿主运行时环境才能完整使用的功能。
虚拟机本质上就是一种模拟实际计算机的功能(例如指令系统、CPU、寄存器)执行指令的程序。v8 根据 ECMAScript 标准封装了底层的机器指令,使得我们编写的 JavaScript 程序可以从逻辑上模拟计算机的硬件系统工作。就像 JVM 是 Java 的虚拟机,v8 也是 JavaScript 的虚拟机。
继续深入“执行”这个概念,我认为代码的执行主要分为两点:
数据在内存里的表示(数据结构)
使用逻辑操作数据
因此,从宏观上看,我认为 v8 的工作是:
把 JavaScript 的数据类型转化为符合计算机内存模型的数据结构
把操作数据的逻辑代码编译成二进制代码执行
内存
Heap & Stack Memory
堆和栈是操作系统对于 RAM 的封装。
我们常听到的堆栈内存,是操作系统对 RAM 空间的一种抽象,使其方便我们程序的编写。它们分别是指:
栈内存:由操作系统自动分配和释放,存放函数的参数值、局部变量、指针等。其操作的方式类似数据结构中的栈。物理上使用一级缓存(离 CPU 内核最近的高速临时存储器)缓存。
堆内存:由程序员管理,若一直不释放,程序结束时会由操作系统回收,分配的方式类似数据结构中的链表。物理上使用二级缓存(相对一级缓存更慢,但空间更大的临时存储器)缓存。
堆栈内存的组织方式
JavaScript 的数据类型是基于虚拟机实现的。因此,栈内存的管理使用两个指针:ESP(栈顶)指针与 EBP(栈底)指针管理。对于被分配到栈内存的基本数据类型,系统操作该指针执行“压栈”和“出栈”操作,并在该变量存在的作用域销毁时自动回收。
在 C 语言中,有(m|c)alloc 和 free 分别负责分配和释放;在 C++中,有 new 和 delete;而在 JavaScript 中,程序员只会使用 new 分配内存,内存的释放则交给 v8 的垃圾回收算法自动处理。
虽然堆空间物理上是连续的,但连续的空间被不断地分配和释放不定大小的内存,最终总是会出现很多的内存碎片。
Free List:一种方式是使用链表来记录内存碎片的基址和大小以管理堆空间,分配和释放时对应修改链表的信息:
Semi-spaces:另一种方式是把堆等分成两块。在每一块中连续地分配,不释放,而这一块内存满了以后则将不需要释放的内存拷贝到另一块中继续使用,并释放该块内存:
Garbage Collection
完整的垃圾回收过程有两步:找出垃圾和清理垃圾。以上的内存模型告诉我们如何清理垃圾,我们还需要知道如何找到垃圾:
早期 IE6/7 使用Reference-Counting 算法。对每个引用类型保存一个自身的引用数,为零时则清理。这种方法虽然思路简单、性能好,但一直无法解决循环引用的问题,容易导致内存泄漏。
后期的 v8 采用Mark-Sweep 算法。从 GC Root(一般是全局变量)开始搜索,标记所有可达的元素,而清理内存中所有不可达的元素。这种方法虽然解决了循环引用的问题,但对内存进行染色标记的过程历时非常久。
结合常用的 JavaScript 运行时环境,v8 对 GC 算法做了不少优化:
根据代际假说,v8 对常驻内存的对象(window、global、Web API 等)和短暂存在的对象使用不同的清理方法。
对于 Mark-Sweep 算法,长时间的查找垃圾过程(被称为 stop-the-world 算法)被拆解成了多个小任务,并移到辅助线程并发地、增量地完成。
【题外话】并发是一种对阻塞任务常见的优化思路。为了防止一个重量级操作阻塞主线程的运行,可以把任务拆细,并分配到阶段执行。例如 v8 的 GC 算法、React Fiber。
编译
机器代码
计算机的 CPU 是一个只认识二进制指令的运算机。为了使人们能和 CPU 沟通,CPU 工程师们封装了 CPU 指令集。由于 CPU 指令集仍然是难以阅读的,程序员进一步封装了汇编指令集:
1000100111011000 机器指令
mov ax, bx 汇编指令
不同架构的 CPU 具有不同的指令集,因此对应的汇编语言也有所不同。编写汇编时还需要了解寄存器、内存、处理器架构等硬件知识。因此程序员需要一种能屏蔽底层硬件架构细节的语言来专心编写业务逻辑。
通过封装底层的硬件细节并约定一种符合人类阅读习惯的语法,高级语言诞生了,人们可以通过学习更贴近人类逻辑的高级语言来了解计算机执行的逻辑。
而后出现的脚本语言,则是基于某种解释程序,通过动态地解释成 CPU 指令交给计算机执行。
编译和解释
JavaScript 起初是种脚本语言,但 v8 为了追求速度而不占用过多空间,通过混合编译和解释的方式将 JavaScript 转化为计算机能“读懂”的机器代码。
在 v8 出现之前,所有的 JavaScript 引擎都是采用纯解释执行的方式,运行速度到达一个瓶颈难以提升。v8 最先使用纯编译(Crankshaft 编译器)的方式执行,大大提高了速度。
而后又引入了JIT的思想(Ignition 解释器)对空间和启动速度进行优化,使用了混合编译(机器语言)和解释执行(字节码)的方式。后续,v8 也是继续引入了惰性编译、内联缓存、隐藏类等机制,继续优化执行效率。
编译
将源代码通过解析器编译成中间代码(通常是汇编代码),再通过编译器编译成机器代码。通常机器代码编译出来会使用.bin 文件存储,需要执行时可以直接读取执行该二进制文件;或者在虚拟机中暂时把二进制代码保存在内存中,需要执行时直接执行:
解释
5K 大小的 JavaScript 代码可以编译出 10M 大小的二进制文件。因此,尽管执行速度非常快,编译代码占用的内存却不可接受。许多的脚本代码只需要执行一次,将其编译成二进制再执行也显然没有直接执行来得轻量。
于是,v8 重新引入了 ByteCode,将源代码通过解析器编译成中间代码,再使用解释器解释提供给 CPU 执行,而重复执行的代码再进行二进制的优化:
计算机的功能是多样的,高级语言也需要依托于操作系统提供的接口才能完成各种各样的功能。例如 v8 需要依托于一个宿主运行时环境(典型的有浏览器的渲染进程、node.js)才可以真正使用代码操作计算机完成一些行为。
运行时系统
运行时系统,也叫运行时环境,提供给程序在对应操作系统中管理内存、访问变量、传递参数以及调用其他操作系统接口的能力。
简单来说,运行时环境是操作系统接口的封装,它连接了虚拟机和操作系统。
举一个在【图解 Google V8】课程中看到的例子说明什么是“宿主环境”:
生物学中,宿主是为寄生物提供生存环境的生物。例如,宿主有完整的代谢系统,而病毒没有,病毒只有核酸链和蛋白质外壳。因此,病毒要想完成自我复制,需要和宿主共同使用一套代谢系统。当病毒离开了宿主细胞,也就没有了生命活动;而如果病毒使用了太多的宿主细胞资源,也会影响到细胞的正常活动。 v8 的核心是实现了 ECMAScript 标准,就像病毒中有自己的核酸。它还需要借助宿主细胞(渲染进程、node.js 等)提供的代谢系统(一条主线程、Event Loop 等)才能完成自我复制(完整地执行)。
运行时环境通过封装操作系统的接口,提供了一些能力,例如 node.js:
可以编译 v8 生成的 JavaScript 中间码
提供访问磁盘(readFile)、IO(Event Loop)、网络(http)等的能力
线程管理
Heap Memory Interface
……
v8 是 node.js 中的一个依赖包。node 依赖 v8 编译代码的能力,而 v8 寄生在其中,二者配合则可以完成一些它自己无法做到的事:
借助 node 提供的对堆空间的操作接口,v8 可以自定义内存模型,也能实现自己的垃圾回收机制
配合 node 依赖的 uv(也就是 libuv,C 语言开发的跨平台异步 I/O 库),实现异步编程等能力。
……
以上举的是 node.js 的例子,而对于浏览器中的 UI 进程,大体的思路是差不多的。
【题外话】程序的本质就是对底层的层层封装:高低电位 ➡️ 硬件 ➡️ 操作系统 ➡️ 运行时系统 ➡️ 程序。
JavaScript 语言思想
v8 是 JavaScript 的执行引擎。学习 v8,首先需要深刻了解 JavaScript 语言的设计思想。
总体来说,JavaScript 借鉴了许多其他语言的特性:
C 系语法
Java 的类型系统
Scheme 的“函数一等公民”
Self 的基于 prototype 的继承机制
一切皆为对象
JavaScript 是一门基于对象的语言。
JavaScript 的数据类型分为原始类型、对象类型,而对象类型又可以分为普通对象类型和函数对象类型。
由于 JavaScript 是基于对象的语言,程序员在其中会非常频繁地使用对象以及访问对象的属性。v8 为了为对象属性取址增速,采用了快慢属性和隐藏类等设计。这使属性在内存中的实际分布更贴近硬件底层,一些简单的对象甚至可以使用更小的空间来存储(优化成 struct)。了解这些复杂的存储规则也使我们可以编写更高效的代码。
函数是一等公民
如果某种编程语言中,函数可以和其他数据类型做一样的事,就可以称为函数在这门语言中是“一等公民”。
在 JavaScript 中,函数是一种特殊的对象。因此你可以把函数赋值给变量(函数表达式),或者作为另一个函数的参数使用(callback),还可以在函数里定义函数。函数作为一种特殊对象,其中也有保存自身信息的一些属性:
Name:函数名(默认为 anonymous)
Code:代码
……
在函数里被定义的函数,会沿着作用域链向外层的词法作用域查找变量,这被称为闭包(closure)。闭包把内部函数对外层变量的引用以及该变量的值一直保存在内存中,方便内层使用。支持闭包的语言方便了程序员编写逻辑,但也增加了内存问题出现的可能性。为了支持闭包,v8 也使用了惰性解析的技术,这也使得 v8 的解析过程变得十分复杂。
函数表达式
由于函数是一等公民,函数可以像对象一样被赋值给变量,这就是函数表达式。
区别于直接声明函数,函数表达式中被赋值的变量与一般的变量声明相同,这就会引起变量提升的问题:
test(); // foo
function test() {
console.log("foo");
}
test2(); // Uncaught TypeError: ...
const test2 = function () {
console.log("foo");
};
变量提升也是 v8 中的 feature。在编译阶段,v8 会把:
函数变量:声明时直接把函数声明提升到词法作用域中。
普通变量:声明时提升到词法作用域中,并设置初始值为 undefined。
除此之外,函数表达式和普通的函数一样,还可以作为函数的参数或者返回值使用。这种灵活性也产生了许多代码设计:
回调函数
柯里化
React 高阶组件
原型链
原型指的是某种技术在投入量产前所作的模型。
JavaScript 函数上具有 prototype 属性,是一个称为“原型”的对象。
JavaScript 使用 new + function 的方式去创建对象,这种方式其实有些诡异,但作者当时主要是为了蹭 Java 语法的热度才这么设计的。所有函数对象被构建出来时,上面都会挂上一个原型(prototype)对象。使用 prototype
一词定义该对象,也寓意着当我们使用 function 来构造对象时,该对象会以这个函数的 prototype 对象为“模型”构建。
JavaScript 不是面向对象的语言(有封装、继承,但弱类型语言本身没有多态,也不需要多态)。但是 JavaScript 的 function 可以比作构造函数,而使用__proto__(非标准对象,v8 自己实现的对象原型)也可以模拟类的方式实现继承。正如继承关系是可以形成链状的,__proto__也可以成链。在对象上查找属性也会沿着__proto__链一直往上查找。
作用域链
作用域是使变量名和实体保持有效的部分计算机程序。
v8 中使用对象来表示作用域,对象里面存储了该作用域内变量的声明。
JavaScript 中有以下几种作用域:
全局作用域(Global Scope)
局部作用域(Local Scope)
with 块(With Block)
闭包作用域(Closure)
catch(Catch)
块作用域(Block Scope)
script 标签作用域(Script)
eval 作用域(Eval)
module 作用域(Module)
WASM 作用域(Wasm Expression Stack)
v8 初始化时就需要初始化全局作用域。【函数运行】、【const/let 变量声明】、【with 语句】和【catch 块的执行】等,则会构造新的作用域加入当前的作用域中。其中每当函数作用域生成时,v8 都会添加 this 指针,默认指向上层的 this。而在函数运行结束时,作用域又会被销毁。
v8 编译 JavaScript 时,在语法分析的 Parse 部分为每层作用域维护了变量表(variable_)。查找一个变量时,v8 也会沿着词法作用域(Lexical Scope)链逐步往外层查找每个作用域的变量表。
由于在语法分析的 Parse 阶段时,v8 将变量的声明和赋值分开处理,变量声明返回的结果也是 undefined。而为了解决闭包变量的查找和惰性解析优化带来的问题时,v8 做了更多复杂的处理。
弱类型
在计算机科学中,类型系统用于定义如何将编程语言中的数值和表达式归类为许多不同的类型,如何操作这些类型,这些类型如何相互作用。
JavaScript 是一门弱类型的语言。编译器在词法分析阶段扫描代码时会自动推断变量的类型。而进行实际的运算时,v8 则会尝试对不符合该运算符规则的类型进行转换:
调用该对象的 ToPrimitive 函数
ToPrimitive 会调用对象的 valueOf 方法
若 valueOf 方法没有定义,则调用 toString 方法
若两个方法都不返回基本类型,则抛出 TypeError
【题外话】JavaScript 的类型系统时根据 ECMAScript 标准实现的,其中的类型转换有不少的问题存在。使用 TypeScript interface 约定对象的类型,也可以强化我们编写程序时通过接口通信的思想,减少模块间的耦合程度。
关于AlloyTeam
AlloyTeam 是国内影响力最大的前端团队之一,核心成员来自前 WebQQ 前端团队。 AlloyTeam负责过WebQQ、QQ群、兴趣部落、腾讯文档等大型Web项目,积累了许多丰富宝贵的Web开发经验。
这里技术氛围好,领导nice、钱景好,无论你是身经百战的资深工程师,还是即将从学校步入社会的新人,只要你热爱挑战,希望前端技术和我们飞速提高,这里将是最适合你的地方。
加入我们,请将简历发送至 alloyteam@qq.com,或直接在公众号留言~
期待您的回复 以上是关于补齐v8基础知识v8与JavaScript简介的主要内容,如果未能解决你的问题,请参考以下文章