1.chrome多进程架构

从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
下面我们来逐个分析下这几个进程的功能。
浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
音频进程:
存储进程:
讲到这里,现在你应该就可以回答文章开头提到的问题了:仅仅打开了 1 个页面,为什么有 4 个进程?因为打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。
不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:
更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。
对于上面这两个问题,Chrome 团队一直在寻求一种弹性方案,既可以解决资源占用高的问题,也可以解决复杂的体系架构的问题。
未来面向服务的架构
为了解决这些问题,在 2016 年,Chrome 官方团队使用"面向服务的架构"(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的"面向服务的架构" 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。如果你对面向服务的架构感兴趣,你可以去网上搜索下资料,这里就不过多介绍了。
Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务,下面是 Chrome"面向服务的架构"的进程模型图:

目前 Chrome 正处在老的架构向服务化架构过渡阶段,这将是一个漫长的迭代过程。
Chrome 正在逐步构建 Chrome 基础服务(Chrome Foundation Service),如果你认为 Chrome 是"便携式操作系统",那么 Chrome 基础服务便可以被视为该操作系统的"基础"系统服务层。
同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上(如下图),Chrome 会将很多服务整合到一个进程中,从而节省内存占用。

最终 Chrome 团队选择了面向服务架构(SOA)形式,这也是 Chrome 团队现阶段的一个主要任务。
鉴于目前架构的复杂性,要完整过渡到面向服务架构,估计还需要好几年时间才能完成。不过 Chrome 开发是一个渐进的过程,新的特性会一点点加入进来,这也意味着我们随时能看到 Chrome 新的变化。
总体说来,Chrome 是以一个非常快速的速度在进化,越来越多的业务和应用都逐渐转至浏览器来开发,身为开发人员,我们不能坐视不管,而应该紧跟其步伐,收获这波技术红利。
如何分析加载页面启动了几个进程?
默认情况下会启动:
1个网络进程、1个浏览器进程、 1个GPU 进程、1个音频服务进程、1个渲染进程
特殊情况:
1:如果页面里有iframe的话,iframe也会运行在单独的进程中!
2:如果页面里有插件,同样插件也需要开启一个单独的进程!
3:如果你装了扩展的话,扩展也会占用进程
4:如果2个页面属于同一站点的话,并且从a页面中打开的b页面,那么他们会公用一个渲染进程
2.浏览器渲染流程

3.JavaScript代码执行机制
1.变量提升
所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的"行为"。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。
javascript
showName()
console.log(myname)
var myname = '极客时间'
function showName() {
console.log('函数showName被执行');
}
我们可以一行一行来分析上述代码:
第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;
第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置(不了解堆也没关系,JavaScript 的执行堆和执行栈我会在后续文章中介绍)。
这样就生成了变量环境对象。接下来 JavaScript 引擎会把声明以外的代码编译为字节码,至于字节码的细节,我也会在后面文章中做详细介绍,你可以类比如下的模拟代码:
ini
showName()
console.log(myname)
myname = '极客时间'
JavaScript 代码执行过程中,需要先做变量提升,而之所以需要实现变量提升,是因为 JavaScript 代码在执行之前需要先编译。
在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为 undefined;
在代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的。
2.栈溢出

那为什么会出现这种错误呢?这就涉及到了调用栈 的内容。你应该知道 JavaScript 中有很多函数,经常会出现在一个函数中调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种数据结构 。因此要讲清楚调用栈,你还要先弄明白函数调用 和栈结构。
1.函数调用

就这样,当执行到 add 函数的时候,我们就有了两个执行上下文了------全局执行上下文和 add 函数的执行上下文。
执行 JavaScript 时,可能会存在多个执行上下文,JavaScript 引擎通过一种叫栈的数据结构来管理
2.为什么容易出现栈溢出
调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。
特别是在你写递归代码的时候,就很容易出现栈溢出的情况。比如下面这段代码:
scss
function division(a,b){
return division(a,b)
}
console.log(division(1,2))
当执行时,就会抛出栈溢出错误,如下图:

那为什么会出现这个问题呢?
这是因为当 JavaScript 引擎开始执行这段代码时,它首先调用函数 division,并创建执行上下文,压入栈中;
然而,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。理解了栈溢出原因后,你就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。
3.总结:
每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
当分配的调用栈空间被占满时,会引发"堆栈溢出"问题。
3.作用域链、闭包

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链。
现在你知道变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?
要回答这个问题,你还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。
1.词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
2.闭包
结合下面这段代码来理解什么是闭包:
javascript
function foo() {
var myName = "极客时间"
let test1 = 1
const test2 = 2
var innerBar = {
getName:function(){
console.log(test1)
return myName
},
setName:function(newName){
myName = newName
}
}
return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())
首先我们看看当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况,你可以参考下图:

从上面的代码可以看出,innerBar 是一个对象,包含了 getName 和 setName 的两个方法(通常我们把对象内部的函数称为方法)。你可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。
根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。
之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包。
好了,现在我们终于可以给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。
那这些闭包是如何使用的呢?当执行到 bar.setName 方法中的myName = "极客邦"这句代码时,JavaScript 引擎会沿着"当前执行上下文-->foo 函数闭包--> 全局执行上下文"的顺序来查找 myName 变量,你可以参考下面的调用栈状态图:

从图中可以看出,setName 的执行上下文中没有 myName 变量,foo 函数的闭包中包含了变量 myName,所以调用 setName 时,会修改 foo 闭包中的 myName 变量的值。
同样的流程,当调用 bar.getName 的时候,所访问的变量 myName 也是位于 foo 函数闭包中的。
你也可以通过"开发者工具"来看看闭包的情况,打开 Chrome 的"开发者工具",在 bar 函数任意地方打上断点,然后刷新页面,可以看到如下内容:

从图中可以看出来,当调用 bar.getName 的时候,右边 Scope 项就体现出了作用域链的情况:Local 就是当前的 getName 函数的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域,从"Local-->Closure(foo)-->Global"就是一个完整的作用域链。所以说,你以后也可以通过 Scope 来查看实际代码作用域链的情况,这样调试代码也会比较方便。
3.闭包回收
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
4.this机制

在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。
当函数作为对象的方法调用时,函数中的 this 就是该对象;
当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window;
嵌套函数中的 this 不会继承外层函数的 this 值。
4.V8引擎的运行原理
一. 认识JavaScript引擎
1.1. 什么是JavaScript引擎
当我们编写JavaScript代码时,它实际上是一种高级语言,这种语言并不是机器语言。
- 高级语言是设计给开发人员使用的,它包括了更多的抽象和可读性。
- 但是,计算机的CPU只能理解特定的机器语言,它不理解JavaScript语言。
- 这意味着,在计算机上执行JavaScript代码之前,必须将其转换为机器语言。
这就是JavaScript引擎的作用:
- 事实上我们编写的JavaScript无论你交给浏览器或者Node执行,最后都是需要被CPU执行的;
- 但是CPU只认识自己的指令集,实际上是机器语言,才能被CPU所执行;
- 所以我们需要JavaScript引擎帮助我们将JavaScript代码翻译成CPU指令来执行;
比较常见的JavaScript引擎有哪些呢?
- SpiderMonkey:第一款JavaScript引擎,由Brendan Eich开发(也就是JavaScript作者);
- Chakra:微软开发,用于IT浏览器;
- JavaScriptCore:WebKit中的JavaScript引擎,Apple公司开发;
- V8:Google开发的强大JavaScript引擎,也帮助Chrome从众多浏览器中脱颖而出;
- 等等...
1.2. 浏览器内核和JS引擎关系
我们前面学习了浏览器内核,那么浏览器内核和JavaScript引擎之间是什么样的关系呢?
- 浏览器内核和JavaScript引擎之间有紧密的关系,因为JavaScript引擎是浏览器内核中的一个组件。
- 浏览器内核负责渲染网页,并在渲染过程中执行JavaScript代码。
- JavaScript引擎则是负责解析、编译和执行JavaScript代码的核心组件。
以WebKit为例,它是一种开源的浏览器内核,最初由Apple公司开发,并被用于Safari浏览器中。
- WebKit包含了一个JavaScript引擎,名为JavaScriptCore,它负责解析、编译和执行JavaScript代码。
WebKit事实上由两部分组成的:
- WebCore:负责HTML解析、布局、渲染等等相关的工作。
- JavaScriptCore:解析、执行JavaScript代码。

WebKit内核
看到这里,学过小程序的同学有没有感觉非常的熟悉呢?
- 在小程序中编写的JavaScript代码就是被JSCore执行的;

小程序的架构设计
另外一个非常强大的JavaScript引擎就是V8引擎,也是我们今天要学习的重点。
二. V8引擎的运行原理
2.1. V8引擎的官方定义
V8引擎是一款Google开源的高性能JavaScript和WebAssembly引擎,它是使用C++编写的。
- V8引擎的主要目标是提高JavaScript代码的性能和执行速度。
- V8引擎可以在多种操作系统上运行,包括Windows 7或更高版本、macOS 10.12+以及使用x64、IA-32、ARM或MIPS处理器的Linux系统。
V8引擎可以作为一个独立的应用程序运行,也可以嵌入到其他C++应用程序中,例如Node.js。
- 由于V8引擎的开源性和高性能,许多现代浏览器都使用了V8引擎或其修改版本,以提供更快、更高效的JavaScript执行体验。
2.2. V8引擎如何工作呢?
2.2.1. V8引擎的工作过程
我这里先给出一副V8引擎的工作图:
- 后续我们会一点点解析它的工作过程

V8引擎的工作图
整体流程如下:(先简单了解)
- 词法分析:
-
- 首先,V8引擎将JavaScript代码分成一个个标记或词法单元,这些标记是程序语法的最小单元。
- 例如,变量名、关键字、运算符等都是词法单元。
- V8引擎使用词法分析器来完成这个任务。
- 语法分析:
-
- 在将代码分成标记或词法单元之后,V8引擎将使用语法分析器将这些标记转换为抽象语法树(AST)。
- 语法树是代码的抽象表示,它捕捉了代码中的结构和关系。
- V8引擎会检查代码是否符合JavaScript语言规范,并将其转换为抽象语法树。
- 字节码生成:
-
- 接下来,V8引擎将从语法树生成字节码。
- 字节码是一种中间代码,它包含了执行代码所需的指令序列。
- 字节码是一种抽象的机器代码,它比源代码更接近机器语言,但仍需要进一步编译成机器指令。
- 机器码生成:
-
- 最后,V8引擎将生成机器码,这是一种计算机可以直接执行的二进制代码。
- V8引擎使用即时编译器(JIT)来将字节码编译成机器码。
- JIT编译器将字节码分析为代码的热点部分,并生成高效的机器码,以提高代码的性能。
2.2.2. V8引擎的架构设计
V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的:
Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;
- 如果函数没有被调用,那么是不会被转换成AST的;
- Parse的V8官方文档:v8.dev/blog/scanne...
Ignition是一个解释器,会将AST转换成ByteCode(字节码)
- 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
- 如果函数只调用一次,Ignition会执行解释执行ByteCode;
- Ignition的V8官方文档:v8.dev/blog/igniti...
TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;
- 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
- 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
- TurboFan的V8官方文档:v8.dev/blog/turbof...
另外,V8引擎还包括了垃圾回收机制,用于自动管理内存的分配和释放。V8引擎使用了一种名为"分代式垃圾回收"(Generational Garbage Collection)的技术,它将堆区分成新生代和老年代两个部分,分别使用不同的垃圾回收策略,以提高垃圾回收的效率。
- 内存管理我们后续再单独来讨论学习。
2.3. V8的转化代码过程
比如我们有如下一段代码,V8引擎是如何一步步帮我们转化的呢?
javascript
const name = "coderwhy"
console.log(name)
function sayHi(name) {
console.log("Hi " + name)
}
sayHi(name)
下面是官方给出的一个图解:

官方图例
2.3.1. 词法分析的过程
词法分析是将JavaScript代码转换成一系列标记的过程,它是编译过程的第一步。
- 在V8引擎中,词法分析器会将JavaScript代码分解成一系列标识符、关键字、操作符和字面量等基本元素,以供后续的语法分析和代码生成等步骤使用。
这里仅仅举一个例子,作为参考即可
ini
Token(type='const', value='const')
Token(type='identifier', value='name')
Token(type='operator', value='=')
Token(type='string', value='"coderwhy"')
Token(type='operator', value=';')
Token(type='console', value='console')
Token(type='operator', value='.')
Token(type='identifier', value='log')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
Token(type='function', value='function')
Token(type='identifier', value='sayHi')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value='{')
Token(type='console', value='console')
Token(type='operator', value='.')
Token(type='identifier', value='log')
Token(type='operator', value='(')
Token(type='string', value='"Hi "')
Token(type='operator', value='+')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
Token(type='operator', value='}')
Token(type='identifier', value='sayHi')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
2.3.2. 语法分析的过程
接下来我们可以根据上面得到的tokens代码,进行语法分析,生成对应的AST树。
在V8引擎中,语法分析的过程可以分为两个阶段:解析(Parsing)和预处理(Pre-parsing)。
解析阶段是将tokens转换成抽象语法树(AST)的过程,而预处理阶段则是在解析阶段之前进行的,用于预处理一些代码,如函数和变量声明等。
对于你提供的JavaScript代码,V8引擎的解析和预处理过程如下所示:
V8引擎的解析和预处理过程如下所示:
- 预处理阶段
- 在预处理阶段,V8引擎会扫描整个代码,查找函数和变量声明,并将其添加到当前作用域的符号表中。
- 在这个过程中,V8引擎会同时进行词法分析和语法分析,生成一些中间表示,以便后续使用。
- 对于我们的代码,预处理阶段不会生成任何AST节点,因为它只包含了一个常量声明和一个函数声明,而没有变量声明(var声明的变量)。
- 解析阶段
- 在解析阶段,V8引擎会将tokens转换成AST节点,生成一棵抽象语法树(AST)。
- AST是一种树形结构,用于表示程序的语法结构,它包含了多种类型的节点,如表达式节点、语句节点和声明节点等。
转化的AST树代码参考:
javascript
Program
└── VariableDeclaration (const name = "coderwhy")
└── ExpressionStatement (console.log(name))
└── FunctionDeclaration (function sayHi(name) { ... })
└── BlockStatement
└── ExpressionStatement (console.log("Hi " + name))
└── ExpressionStatement (sayHi(name))
从AST树中可以看出,整个程序由一个Program节点和三个子节点组成。
- 其中,第一个子节点是一个VariableDeclaration节点,表示常量声明语句;
- 第二个子节点是一个ExpressionStatement节点,表示console.log语句;
- 第三个子节点是一个FunctionDeclaration节点,表示函数声明语句。
-
- FunctionDeclaration节点包含一个BlockStatement子节点,表示函数体,其中包含一个ExpressionStatement节点,表示console.log语句。
- 最后一个子节点是一个ExpressionStatement节点,表示调用函数语句。
2.3.3. 转化的字节码(了解)
根据上面得到的AST树,我们可以将其转换成对应的字节码。在V8引擎中,字节码是一种中间表示,用于表示程序的执行流程和指令序列。
V8引擎会将AST树转换成如下的字节码序列:
csharp
// 字节码指令集
[Constant name="coderwhy"]
[SetLocal name]
[GetLocal name]
[LoadProperty console]
[LoadProperty log]
[Call 1]
[Constant Hi ]
[GetLocal name]
[BinaryOperation +]
[Call 1]
[SetLocal sayHi]
[GetLocal name]
[GetLocal sayHi]
[Call 1]
[Return]
根据上面生成的字节码,我们可以看到V8引擎生成的字节码指令集,每个指令都对应了一种操作,如Constant、SetLocal、GetLocal等等。下面是对字节码指令集的解释:
- Constant:将常量值压入操作数栈中。
- SetLocal:将操作数栈中的值存储到本地变量中。
- GetLocal:将本地变量的值压入操作数栈中。
- LoadProperty:从对象中加载属性值,并将其压入操作数栈中。
- Call:调用函数,并将返回值压入操作数栈中。
- BinaryOperation:对两个操作数执行二元运算,并将结果压入操作数栈中。
- Return:从当前函数中返回,并将返回值压入操作数栈中。
由于字节码是一种中间表示,它可以跨平台运行,在不同的操作系统和硬件平台上都可以执行。这种跨平台的特性,使得V8引擎成为了一款非常流行的JavaScript引擎。
在Node环境中,我们可以通过如下命令查看到字节码:
- 但是默认Node环境下是打印所有的字节码的,所以内容会非常多(了解即可)
arduino
node --print-bytecode test.js
2.3.4. 生成的机器码(了解)
在V8引擎中,机器码是通过即时编译(Just-In-Time Compilation,JIT)技术生成的。
- JIT编译是一种动态编译技术,它将字节码转换成本地机器码,并将其缓存起来以提高代码的执行速度和性能。
- JIT编译器可以根据运行时信息对代码进行优化,并且可以根据不同的平台和硬件生成对应的机器码。
在V8引擎中,机器码的生成过程分为两个阶段:
- 预编译(pre-compilation)和优化(optimization)。
- 预编译阶段会生成一些简单的机器码,用于快速执行代码;
- 优化阶段则会根据代码的运行时信息生成更优化的机器码,以提高代码的执行效率和性能。
具体的生成过程如下:
- 预编译阶段
- 在预编译阶段,V8引擎会生成一些简单的机器码,用于快速执行代码。
- 这些机器码是基于字节码生成的,它们可以直接执行,并且具有一定的优化效果。
- 在这个阶段,V8引擎会根据代码的运行时信息生成一些简单的机器码,如对象和数组的存取、字符串的拼接、函数的调用等。
- 优化阶段
- 在优化阶段,V8引擎会根据代码的运行时信息生成更优化的机器码,以提高代码的执行效率和性能。
- 在这个阶段,V8引擎会通过分析代码的执行路径、类型信息、控制流程等,生成一些高效的机器码,并且可以进行多次优化,以获得更高的性能。
在优化阶段,V8引擎会使用TurboFan编译器来生成机器码。
- TurboFan是一个基于中间表示(Intermediate Representation,IR)的编译器,它可以将字节码转换成高效的机器码,并且可以进行多层次的优化,包括基于类型的优化、内联优化、控制流优化、垃圾回收优化等。
通过机器码的生成过程,我们可以看到V8引擎是如何根据代码的运行时信息生成高效的机器码,并且可以多次优化,以获得更高的性能。
- 在后续的执行过程中,V8引擎会将机器码缓存起来,以提高代码的执行速度和性能。
三. V8引擎的内存管理
3.1. 认识内存管理
不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。
不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:
- 第一步:分配申请你需要的内存(申请);
- 第二步:使用分配的内存(存放一些东西,比如对象等);
- 第三步:不需要使用时,对其进行释放;
不同的编程语言对于第一步和第三步会有不同的实现:
- 手动管理内存:比如C、C++,包括早期的OC,都是需要手动来管理内存的申请和释放的(malloc和free函数);
-
- 这种方式需要程序员手动管理内存,容易出现内存泄漏和野指针等问题,程序的稳定性和安全性有一定的风险。
- 自动管理内存:比如Java、JavaScript、Python、Swift、Dart等,它们有自动帮助我们管理内存;
-
- 在这些语言中,存在垃圾回收机制来自动回收不再使用的内存空间,程序员只需要正确地使用变量和对象等引用类型数据,垃圾回收器就会自动进行内存管理,释放不再被引用的内存空间。
- 这种方式可以避免内存泄漏和野指针等问题,提高了程序的稳定性和安全性。
对于开发者来说,JavaScript 的内存管理是自动的、无形的。
- 我们创建的原始值、对象、函数......这一切都会占用内存;
- 但是我们并不需要手动来对它们进行管理,JavaScript引擎会帮助我们处理好它;
3.2. JS的内存管理
在JavaScript中,内存分为栈内存和堆内存两种类型。
- 栈内存用于存储基本数据类型和引用类型的地址,它具有自动分配和自动释放的特点。
- 堆内存用于存储引用类型的对象和数组等数据结构,它需要手动分配和释放内存。
在JavaScript中,使用var、let和const声明的变量都是存在栈内存中的。
- 当我们声明一个变量时,JavaScript引擎会在栈内存中为其分配一块空间,并将变量的值存储在该空间中。
- 当变量不再被引用时,JavaScript引擎会自动将其释放掉,以回收其空间。
在JavaScript中,创建的对象和数组等引用类型数据都是存在堆内存中的。
- 当我们创建一个对象时,JavaScript引擎会在堆内存中为其分配一块空间,并将其属性存储在该空间中。
- 当对象不再被引用时,垃圾回收器会自动将其标记为垃圾,并回收其空间。
为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。
在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如free函数:
- 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
- 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露和野指针的情况;
- 影响程序的稳定性和安全性,同时也会影响编写逻辑代码的效率;
所以大部分现代的编程语言都是有自己的垃圾回收机制:
- 垃圾回收的英文是Garbage Collection,简称GC;
- 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
- 而我们的语言运行环境,比如Java的运行环境JVM,JavaScript的运行环境js引擎都会内存 垃圾回收器;
- 垃圾回收器我们也会简称为GC,所以在很多地方你看到GC其实指的是垃圾回收器;
但是这里又出现了另外一个很关键的问题:GC怎么知道哪些对象是不再使用的呢? 这里就要用到GC的实现以及对应的算法;
3.3. 常见的GC算法
3.3.1. 引用计数(Reference counting)
引用计数(Reference counting)是一种常见的垃圾回收算法。
- 它的基本思想是在对象中添加一个引用计数器。
- 每当有一个指针引用该对象时,引用计数器就加一。
- 当指针不再引用该对象时,引用计数器就减一。
- 当引用计数器的值为0时,表示该对象不再被引用,可以被回收。
引用计数算法的优点是实现简单,垃圾对象的回收及时,可以避免内存泄漏。
但是引用计数算法也有一些缺点。
- 最大的缺点是很难解决循环引用问题。
- 如果两个对象相互引用,它们的引用计数器永远不会为0,即使它们已经成为垃圾对象。
- 这种情况下,引用计数算法就无法回收它们,导致内存泄漏。

循环引用
3.3.2. 标记清除(mark-Sweep)
标记清除(mark-Sweep)是一种常见的垃圾回收算法,其核心思想是可达性(Reachability)。算法的实现过程如下:
- 设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象。
- 对于每一个找到的对象,标记为可达(mark),表示该对象正在使用中。
- 对于所有没有被标记为可达的对象,即不可达对象,就认为是不可用的对象,需要被回收。
- 回收不可达对象所占用的内存空间,并将其加入空闲内存池中,以备将来重新分配使用。
标记清除算法可以很好地解决循环引用的问题,因为它只关注可达性,不会被循环引用的对象误判为可用对象。

标记清除算法
但是这种算法也有一些缺点,最主要的是它的效率不高,因为在标记可达对象和回收不可达对象的过程中需要遍历整个对象图。
此外,标记清除算法还会造成内存碎片的问题,因为回收的内存空间不一定是连续的,导致大块的内存无法被分配使用。
3.3.3. 其他算法优化补充
S引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于V8引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。
标记整理(Mark-Compact)
- 和"标记-清除"相似;
- 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;
分代收集(Generational collection)------ 对象被分成两组:"新的"和"旧的"。
- 许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
- 那些长期存活的对象会变得"老旧",而且被检查的频次也会减少;
增量收集(Incremental collection)
- 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
- 所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
闲时收集(Idle-time collection)
- 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
- 这种算法通常用于移动设备或其他资源受限的环境,以确保垃圾收集对用户体验的影响最小。
3.3.4. V8引擎的内存图
事实上,V8引擎为了提供内存的管理效率,对内存进行非常详细的划分。(详细参考视频学习)
这幅图展示了一个堆(heap)的内存结构,下面是对每个内存块的解释:
- Old Space(老生代):分配的内存较大,存储生命周期较长的对象,比如页面或者浏览器的长时间使用对象;
- New Space(新生代):分配的内存较小,存储生命周期较短的对象,比如临时变量、函数局部变量等;
- Large Object Space(大对象):分配的内存较大,存储生命周期较长的大型对象,比如大数组、大字符串等;
- Code Space(代码空间):存储编译后的函数代码和 JIT 代码;
- Map Space(映射空间):存储对象的属性信息,比如对象的属性名称、类型等信息;
- Cell Space(单元格空间):存储对象的一些元信息,比如字符串长度、布尔类型等信息。
这些不同的内存块都有各自的特点和用途,V8 引擎会根据对象的生命周期和大小将它们分配到不同的内存块中,以优化内存的使用效率。

V8引擎的内存图