跳到主要内容

2. 存储机制

JavaScript 是如何存储数据

JavaScript 是动态语言,在声明变量之前不需要确认变量的数据类型。JavaScript 引擎在运行代码的时候自己会计算出变量的类型。

JavaScript 是弱类型语言,它支持隐式类型转换。可以使用同一个变量保存不同类型的数据。

image[30]

  • 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 需要访问该数据的时候,是通过栈中的引用地址来访问的。

image[40]

为什么要把引用数据类型全部存在堆内存中?

  • 因为 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())

当执行这段代码的时候,你应该有过这样的分析:

  1. 由于变量 myNametest1test2 都是原始类型数据,所以在执行 foo 函数的时候,它们会被压入到调用栈中;
  2. 当 JavaScript 引擎执行到 foo 函数时:
    1. 首先进入编译阶段,创建一个空执行上下文,然后依次声明 myNametest1test2innerBar 变量。
    2. 之后进入运行阶段,当执行到对 innerBar 赋值的代码时,发现其内部函数 setNamegetName 引用了外部函数 foo 的变量 nyNametest1
      1. 内部函数引用外部变量,这就形成了一个 闭包。所以,此时会在堆内存中创建一个 closure(foo) 对象,存储 foo 的变量 nyNametest1
      2. 然后,在 foo 执行上下文中的环境变量中,删除变量 nyNametest1的信息,通过引用 closure(foo) 对象获取 myNametest1,如下图。
    3. 最后当 foo 函数执行完毕,返回 innerBar 后,foo 执行上下文销毁,test2 变量也一并被销毁。
      1. 但是在堆内存中的 closure(foo) 依然保存了myNametest
      2. 返回的 innerBar 对象中,内部属性 [[Scopes]] 保存了对 Closure(foo) 的引用。可以顺利访问到闭包的内容。

image[80]

下图可以看到,getName 方法引用了 foo 闭包 CLosure(foo)。这个引用保存在内部属性 [[Scope]] 中,形成完整的作用域链。

image[30]

总的来说,产生闭包的核心有两步:

  • 第一步是需要预扫描内部函数;

  • 第二步是把内部函数引用的外部变量保存到堆中。

内存泄露

如果有大量的变量在业务上已没有实际意义,但却因被引用而无法正确被 GC 回收,最终导致占用内存,这个现象就叫内存泄露。

内存泄露的情况:

  1. 全局变量。全局变量会一直被引用,无法被 GC 回收。
  2. 闭包。闭包如果被引用,就无法被 GC 回收。
  3. 被遗忘的定时器。setInterval 会周期性的调用回掉函数,有时会忘记对 setInterval 进行删除。
  4. DOM 引用。考虑到性能或代码简洁方面,我们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放。

Js 的垃圾回收机制

垃圾回收的处理方式:

  • 手动回收:C/C++ 使用手动回收策略,何时分配内存、何时销毁内存都是由代码手动控制的。

  • 自动回收:JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的。

数据是存储在栈和堆两种内存空间中的,分别介绍 栈中的垃圾数据堆中的垃圾数据 是如何回收的:

  1. 🍊 栈中的垃圾数据:通过 ESP 指针的移动,直接抛弃。

  2. 🍊 堆中的垃圾数据:垃圾回收器 通过三个步骤:标记对象、回收内存、整理内存,完成回收工作,具体划分为:

  • 新生代的回收内存,副垃圾回收器通过 Scavenge 算法,利用 对象/空闲 两个区域来回 翻转 实现垃圾回收和整理。然后通过两次存活则 对象晋升的办法来解决容积问题。
  • 老生代的回收内存,负责体积大或存活时间久的对象,主垃圾回收器通过 标记 - 清除(Mark-Sweep)算法标记 - 整理(Mark-Compact)算法 来回收和整理内存。

最后,因为垃圾回收器执行时间过长(尤其是老生代的工作时间长),产生了主线程工作 全停顿(Stop-The-World)现象 等待垃圾回收的问题。

对此,解决方案是利用 增量标记(Incremental Marking)算法 ,把垃圾标记的任务拆分成多个小任务,在 JavaScript 代码执行中穿插完成,减小卡顿问题。

栈内存的数据回收

调用栈是 JS 引擎用来追踪函数调用执行过程的数据结构,遵循 LIFO(后进先出)原则。

  • 满足栈的性质:入栈、出栈、栈顶指针。而实际上是编译器优化下的 “栈帧链表 + 栈区模拟结构”
  1. 入栈:当遇到一个新的函数调用时,会入栈。新建一个函数执行上下文,然后入栈,向上移动 ESP 指针到这个新的函数上下文上。

  2. 出栈:当这个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

    • 这个销毁的执行上下文,其依然保存在栈内存中,但已经是无效内存了。
  3. 入栈:有新的函数需要调用时,这块内容会就会被直接覆盖掉,实现了栈内存中的数据回收。

堆内存的数据回收

要回收堆中的垃圾数据,就需要用到 JavaScript 中的 垃圾回收器 了。

(1)代际假说

代际假说(The Generational Hypothesis)是一个垃圾回收的基础理论:

  • 大部分对象在内存中存在的时间很短。简单来说,就是很多对象一经分配内存,很快就变得不可访问(不再使用,没有指针指向这些对象);

  • 不死的对象,会活得更久。

(2)分代收集

分代收集是 V8 引擎采用的垃圾回收策略。

在 V8 中会把堆分为 新生代老生代 两个区域。

  • 新生代:存放生存 时间短的对象,新生区通常只支持 1~8M 的容量。副垃圾回收器,主要负责新生代的垃圾回收。
  • 老生代:存放生存 时间久的对象。老生区支持非常大的容量。主垃圾回收器,主要负责老生代的垃圾回收。

(3)垃圾回收器的工作流程

不论什么类型(主/副)的垃圾回收器,它们都有一套共同的执行流程。

  • 第一步:标记对象。标记空间中 活动对象(还在使用的对象) 和 非活动对象(可以进行垃圾回收的对象)。

  • 第二步:回收内存。回收非活动对象所占据的 内存,统一清理内存中所有被标记为可回收的对象。

  • 第三步:整理内存。频繁回收对象后,内存中就会存在大量不连续空间,称为内存碎片。需要移动内存碎片,空出连续的空间,

    • 当内存中出现了大量的内存碎片,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。

副垃圾回收器

  • 副垃圾回收器主要负责新生区的垃圾回收。通常情况下,大多数小的对象都会被分配到新生区,垃圾回收比较频繁。

  • 新生代中用 Scavenge 算法 ,把新生代空间对半划分为两个区域:对象区域 + 空闲区域。新加入小体积对象都会存放到对象区域。当对象区域快被写满时,就需要执行一次垃圾清理操作。流程如下:

    1. 标记对象。标记空间中活动对象和非活动的垃圾对象。
    2. 回收内存 + 整理内存。把存活的活动对象复制到空闲区域中,在复制的过程中,同时把这些对象进行有序排列。所以回收内存的同时也完成了碎片的整理。
    3. 角色反转。原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。
    4. 对象晋升。经过 两次 垃圾回收依然还存活的对象,会被移动到老生区中。
  • 探讨:新生区小体积问题

    1. 体积小原因:复制操作需要时间成本,如果新生区空间设置得太大,会导致每次清理时间过久。为了 执行效率,一般新生区的空间会被设置得比较小
    2. 体积小解决:新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略策略。

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。对象来源:(1)大体积对象会直接被分配到(2)从新生区晋升而来的对象。

  • 老生区中的对象有两个特点:占用空间大、存活时间长。

新生代中用 **标记 - 清除(Mark-Sweep)**和 **标记 - 整理(Mark-Compact)**来处理。流程如下:

  1. 标记对象。挨个遍历调用栈中的全部变量,以每一个变量为根元素,遍历这组根元素。所有遍历过程中,能到达的元素称为 活动对象,没有到达的元素判断为 垃圾数据
  2. 回收内存:标记 - 清除(Mark-Sweep)。直接清理掉标记为垃圾数据的对象。
  3. 整理内存:标记 - 整理(Mark-Compact)。并不是每次都执行整理工作。执行时,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

(4)全停顿(Stop-The-World)和 增量标记(Incremental Marking)

全停顿(Stop-The-World):

  • 由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

  • 比如堆中的数据有 1.5GB,V8 实现一次完整的垃圾回收需要 1 秒以上的时间,这也是由于垃圾回收而引起 JavaScript 线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。主垃圾回收器执行一次完整的垃圾回收流程如下图所示:

image[40]

  • 在这 200 毫秒内,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行的,这将会造成页面的卡顿现象。

增量标记(Incremental Marking):

  • 这其中,对象标记需要便利整个调用栈和堆内存中的所有对象,非常耗时。
  • 为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为 增量标记(Incremental Marking)算法。如下图所示:

image[40]

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务。这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

3. 页面循环系统

image-20220814221037733[60]

消息队列、任务循环系统

浏览器页面是由消息队列和事件循环系统来驱动的。

  • 每个渲染进程都有一个主线程,要处理 DOM、计算样式、处理布局、 JavaScript 任务以及各种输入事件。
  • 要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是 消息队列和事件循环系统

🍊 事件循环机制

可以想象成线程是一个 for 循环语句,会一直循环执行。在线程运行过程中,一直等待事件触发,比如在等待用户输入的数字,一旦接收到用户输入信息,那么线程就会背激活,然后执行相加运算,最后输出结果。

🍊 消息队列

消息队列 是一个队列数据结构,存放了主线程要执行的任务。符合队列的先进先出特点。

  • 渲染进程专门有一个 IO 线程 用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。
  • IO线程 往队列尾部添加任务,等待执行;主线程 从队列头部取出任务,并执行。

🍊 进程间通信

不同进程之间无法直接共享内存,需要借助操作系统提供的跨进程通信 IPC(Inter-Process Communication)

  • 利用 IPC,IO线程接收到来自网络进程发来的资源加载完成信息、来自浏览器进程发来的用户鼠标点击信息,然后添加到消息队列中,等待主线程将它们处理。
  • 常见方式 1:共享内存。多个进程共享一块无力内存区域 + 信号量同步机制;
  • 常见方式 2:消息队列。操作系统内核维护一个消息缓冲区;

系统调用栈

是当循环系统在执行一个任务的时候,都要为这个任务维护一个 系统调用栈

这个系统调用栈类似于 JavaScript 的调用栈,只不过是 C++ 来维护的,可通过 Performance 来抓取核心的调用信息:

image-20220814221114838

每个任务在执行过程中都有自己的调用栈。

  • 这幅图记录了一个 Parse HTML 的任务执行过程,其中黄色的条目表示执行 JavaScript 的过程,其他颜色的条目表示浏览器内部系统的执行过程。

  • Parse HTML 任务在执行过程中会遇到一系列的子过程:比如在解析页面的过程中遇到了 JavaScript 脚本,那么就暂停解析过程去执行该脚本,等执行完成之后,再恢复解析过程。然后又遇到了样式表,这时候又开始解析样式表……直到整个任务执行完成。

宏任务和微任务

如果一次JS任务执行花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件、页面渲染。

  • 宏任务和微任务的加入,使任务执行实现了 效率实时性 的平衡。

宏任务 / 微任务概念

在渲染进程中,把消息队列中的任务称为 宏任务,每个宏任务中都包含了一个 微任务队列

🍊 (1)宏任务

渲染进程内部会维护多个消息队列,在 Chrome 中主要有两个:延迟执行队列消息队列

宏任务主要包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件
  • 网络请求完成文件读写完成事件

典型的触发宏任务的两种 WebAPI:

  • setTimeout:在进程内,将延迟触发的回调函数放入延迟队列中,在每个宏任务执行完毕后遍历延迟队列,寻找到期的任务,并执行。
  • XMLHttpRequest:渲染进程的 IO 线程,通过 IPC 和网络进程沟通,通知网络进程去服务器请求资源。当返回资源后,渲染进程的 IO 线程把返回情况(成功?失败?故障?)封装为一个任务放在任务队列末尾。主线程执行该宏任务时,根据返回状态来调用对应的回调函数。

🍊 (2)微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个 微任务队列

**在当前宏任务执行的过程中,有时候会产生多个微任务,按序保存在微任务队列中。**所以异步使用微任务的一个优势是,同一次事件循环中,这些微任务的上下文环境是一致的。

微任务的产生有两种:

  • 使用 MutationObserver 监控某个 DOM 节点。目的是为了根据节点变化,通过 JavaScript 来修改节点、添加或删除部分子节点。当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • 使用 Promise。当调用 Promise.resolve() 或者 Promise.reject() 的时候,产生微任务。

🍊(3)总结

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。

  • 微任务的执行时长会影响到当前宏任务的时长。

  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

执行流程

在主线程在执行一个宏任务时,

  1. 产生的微任务会被添加到这个宏任务的微任务队列中;
  2. 产生的宏任务会被添加到消息队列的末端;

在执行完这个宏任务时,不是直接退出去执行下一个宏任务,而是:

  1. 查看这个宏任务的微任务队列中,是否有微任务等待执行。
  2. 如果有,则按序执行微任务队列中的任务。
    • 如果在执行微任务过程中产生了新的微任务,则添加到 当前微任务队列 的队尾;

直到当前微任务队列中所有任务被执行完毕,退出当前宏任务,去执行下一个宏任务。

检查点

在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。

  • 如果在执行微任务过程中产生了新的微任务,则添加到 当前微任务队列 的队尾;V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。

image-20220814221126049[30]

image-20220814221132679[30]

上面两个图是在执行一个 ParseHTML 的宏任务。

在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。

  1. 在 JavaScript 执行中,分别通过 PromiseremoveChild 创建了两个微任务;
  2. 这两个微任务按序添加到微任务列表中;
  3. 当 JavaScript 执行结束,准备退出全局执行上下文时,到了 检查点,JavaScript 引擎会检查微任务列表;
  4. JS 引擎发现列表有微任务,,依次执行这两个微任务。
  5. JS 引擎继续检查微任务列表,发现队列清空之后,退出全局执行上下文。

为什么设计宏+微任务

宏任务:解决单个任务执行时间过长

所有的任务都是在 单线程 中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。

  • 如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉。针对这种情况,JavaScript 可以通过 回调 功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。通过回调,

微任务:解决高优先级的任务

在执行宏任务的过程中,如果有高优先级的异步任务需要先处理,则就把这个任务添加到当前宏任务的微任务队列中,这样既不会影响到对宏人物的继续执行(效率),有保证了高优先级任务先被执行(实施性)。

  • 一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。

setTimeOut 是如何实现的?

消息队列中的任务是按序执行的;而通过定时器设置的回调函数需要在指定的时间间隔后被调用,无法直接放置在消息队列中。

  • 为了保证回调函数能在指定时间内执行,Chrome 中还有一个 延迟队列,这个队列中维护了需顺从延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。

定时器的延迟队列

当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

  • 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数、当前发起时间、延迟执行时间。

延迟队列是一个 hashmap 结构,等到执行这个结构的时候,会计算 hashmap 中的每个任务是否到期了,到期了就去执行,直到所有到期的任务都执行结束,才会进入下一轮循环:

当主进程处理完消息队列中的一个任务(完整的宏任务)之后,就开始处理延迟队列中的任务:

  1. 根据 当前时间发起时间 + 延迟时间,计算出到期的任务,
  2. 依次执行这些到期的任务,
  3. 到期任务执行完毕后,继续下一个循环,执行消息队列中下一个任务。

所以,延迟队列中的到期的任务,需要等待当前执行的任务执行完毕才能被执行,并不是一旦到期就立刻被执行。

setTimeOut 的注意事项

  1. 如果主线程中当前任务执行时间过久,导致定时器设置的任务被延后执行

    • 最终导致回调函数执行比设定的预期值要久。
  2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒。

    function cb() { setTimeout(cb, 0); }
    setTimeout(cb, 0);
    • 在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。
  3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒。

    • 目的是为了优化后台页面的加载损耗以及降低耗电量。
  4. 延时执行时间有最大值。

    • Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被 立即执行
  5. 使用 setTimeout 设置的回调函数中的 this 不符合直觉。

什么是 XMLHttpRequest

XMLHttpRequest 提供了从 Web 服务器获取数据的能力。

如果你想要更新某条数据,只需要通过 XMLHttpRequest 请求服务器提供的接口,就可以获取到服务器的数据,然后再操作 DOM 来更新页面内容。

整个过程只需要更新网页的一部分就可以了,而不用像之前那样还得刷新整个页面,这样既有效率又不会打扰到用户。

image-2022081422115751590[40]