自学内容网 自学内容网

Javascript垃圾回收机制-运行机制(大厂内部培训版本)

前言

计算机基本组成:
在这里插入图片描述
我们编写的软件首先读取到内存,用于提供给 CPU 进行运算处理。
内存的读取和释放,决定了程序性能。
冯·诺依曼结构
在这里插入图片描述

解释和编译

这两个概念怎么理解呢。

  • 编译相当于事先已经完成了可以直接用。好比去饭店吃饭点完上菜就可以吃不需要自己做;
  • 解释呢就好比你要吃饭就得煮火锅一边煮一边吃;

JavaScript 属于解释型语言,它需要在代码执行时,将代码编译为机器语言。
在这里插入图片描述
ast(abstract struct tree)抽象语法树
在这里插入图片描述

  • Interpreter 逐行读取代码并立即执行。
  • Compiler 读取您的整个代码,进行一些优化,然后生成优化后的代码。
function add(a, b) {    return a+b}
for(let i = 0; i < 1000; i++) {    add(1 + 1)}

上面的示例循环调用了 add 函数1000次,该函数将两个数字相加并返回总和。

  1. Interpreter 接收上面的代码后,它将逐行读取并立即执行代码,直到循环结束。 它的工作仅仅是实时地将代码转换为我们的计算机可以理解的内容。
  2. 如果这段代码受者是 Compiler,它会先完整地读取整个程序,对我们要执行的代码进行分析,并生成电脑可以读懂的机器语言。过程如同获取 X(我们的JS文件)并生成 Y(机器语言)一样。如果我们使用 Interpreter 执行 Y,则会获得与执行 X 相同的结果。
    在这里插入图片描述
    从上图中可以看出,ByteCode 只是中间码,计算机仍需要对其进行翻译才能执行。 但是 Interpreter 和 Compiler 都将源代码转换为机器语言,它们唯一的区别在于转换的过程不尽相同。
  • Interpreter 逐行将源代码转换为等效的机器代码。
  • Compiler 在一开始就将所有源代码转换为机器代码。

JavaScript 引擎

JavaScript 其实有众多引擎,只不过 v8 是我们最为熟知的。

  • V8 (Google),用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js。
  • JavaScriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari ,2008 年实现了编译器和字节码解释器,升级为了 SquirrelFish。苹果内部代号为“Nitro”的 JavaScript 引擎也是基于 JavaScriptCore 引擎的。
  • Rhino,由 Mozilla 基金会管理,开放源代码,完全以 Java 编写,用于 HTMLUnit
  • SpiderMonkey (Mozilla),第一款 JavaScript 引擎,早期用于 Netscape Navigator,现时用于 Mozilla Firefox。

NodeJs 大体架构:

在这里插入图片描述

谷歌的 Chrome 使用 V8,Safari 使用 JavaScriptCore,Firefox 使用 SpiderMonkey。

简单看一下 V8 的处理过程。
在这里插入图片描述

  1. 始于从网络中获取 JavaScript 代码。
  2. V8 解析源代码并将其转化为抽象语法树(AST)。
  3. 基于该 AST,Ignition 解释器可以开始做它的事情,并产生字节码。
  4. 在这一点上,引擎开始运行代码并收集类型反馈。
  5. 为了使它运行得更快,字节码可以和反馈数据一起被发送到优化编译器。优化编译器在此基础上做出某些假设,然后产生高度优化的机器代码。
  6. 如果在某些时候,其中一个假设被证明是不正确的,优化编译器就会取消优化,并回到解释器中。
traverse(ast, {
  FunctionDeclaration: function(path) {
    path.node.id.name = "x";
  },
  VariableDeclaration: function(path) {
    // 匹配,map 映射
  }
});

垃圾回收

在这里插入图片描述
GC 即 Garbage Collection ,程序工作过程中会产生很多 垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC 就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说,GC 过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的 垃圾回收机制 了
当然也不是所有语言都有 GC,一般的高级语言里面会自带 GC,比如 Java、Python、JavaScript 等,也有无 GC 的语言,比如 C、C++ 等,那这种就需要我们程序员手动管理内存了,相对比较麻烦

上面提到了内容存储的方式,变量会在栈中存储,对象在堆中存储,例如:

let a = {name: 'heyi'};
a = [1, 2, 3, 4, 5];

在这里插入图片描述
我们知道写代码时创建一个基本类型、对象、函数……都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存,那么 JavaScript 引擎是如何发现并清理垃圾的呢?

可达性分析算法 - 引用计数法

相信这个算法大家都很熟悉,也经常听说。
它的策略是跟踪记录每个变量值被使用的次数

  • 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1
  • 如果同一个值又被赋给另一个变量,那么引用数加 1
  • 如果该变量的值被其他的值覆盖了,则引用次数减 1
  • 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存
    在这里插入图片描述
    这个算法最怕的就是循环应用,还有比如 JavaScript 中不恰当的闭包写法。
function test(){
  let A = new Object() +1 +1 = +2 -1 = 1
  let B = new Object() +1 +1 = +2 -1 = 1
  
  A.b = B
  B.a = A
  
  A = null
  B = null
}

优点
引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾
而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了

缺点
引用计数的缺点想必大家也都很明朗了,首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的

标记清除(mark-sweep)算法

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
此算法分为 标记 和 清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁
此算法分为 标记 和 清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁

当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表。
引擎在执行 GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 根 对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象、文档DOM树

整个标记清除算法大致过程就像下面这样

  1. 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
  2. 然后从各个根对象开始遍历,把不是垃圾的节点改成1
  3. 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  4. 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收

优点
标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单

缺点
标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题

在这里插入图片描述
在这里插入图片描述
假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)
在这里插入图片描述
那如何找到合适的块呢?我们可以采取下面三种分配策略

  • First-fit,找到大于等于 size 的块立即返回
  • Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
  • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回
    这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit 和 Best-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择
    综上所述,标记清除算法或者说策略就有两个很明显的缺点
  • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了

而 标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存

内存管理

以上给大家介绍了常用的一些垃圾回收算法,在不同语言中,对于垃圾回收的处理略有差异。
V8 引擎对内存这块做了深度优化,我们接下来详细介绍。

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收

在这里插入图片描述

新生代

在这里插入图片描述
当新加入对象时,它们会被存储在使用区。然而,当使用区快要被写满时,垃圾清理操作就需要执行。在开始垃圾回收之前,新生代垃圾回收器会对使用区中的活动对象进行标记。标记完成后,活动对象将会被复制到空闲区并进行排序。然后,垃圾清理阶段开始,即将非活动对象占用的空间清理掉。最后,进行角色互换,将原来的使用区变成空闲区,将原来的空闲区变成使用区。

如果一个对象经过多次复制后依然存活,那么它将被认为是生命周期较长的对象,且会被移动到老生代中进行管理。除此之外,还有一种情况,如果复制一个对象到空闲区时,空闲区的空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中。25%比例的设置是为了避免影响后续内存分配,因为当按照 Scavenge 算法回收完成后,空闲区将翻转成使用区,继续进行对象内存分配。
这是 V8 引擎存储对象(Object)和动态数据(Dynamic Data)的地方。这也是程序对于内存区域中最大的一块地方,同时垃圾回收( GC )也发生在这里。并不是整个 Heap (堆)内存都进行垃圾回收,只有新空间(New Space)和旧空间(Old Space)由垃圾回收管理。

  • 新空间(New Space):是新对象存活的地方,这些对象的生命周期都很短。这个空间很小,由两个 Semi-Space 组成,类似 JVM 中的 S0 和 S1。
    我们将会在后面的内容看到它。新空间(New Space)的大小是由两个 V8 中的标志位来控制: min_semi_space_size(Initial) 和 max_semi_space_size(Max) 。

  • 旧空间(Old Space):在新空间(New Space)中存活了两个 minor GC 周期的对象,会被迁移到这里。
    这个空间由 Major GC(Mark-Sweep & Mark-Compact) 管理。我们也会在后面内容中看到它。旧空间(Old Space)的大小也是由两个 V8 中的标志位来控制:nitial_old_space_size(Initial) 和 max_old_space_size(Max) 。
    旧空间(Old Space)被分成两个部分:

    • 旧指针空间(Old pointer space):这些存活下来的的对象都包含了指向其他对象的指针。
    • 旧数据空间(Old data space):这些对象只包含数据,没有指向其他对象的指针。在新空间(New Space)中存活两个 minor GC 周期后,String,已经装箱的数字,未装箱的双精度数组会被迁移到这里。
  • 大型对象空间(Large object space):大于其他空间大小限制的对象存放在这里。每个对象都有自己的内存区域,这里的对象不会被垃圾回收器移动。

  • 代码空间(Code-space):这是即时编译器(JIT)存储已经编译的代码块的地方。这是唯一可执行内存的空间(尽管代码可能被分配到大型对象空间(Large object space),那也是可以执行的)。

  • 单元空间(Cell Space),属性单元空间(Property Cell Space)和映射空间(Map Space):这些空间分别存放 Cell,PropertyCell 和 Map。这些空间包含的对象大小相同,并且对对象类型有些限制,可以简化回收工作。
    每个空间(除了大型对象空间(Large object space))都由一组 Page 组成。一个 page 是由操作系统分配的一个连续内存块,大小为 1MB。

老生代

不同于新生代,老生代中存储的内容是相对使用频繁并且短时间无需清理回收的内容。这部分我们可以使用标记整理进行处理。
从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象
清除阶段老生代垃圾回收器会直接将非活动对象进行清除。

并行回收

为了减少主线程阻塞,我们在进行 GC 处理时,使用辅助进程。
在这里插入图片描述

全停顿标记

这个概念看字眼好像不好理解,其实如果用前端开发的术语来解释,就是阻塞。
虽然我们的 GC 操作被放到了主进程与子进程中去处理,但最终的结果还是主进程被较长时间占用。

切片

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记(如下图)在这里插入图片描述

三色标记

三色:白灰黑。相信大家都知道灰色地带这个词吧?😄
我们这里的会,表示的是一个中间状态,为什么会有这个中间状态呢?

  • 白色指的是未被标记的对象
  • 灰色指自身被标记,成员变量(该对象的引用对象)未被标记
  • 黑色指自身和成员变量皆被标记
    在这里插入图片描述

写屏障(增量中修改引用)

这一机制用于处理在增量标记进行时修改引用的处理,可自行修改为灰色
在这里插入图片描述

惰性清理

增量标记只是用于标记活动对象和非活动对象,真正的清理释放内存,则 V8 采用的是惰性清理(Lazy Sweeping)方案。
在增量标记完成后,进行清理。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕。

并发回收

还记得 react 中的 Concurrent 吗?
我们想想 React 演进过程,是不是就会觉得从并行到并发的演进变得很合了呢?
并发挥收其实是更进一步的切片,几乎完全不阻塞主进程。
在这里插入图片描述

总结

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率

怎么理解内存泄漏

怎么解决内存泄漏,代码层面如何优化?

减少查找

var i, str = ""
function packageDomGlobal() {
    for(i = 0; i < 1000; i++) {
        str += i
    }
}

// 第二种情况。我们采用局部变量来保存相关数据
function packageDomLocal() {
    let str = ''
    for(let i = 0; i < 1000; i++) {
        str += i
    }
}

减少变量声明

// 第一种情况,循环体中没有抽离出值不变的数据
var test = () => {
  let arr = ['czs', 25, 'I love FrontEnd'];
  for(let i = 0; i < arr.length; i++){
      console.log(arr[i]);
  }
}

// 第二种情况,循环体中抽离出值不变的数据
var test = () => {
  let arr = ['czs', 25, 'I love FrontEnd'];
  const length = arr.length;
  for(let i = 0; i < length; i++){
      console.log(arr[i]);
  }
}
  1. 使用 Performance + Memory 分析内存与性能
  2. setTimeout、setInterval 需要及时清除
  3. 事件绑定的清除

运行机制

浏览器主进程

  • 协调控制其他子进程(创建、销毁)
  • 浏览器界面显示,用户交互,前进、后退、收藏
  • 将渲染进程得到的内存中的Bitmap,绘制到用户界面上
  • 存储功能等
    第三方插件进程
  • 每种类型的插件对应一个进程,仅当使用该插件时才创建
    GPU进程
  • 用于3D绘制等
    渲染进程,就是我们说的浏览器内核
  • 排版引擎Blink和JavaScript引擎V8都是运行在该进程中,将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,
  • 负责页面渲染,脚本执行,事件处理等
  • 每个tab页一个渲染进程
  • 出于安全考虑,渲染进程都是运行在沙箱模式下
    网络进程
  • 负责页面的网络资源加载,之前作为一个模块运行在浏览器主进程里面,最近才独立成为一个单独的进程

浏览器事件循环

在这里插入图片描述

宏任务

可以将每次执行栈执行的代码当做是一个宏任务

  • I/O
  • setTimeout
  • setInterval
  • setImmediate
  • requestAnimationFrame

微任务

当宏任务执行完,会在渲染前,将执行期间所产生的所有微任务都执行完

  • process.nextTick
  • MutationObserver
  • Promise.then catch finally

整体流程

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

浏览器事件循环

console.log(1);

queueMicrotask(() => {console.log(2)});

Promise.resolve().then(() => console.log(3));

setTimeout(() => {console.log(4)})

问:上面的打印顺序是怎么样的???
首先,任务,js 主进程的内容先执行,js 常规的代码 1, 2 为微任务,3 微任务,4 宏任务

  1. 执行同步代码。
  2. 执行一个宏任务(执行栈中没有就从任务队列中获取)。
  3. 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中。
  4. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)。
  5. 当前宏任务执行完毕,开始检查渲染,然后渲染线程接管进行渲染。
  6. 渲染完毕后,JavaScript 线程继续接管,开始下一个循环。
    宏任务-微任务-渲染(如果需要渲染,比如到了时间,或有更改 dom),而不是又执行宏任务

在这里插入图片描述
call stack vs task queue

console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

// 结果
/*
1 7 3 5 2 6 4
*/

在这里插入图片描述
再来一道:

Promise.resolve().then(() => {
    // 微任务1
    console.log('Promise1')
    setTimeout(() => {
        // 宏任务2
        console.log('setTimeout2')
    }, 0)
})
setTimeout(() => {
    // 宏任务1
    console.log('setTimeout1')
    Promise.resolve().then(() => {
        // 微任务2
        console.log('Promise2')
    })
}, 0)

// p1 s1 p2 s2

具体执行流程分析:
主线程执行:

  • 首先,Promise.resolve() 立即解决,然后调用 .then() 方法,将回调函数(微任务1)放入微任务队列。
  • 接着,执行 setTimeout(宏任务1),将回调函数放入宏任务队列,设置延迟时间为 0。
  • 主线程执行完毕后,开始执行微任务队列中的微任务1:
  • 执行 .then() 方法的回调函数,输出 Promise1。
  • 微任务1执行完毕后,主线程再次清空,此时宏任务队列中有两个任务:宏任务1和宏任务2:
  • 宏任务队列是先进先出(FIFO)的,所以首先执行宏任务1。
  • 在宏任务1中,输出 setTimeout1,然后立即将一个新的 - -Promise.resolve().then() 的回调函数(微任务2)放入微任务队列。
  • 宏任务1执行完毕后,主线程再次清空,此时微任务队列中有一个任务:微任务2:
  • 执行微任务2,输出 Promise2。
  • 微任务2执行完毕后,主线程再次清空,此时宏任务队列中只剩下宏任务2:
  • 执行宏任务2,输出 setTimeout2。

来一道更复杂些的题目

console.log('stack [1]');
setTimeout(() => console.log("macro [2]"), 0);
setTimeout(() => console.log("macro [3]"), 1);

const p = Promise.resolve();
for(let i = 0; i < 3; i++) p.then(() => {
    setTimeout(() => {
        console.log('stack [4]')
        setTimeout(() => console.log("macro [5]"), 0);
        p.then(() => console.log('micro [6]'));
    }, 0);
    console.log("stack [7]");
});

console.log("macro [8]");

// 请说出答案
/* Result:
stack [1]
macro [8]

stack [7], stack [7], stack [7]

macro [2]
macro [3]

stack [4]
micro [6]
stack [4]
micro [6]
stack [4]
micro [6]

macro [5], macro [5], macro [5]
--------------------
but in node in versions < 11 (older versions) you will get something different


stack [1]
macro [8]

stack [7], stack [7], stack [7]

macro [2]
macro [3]

stack [4], stack [4], stack [4]
micro [6], micro [6], micro [6]

macro [5], macro [5], macro [5]

more info: https://blog.insiderattack.net/new-changes-to-timers-and-microtasks-from-node-v11-0-0-and-above-68d112743eb3
*/
**const $inner = document.querySelector('#inner')
const $outer = document.querySelector('#outer')

function handler () {
  console.log('click') // 直接输出

  Promise.resolve().then(_ => console.log('promise')) // 注册微任务

  setTimeout(() => console.log('timeout')) // 注册宏任务

  requestAnimationFrame(_ => console.log('animationFrame')) // 注册宏任务

  $outer.setAttribute('data-random', Math.random()) // DOM属性修改,触发微任务
}

new MutationObserver(_ => {
  console.log('observer')
}).observe($outer, {
  attributes: true
})

$inner.addEventListener('click', handler)
$outer.addEventListener('click', handler)**

以上执行的顺序:click -> promise -> observer -> click -> promise -> observer -> animationFrame -> animationFrame -> timeout -> timeout

Node事件循环机制

在这里插入图片描述

伪代码运行

while (true){
    // 1. Get one macrotask (oldest) task item
    task = macroTaskQueue.pop(); 
    execute(task);

   // 2. Go and execute microtasks while they have items in their queue (including those which were added during this iteration)while (microtaskQueue.hasTasks()){
        const microTask = microtaskQueue.pop();
        execute(microTask);
    }

    // 3. If 16ms have elapsed since last time this condition was trueif (isPaintTime()){
    // 4. Go and execute animationTasks while they have items in their queue (not including those which were added during this iteration) const animationTasks = animationQueue.getTasks();
        for (task in animationTasks){
            execute(task);
        }

        repaint(); // render the page changes (via the render pipeline)
    }
}

原文地址:https://blog.csdn.net/qq_37779333/article/details/143824991

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!