微信公众号:[前端一锅煮]
一点技术、一点思考。
  • 栈空间
  • 堆空间
  • 新生代内存回收
  • 老生代内存回收
  • 标记清除、标记整理、增量标记

JavaScript 引擎的内存空间主要分为栈和堆。

V8 的垃圾回收策略主要基于分代式垃圾回收机制。按照对象的存活时间将内存的垃圾回收进行不同分代,然后分别对不同分代的内存使用最适合的算法。主要分为新生代和老生代,有标记清除、标记整理、增量标记等方法。

栈空间

栈是临时存储空间,主要存储局部变量和函数调用。

基本类型赋值(Number, Boolean, String, Null, Undefined, Symbol, BigInt),系统会为新的变量在栈内存中分配一个新值。

引用类型赋值,系统会为新的变量在栈内存中分配一个值,这个值仅仅是指向同一个对象的引用,和原对象指向的都是堆内存中的同一个对象。

对于函数,解释器创建了”调用栈“来记录函数的调用过程。每调用一个函数,解释器就把该函数添加进调用栈,解释器会为被添加进来的函数创建一个栈帧(用来保存函数的局部变量以及执行语句)并立即执行。如果正在执行的函数还调用了其他函数,新函数会继续被添加进入调用栈。函数执行完成,对应的栈帧立即被销毁。

两种查看调用栈的方法

使用 console.trace() 向 web 控制台输出一个堆栈跟踪。

浏览器开发者工具进行断点调试。

栈溢出

栈虽然很轻量,在使用时创建,使用结束后销毁,但是不是可以无限增长的,被分配的调用栈空间被占满时,就会引起”栈溢出“的错误。

(function foo() {
  foo()
})()

Maximum call stack size exceeded.

为什么基本数据类型存储在栈中,引用数据类型存储在堆中?

JavaScript 引擎需要用栈来维护程序执行期间的上下文的状态,如果栈空间大了的话,所有数据都存放在栈空间里面,会影响到上下文切换的效率,进而影响整个程序的执行效率。

堆空间

堆空间存储的数据比较复杂,大致可以划分为 5 个区域:

  1. 新生代内存区(new space):新生代内存区会被划分为两块,分别是 from space 和 to space(具体有什么用下文会说),64位系统下默认 32MB,32位系统下默认 16MB,通常新创建的对象会先放入 from 中。
  2. 老生代内存区(old space):较为持久的保存对象,分为两个区域 old pointer space 和 old data space 分别用来存放 GC 后还存活的指针信息和数据信息,64位系统下能使用约 1.4GB,32位系统下能使用约 0.7GB。
  3. 大对象区(large object space):这里存放体积超越其他区大小的对象,主要为了避免大对象的拷贝,使用该空间专门存储大对象。
  4. 单元区、属性单元区、Map区(Cell space、property cell space、map space):Map 空间存放对象的 Map 信息也就是隐藏类(Hiden Class)最大限制为 8MB;每个 Map 对象固定大小,为了快速定位,所以将该空间单独出来。
  5. 代码区 (code Space):主要存放代码对象,最大限制为 512MB,也是唯一拥有执行权限的内存。

新生代内存是临时分配的内存,存活时间短,老生代内存是常驻内存,存活时间长。

新生代内存回收

新生代内存中的垃圾回收主要通过 Scavenge 算法进行,具体实现时主要采用 Cheney 算法。

Cheney 将内存空间一分为二,一块叫做 From 正在使用的内存,另一块叫做 To 目前闲置的内存。

Scavenge GC算法:

  • 存活的对象从 from space 转移到 to space
  • 清空 from space
  • from space 与 to space 互换
  • 完成一次新生代 GC

简而言之,在垃圾回收的过程中,将存活对象在两个空间之间进行复制。

Scavenge 是典型的牺牲空间换取时间的算法,缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。

老生代内存回收

V8 在老生代中主要采 用了 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收。

晋升

当一个对象经过多次复制依然存在时,它将会被认为是生命周期较长的对象,这种对象会被移到老生代中,采用新的算法进行管理,这种移动称之为“晋级”。

对象晋级的条件主要有两个:

已经经历过一次 Scavenge 回收

To(闲置内存)空间的内存不足75%

标记清除(Mark-Sweep)

标记清除,分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象。

标记清除最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。为了解决标记清除的内存碎片问题,标记整理(Mark-Compact)被提出来。

标记整理(Mark-Compact)

Mark-Compact 是标记整理的意思,在 Mark-Sweep 的基础上演变而来。

标记整理对待未存活对象不是立即回收,而是将存活对象移动到一边,然后直接清掉端边界以外的内存。

增量标记

为了避免出现 JavaScript 应用程序与垃圾回收器看到的不一致的情况,进行垃圾回收的时候,都需要将正在运行的程序停下来,等待垃圾回收执行完成之后再回复程序的执行,这种现象称为“全停顿”。如果需要回收的数据过多,那么全停顿的时候就会比较长,会影响其他程序的正常执行。

为了避免垃圾回收时间过长影响其他程序的执行,V8将标记过程分成一个个小的子标记过程,同时让垃圾回收和JavaScript应用逻辑代码交替执行,直到标记阶段完成。我们称这个过程为增量标记算法。

通俗理解,就是将原本一口气完成的标记任务分为了很多小的部分去完成, 每完成一个小任务就停一会, 让 js 逻辑执行一会, 然后再继续执行下面的部分。

总结

从 V8 垃圾回收机制可以看到,垃圾回收是一件非常耗时的事情, 以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1s 以上,所以要做限制。

新生代设计为一个较小的内存空间是合理的,而老生代空间过大对于垃圾回收并无特别意义。V8 对内存限制的设置对于 Chrome 浏览器这种每个选项卡页面使用一个 V8 实例而言,内存的使用是绰绰有余了。对于 Node 编写的服务器端来说,内存限制也并不影响正常场景下的使用。但是对于 V8 的垃圾回收特点和js 在单线程上的执行情况,垃圾回收是影响性能的因素之一。想要高性能的执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。

其他

vue-cli 打包内存溢出,修改内存限制

node_modules/.bin

vue-cli-service

#!/usr/bin/env node --max_old_space_size=4096

调整老生代内存限制,单位mb

node --max-old-space-size=2048 build/build.js

调整新生代内存限制,单位kb

node --max-new-space-size=2048 build/build.js


前端一锅煮
852 声望31 粉丝

积极阳光前端一枚,爱学习,爱分享,全栈进行中~