2. 存储机制
JavaScript 是如何存储数据
JavaScript 是动态语言,在声明变量之前不需要确认变量的数据类型。JavaScript 引擎在运行代码的时候自己会计算出变量的类型。
JavaScript 是弱类型语言,它支持隐式类型转换。可以使用同一个变量保存不同类型的数据。
-
JavaScript 有 7 种原始类型数据:null、undefined、number、boolean、string、symbol、bigInt
-
JavaScript 有 1 种引用类型数据:object
-
JavaScript 在执行过程中, 有三种类型内存空间:
-
代码空间:存储可执行代码。
-
栈空间:运行时调用栈,存储执行上下文、原始类型数据。
-
堆空间:存储闭包、引用类型数据
-
举例:
function foo(){
var a = "极客时间";
var b = a;
var c = {name:"极客时间"};
var d = c;
}
foo()
对象类型是存放在堆空间的,在栈空间中只是保留了对象的引用地址。当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的。
为什么要把引用数据类型全部存在堆内存中?
- 因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。
所以通常情况下,栈空间的体积偏小,主要用来存放原始类型的小体积数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到空间更大的堆中。堆内存的缺点是 分配内存 和 回收内存 都会有一定的开销。
闭包的存储机制
闭包存储在堆空间中。
通过一段代码距离:
function foo() {
var myName = "极客时间"
let test1 = 1
const test2 = 2
var innerBar = {
setName:function(newName){
myName = newName
},
getName:function(){
console.log(test1)
return myName
}
}
return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())
当执行这段代码的时候,你应该有过这样的分析:
- 由于变量
myName
、test1
、test2
都是原始类型数据,所以在执行foo
函数的时候,它们会被压入到调用栈中; - 当 JavaScript 引擎执行到
foo
函数时:- 首先进入编译阶段,创建一个空执行上下文,然后依次声明
myName
,test1
,test2
,innerBar
变量。 - 之后进入运行阶段,当执行到对
innerBar
赋值的代码时,发现其内部函数setName
和getName
引用了外部函数foo
的变量nyName
和test1
。- 内部函数引用外部变量,这就形成了一个 闭包。所以,此时会在堆内存中创建一个
closure(foo)
对象,存储foo
的变量nyName
和test1
。 - 然后,在
foo
执行上下文中的环境变量中,删除变量nyName
和test1
的信息,通过引用closure(foo)
对象获取myName
和test1
,如下图。
- 内部函数引用外部变量,这就形成了一个 闭包。所以,此时会在堆内存中创建一个
- 最后当
foo
函数执行完毕,返回innerBar
后,foo
执行上下文销毁,test2
变量也一并被销毁。- 但是在堆内存中的
closure(foo)
依然保存了myName
和test
。 - 返回的
innerBar
对象中,内部属性[[Scopes]]
保存了对Closure(foo)
的引用。可以顺利访问到闭包的内容。
- 但是在堆内存中的
- 首先进入编译阶段,创建一个空执行上下文,然后依次声明
下图可以看到,getName
方法引用了 foo
闭包 CLosure(foo)
。这个引用保存在内部属性 [[Scope]]
中,形成完整的作用域链。
总的来说,产生闭包的核心有两步:
-
第一步是需要预扫描内部函数;
-
第二步是把内部函数引用的外部变量保存到堆中。
内存泄露
如果有大量的变量在业务上已没有实际意义,但却因被引用而无法正确被 GC 回收,最终导致占用内存,这个现象就叫内存泄露。
内存泄露的情况:
- 全局变量。全局变量会一直被引用,无法被 GC 回收。
- 闭包。闭包如果被引用,就无法被 GC 回收。
- 被遗忘的定时器。
setInterval
会周期性的调用回掉函数,有时会忘记对setInterval
进行删除。 - DOM 引用。考虑到性能或代码简洁方面,我们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放。
Js 的垃圾回收机制
垃圾回收的处理方式:
-
手动回收:C/C++ 使用手动回收策略,何时分配内存、何时销毁内存都是由代码手动控制的。
-
自动回收:JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的。
数据是存储在栈和堆两种内存空间中的,分别介绍 栈中的垃圾数据 和 堆中的垃圾数据 是如何回收的:
-
🍊 栈中的垃圾数据:通过 ESP 指针的移动,直接抛弃。
-
🍊 堆中的垃圾数据:垃圾回收器 通过三个步骤:标记对象、回收内存、整理内存,完成回收工作,具体划分为:
- 新生代的回收内存,副垃圾回收器通过 Scavenge 算法,利用 对象/空闲 两个区域来回 翻转 实现垃圾回收和整理。然后通过两次存活则 对象晋升的办法来解决容积问题。
- 老生代的回收内存,负责体积大或存活时间久的对象,主垃圾回收器通过 标记 - 清除(Mark-Sweep)算法 和 标记 - 整理(Mark-Compact)算法 来回收和整理内存。
最后,因为垃圾回收器执行时间过长(尤其是老生代的工作时间长),产生了主线程工作 全停顿(Stop-The-World)现象 等待垃圾回收的问题。
对此,解决方案是利用 增量标记(Incremental Marking)算法 ,把垃圾标记的任务拆分成多个小任务,在 JavaScript 代码执行中穿插完成,减小卡顿问题。
栈内存的数据回收
调用栈是 JS 引擎用来追踪函数调用执行过程的数据结构,遵循 LIFO(后进先出)原则。
- 满足栈的性质:入栈、出栈、栈顶指针。而实际上是编译器优化下的 “栈帧链表 + 栈区模拟结构”。
-
入栈:当遇到一个新的函数调用时,会入栈。新建一个函数执行上下文,然后入栈,向上移动 ESP 指针到这个新的函数上下文上。
-
出栈:当这个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。
- 这个销毁的执行上下文,其依然保存在栈内存中,但已经是无效内存了。
-
入栈:有新的函数需要调用时,这块内容会就会被直接覆盖掉,实现了栈内存中的数据回收。
堆内存的数据回收
要回收堆中的垃圾数据,就需要用到 JavaScript 中的 垃圾回收器 了。
(1)代际假说
代际假说(The Generational Hypothesis)是一个垃圾回收的基础理论:
-
大部分对象在内存中存在的时间很短。简单来说,就是很多对象一经分配内存,很快就变得不可访问(不再使用,没有指针指向这些对象);
-
不死的对象,会活得更久。
(2)分代收集
分代收集是 V8 引擎采用的垃圾回收策略。
在 V8 中会把堆分为 新生代 和 老生代 两个区域。
- 新生代:存放生存 时间短的对象,新生区通常只支持 1~8M 的容量。副垃圾回收器,主要负责新生代的垃圾回收。
- 老生代:存放生存 时间久的对象。老生区支持非常大的容量。主垃圾回收器,主要负责老生代的垃圾回收。
(3)垃圾回收器的工作流程
不论什么类型(主/副)的垃圾回收器,它们都有一套共同的执行流程。
-
第一步:标记对象。标记空间中 活动对象(还在使用的对象) 和 非活动对象(可以进行垃圾回收的对象)。
-
第二步:回收内存。回收非活动对象所占据的 内存,统一清理内存中所有被标记为可回收的对象。
-
第三步:整理内存。频繁回收对象后,内存中就会存在大量不连续空间,称为内存碎片。需要移动内存碎片,空出连续的空间,
- 当内存中出现了大量的内存碎片,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。
副垃圾回收器
-
副垃圾回收器主要负责新生区的垃圾回收。通常情况下,大多数小的对象都会被分配到新生区, 垃圾回收比较频繁。
-
新生代中用 Scavenge 算法 ,把新生代空间对半划分为两个区域:对象区域 + 空闲区域。新加入小体积对象都会存放到对象区域。当对象区域快被写满时,就需要执行一次垃圾清理操作。流程如下:
- 标记对象。标记空间中活动对象和非活动的垃圾对象。
- 回收内存 + 整理内存。把存活的活动对象复制到空闲区域中,在复制的过程中,同时把这些对象进行有序排列。所以回收内存的同时也完成了碎片的整理。
- 角色反转。原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。
- 对象晋升。经过 两次 垃圾回收依然还存活的对象,会被移动到老生区中。
-
探讨:新生区小体积问题
- 体积小原因:复制操作需要时间成本,如果新生区空间设置得太大,会导致每次清理时间过久。为了 执行效率,一般新生区的空间会被设置得比较小。
- 体积小解决:新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略策略。
主垃圾回收器
主垃圾回收器主要负责老生区中的垃圾回收。对象来源:(1)大体积对象会直接被分配到(2)从新生区晋升而来的对象。
- 老生区中的对象有两个特点:占用空间大、存活时间长。
新生代中用 **标记 - 清除(Mark-Sweep)**和 **标记 - 整理(Mark-Compact)**来处理。流程如下:
- 标记对象。挨个遍历调用栈中的全部变量,以每一个变量为根元素,遍历这组根元素。所有遍历过程中,能到达的元素称为 活动对象,没有到达的元素判断为 垃圾数据。
- 回收内存:标记 - 清除(Mark-Sweep)。直接清理掉标记为垃圾数据的对象。
- 整理内存:标记 - 整理(Mark-Compact)。并不是每次都执行整理工作。执行时,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
(4)全停顿(Stop-The-World)和 增量标记(Incremental Marking)
全停顿(Stop-The-World):
-
由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。
-
比如堆中的数据有 1.5GB,V8 实现一次完整的垃圾回收需要 1 秒以上的时间,这也是由于垃圾回收而引起 JavaScript 线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。主垃圾回收器执行一次完整的垃圾回收流程如下图所示:
- 在这 200 毫秒内,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行的,这将会造成页面的卡顿现象。
增量标记(Incremental Marking):
- 这其中,对象标记需要便利整个调用栈和堆内存中的所有对象,非常耗时。
- 为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程, 同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为 增量标记(Incremental Marking)算法。如下图所示:
使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务。这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。
3. 页面循环系统
消息队列、任务循环系统
浏览器页面是由消息队列和事件循环系统来驱动的。
- 每个渲染进程都有一个主线程,要处理 DOM、计算样式、处理布局、 JavaScript 任务以及各种输入事件。
- 要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是 消息队列和事件循环系统。
🍊 事件循环机制
可以想象成线程是一个 for
循环语句,会一直循环执行。在线程运行过程中,一直等待事件触发,比如在等待用户输入的数字,一旦接收到用户输入信息,那么线程就会背激活,然后执行相加运算,最后输出结果。
🍊 消息队列
消息队列 是一个队列数据结构,存放了主线程要执行的任务。符合队列的先进先出特点。
- 渲染进程专门有一个 IO 线程 用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。
- IO线程 往队列尾部添加任务,等待执行;主线程 从队列头部取出任务,并执行。
🍊 进程间通信
不同进程之间无法直接共享内存,需要借助操作系统提供的跨进程通信 IPC
(Inter-Process Communication)
- 利用 IPC,IO线程接收到来自网络进程发来的资源加载完成信息、来自浏览器进程发来的用户鼠标点击信息,然后添加到消息队列中,等待主线程将它们处理。
- 常见方式 1:共享内存。多个进程共享一块无力内存区域 + 信号量同步机制;
- 常见方式 2:消息队列。操作系统内核维护一个消息缓冲区;
系统调用栈
是当循环系统在执行一个任务的时候,都要为这个任务维护一个 系统调用栈。
这个系统调用栈类似于 JavaScript 的调用栈,只不过是 C++ 来维护的,可通过 Performance
来抓取核心的调用信息:
每个任务在执行过程中都有自己的调用栈。
-
这幅图记录了 一个 Parse HTML 的任务执行过程,其中黄色的条目表示执行 JavaScript 的过程,其他颜色的条目表示浏览器内部系统的执行过程。
-
Parse HTML 任务在执行过程中会遇到一系列的子过程:比如在解析页面的过程中遇到了 JavaScript 脚本,那么就暂停解析过程去执行该脚本,等执行完成之后,再恢复解析过程。然后又遇到了样式表,这时候又开始解析样式表……直到整个任务执行完成。
宏任务和微任务
如果一次JS任务执行花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件、页面渲染。
- 宏任务和微任务的加入,使任务执行实现了 效率 和 实时性 的平衡。
宏任务 / 微任务概念
在渲染进程中,把消息队列中的任务称为 宏任务,每个宏任务中都包含了一个 微任务队列。
🍊 (1)宏任务
渲染进程内部会维护多个消息队列,在 Chrome 中主要有两个:延迟执行队列 和 消息队列。
宏任务主要包括了:
- 渲染事件(如解析 DOM、计算布局、绘制);
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
- JavaScript 脚本执行事件;
- 网络请求完成