微信公众号:[前端一锅煮]
一点技术、一点思考。
- 栈空间
- 堆空间
- 新生代内存回收
- 老生代内存回收
- 标记清除、标记整理、增量标记
JavaScript 引擎的内存空间主要分为栈和堆。
V8 的垃圾回收策略主要基于分代式垃圾回收机制。按照对象的存活时间将内存的垃圾回收进行不同分代,然后分别对不同分代的内存使用最适合的算法。主要分为新生代和老生代,有标记清除、标记整理、增量标记等方法。
栈空间
栈是临时存储空间,主要存储局部变量和函数调用。
基本类型赋值(Number, Boolean, String, Null, Undefined, Symbol, BigInt),系统会为新的变量在栈内存中分配一个新值。
引用类型赋值,系统会为新的变量在栈内存中分配一个值,这个值仅仅是指向同一个对象的引用,和原对象指向的都是堆内存中的同一个对象。
对于函数,解释器创建了”调用栈“来记录函数的调用过程。每调用一个函数,解释器就把该函数添加进调用栈,解释器会为被添加进来的函数创建一个栈帧(用来保存函数的局部变量以及执行语句)并立即执行。如果正在执行的函数还调用了其他函数,新函数会继续被添加进入调用栈。函数执行完成,对应的栈帧立即被销毁。
两种查看调用栈的方法
使用 console.trace() 向 web 控制台输出一个堆栈跟踪。
浏览器开发者工具进行断点调试。
栈溢出
栈虽然很轻量,在使用时创建,使用结束后销毁,但是不是可以无限增长的,被分配的调用栈空间被占满时,就会引起”栈溢出“的错误。
(function foo() {
foo()
})()
Maximum call stack size exceeded.
为什么基本数据类型存储在栈中,引用数据类型存储在堆中?
JavaScript 引擎需要用栈来维护程序执行期间的上下文的状态,如果栈空间大了的话,所有数据都存放在栈空间里面,会影响到上下文切换的效率,进而影响整个程序的执行效率。
堆空间
堆空间存储的数据比较复杂,大致可以划分为 5 个区域:
- 新生代内存区(new space):新生代内存区会被划分为两块,分别是 from space 和 to space(具体有什么用下文会说),64位系统下默认 32MB,32位系统下默认 16MB,通常新创建的对象会先放入 from 中。
- 老生代内存区(old space):较为持久的保存对象,分为两个区域 old pointer space 和 old data space 分别用来存放 GC 后还存活的指针信息和数据信息,64位系统下能使用约 1.4GB,32位系统下能使用约 0.7GB。
- 大对象区(large object space):这里存放体积超越其他区大小的对象,主要为了避免大对象的拷贝,使用该空间专门存储大对象。
- 单元区、属性单元区、Map区(Cell space、property cell space、map space):Map 空间存放对象的 Map 信息也就是隐藏类(Hiden Class)最大限制为 8MB;每个 Map 对象固定大小,为了快速定位,所以将该空间单独出来。
- 代码区 (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
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。