关注内存的原因
思考问题
- 为什么要关注内存原因
早期开发中,JavaScript 开发者很少遇到需要对内存精确控制的场景。
提到内存泄漏,早期版本的IE中 JavaScript 与 DOM 交互时发生的问题。如果页面里的内存占用过多,基本等不到进行代码回收,用户已经不耐烦地刷新了当前页面。
随着Node的发展,JavaScript 已经实现 Commonjs 的生态圈大一统的梦想,JavaScript 的应用场景早已不再局限在浏览器中。随着广泛应用,其他语言的问题在 JavaScript 中暴露了出来。
基于无阻塞、事件驱动建立的 Node 服务,具有内存消耗低的优点,非常适合处理海量的网络请求。
在海量的网络请求的前提下,我们就需要考虑一些平常不会形成影响的问题。 在服务端,资源向来就寸土寸金,要为海量用户服务,就得使一切资源高效利用。
V8的垃圾回收机制和内存限制
JavaScript 和 Java一样,都是由垃圾回收机制来进行自动内存管理。
这样的话我们就不用像 C 和 C++ 那样编写代码的过程中,时刻关注内存的分配和释放问题。
在浏览器开发中,很少有人能遇到垃圾回收对程序构成的性能影响。
Node 在服务端使用,对性能敏感的服务器端程序,内存管理的好坏、垃圾回收状况是否优良,都会对服务构成影响,这一切都与 Node 的 JavaScript **执行引擎 V8 **息息相关。
第三次浏览器大战,Chrome 赢了,成功的背后离不开 JavaScript 引擎 V8
作者 Ryan Dahl 选择了JavaScript,选择了 V8,在事件驱动、非阻塞 I/O 模型的设计下实现了 Node。
V8的内存限制
思考问题
- 为什么 V8 要限制堆内存大小呢?
其他语言,基本的内存使用上没有限制。
但是在 Node 中,通过 JS 使用内存时就会发现只能使用部分内存,(64位系统下约为 1.4GB,32系统下约为0.7GB)
这样的限制将导致Node无法直接操作大内存对象。即使电脑有很大的内存,但是在单个Node进程下,计算机的内存无法得到充足的使用。
造成这个问题的主要原因在于 Node 基于 V8 构建,所以在 Node中使用的 JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。V8 的这套内存管理机制在浏览器的应用场景下使用起来绰绰有余,足以胜任前端页面中的所有需求。但在 Node 中,这却限制了开发者随心所欲使用大内存的想法。
实际应用中如果不下心触碰了这个边界,会照成进程退出。所以才要知晓原理,才能避免问题,更好的进行内存管理。
V8的对象分配
所有的 JavaScript 对象都是通过堆来进行分配的。
Node提供了 V8 中内存 使用 process.memoryUsage() 返回当前进程的内存使用情况,但不包括子进程。
子进程的内存使用情况需要在子进程中单独调用 process.memoryUsage()。
- rss: (Resident Set Size) 操作系统分配给进程的总的内存大小,包含所有的 C++ 和 JavaScript代码。
- 使用 Worker 线程时, rss 将会是一个对整个进程有效的值,而其他字段只指向当前线程
- heapTotal 堆的总大小,包括3个部分,
- 已分配的内存,用于对象的创建和存储,对应于 heapUsed
- 未分配的但可用于分配的内存
- 未分配的但不能分配的内存,例如在垃圾收集(GC)之前对象之间的内存碎片
- heapUsed: V8堆内存-已分配的内存
- external: 代表 V8 管理的绑定到 Javascript 对象的 C++ 对象的内存使用情况。
- arrayBuffers: 指分配给 ArrayBuffer 和 SharedArrayBuffer 的内存,包括所有的 Node.js Buffer。
实际业务中,当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中,如果已申请的堆空间内存不够分配新的对象,将继续申请堆内存,直到堆内存大小超过V8的限制为止。
V8 限制堆大小的原因
思考问题: 为什么 V8 要限制堆内存大小呢?
- 表层原因:早期 V8 为浏览器设计,不太可能遇到用大量内存的场景,对于网页来说 V8 的限制值已经够了
- 深层原因:V8 的垃圾回收机制的限制。
解释:
垃圾回收是V8自动执行的,而Node又是单线程,所以在回收的过程中js线程会阻塞。
按官方的说法,以1.5 GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要 50 毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。
这是垃圾回收中引起 JavaScript 线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。
这样的情况不仅仅后端服务无法接受,前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存是一个好的选择。
修改V8内存堆两种方法(修改新生代和老生代的最大内存值)
- 如果遇到 Node 无法分配足够内存给 JavaScript 对象的情况,可以用这个办法来放宽 V8 默认的内存限制,避免在执行过程中稍微多用了一些内存就轻易崩溃。
- 修改新生代和老生代的最大内存值,意味着 V8 使用的内存,不能根据使用情况动态扩充,当分配内存过程中超过极限值时,就会引起进程出错。
node --max-old-space-size=1700 test.js // 单位为MB。设置老生代的最大内存值
// 或者
node --max-new-space-size=1024 test.js // 单位为KB 设置新生代的最大内存值
V8 的垃圾回收机制(不同情况分开处理,分代式垃圾回收机制)
总策略:V8 内存分配情况 + 垃圾回收策略
能了解什么:
- 垃圾回收的主要策略,为什么要这样要使用分代式垃圾回收机制?
- 了解 V8 内存分为新生代和老生代的概念,在 32 位 和 64 位系统的内存大小情况。
- 垃圾回收的主要策略
结论:
- 垃圾回收策略主要基于分代式垃圾回收机制。(我理解的是不同的情况的垃圾进行不同的算法回收)
原因:
- 因为实际应用中,对象的生存周期长短不一,不同的算法只能针对特定情况具有最好的结果,所以利用统计学,按照对象的存活时间将内存的垃圾回收进行不同的分代,然后针对不同分代的内存使用更高效的算法。
- 内存分配情况
在 V8 中,主要将内存分为新生代和老生代两代。
默认情况下,V8 内存在 32 位系统上约是 732MB,在 64 位系统上约是 1464MB 解释了为何在64位系统下只能使用约 1.4 GB内存和在 32位系统下只能使用约 0.7 GB内存。
随着 Node 的发展,V8 堆内存在不同版本默认设置是不一样的。
- 新生代中的对象为存活时间较短的对象(我理解为运行内存)
- 新生代在 32 位系统上说 16MB, 在 64 位系统上是 32MB
- 老生代中的对象为存活时间较长或常驻内存的对象。(我理解为存储内存)
- 老生代在 32 位系统下为 700 MB,在 64 位系统下为 1400 MB
1. 新生代中的对象的垃圾回收算法 -- Scavenge(清除)算法
思考: 垃圾回收机制分代式处理,那么新生代(内存)中的对象如何处理更好呢? 新生代内存中有哪些特点?(生命周期短、内存比较小)
新生代中的对象主要通过 Scavenge 算法进行垃圾回收; 具体主要采用的是 Cheney 算法,作者 cheney(与典型的复制算法递归进行不同,cheney 是迭代的复制进行。典型的复制算法以类似深度优先遍历进行,而 cheney 由广度优先遍历进行。)Cheney的复制算法
Cheney 算法是一种采用复制的方式实现的垃圾回收算法。
将堆内存分为两份,每一步分空间称为 semispace(半空间)。
在这两个空间中,只有一个处于使用中,另外一个处于闲置(以便于未来将存活的对象复制过来)
处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。
当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,将存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To空间的角色发生对换(又称翻转)。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。
只能使用堆内存的一半,这是划分机制所决定的,但是 Scavenge 由于只复制了存活的对象,并且对于生命周期短的场景,存活对象只占少部分,所以它在时间效率上有优异的表现
堆内存 = 新生代(两个 semispace 空间大小) + 老生代占内存大小
缺点:
Scavenge 是典型的用空间换时间的算法,因为需要空闲出一半的内存空间来完成算法,虽然速度很快,但是牺牲了一半的空间,所以只适合在新生代内存空间这种生命周期很短,空间比较小的处理上。无法在大规模地运用到所有的垃圾回收中。所以在新生代内存中正好合适。
总结:
-
- 但是 Scavenge 非常适合应用中新生代中,因为新生代中对象的生命周期短,恰恰适合这个算法。
- 当一个对象,经历多次复制依然存活,它将会被认为生命周期较长的对象,随后会被移入老生代中,采用新的算法进行管理。这个过程叫晋升。
- 什么时候进行对象晋升? 在从 From 空间中存活的对象复制到 To 空间时,需要进行检查,需要将存活周期长的对象移动到老生代中;
- 对象晋升到条件主要有两个:1. 是否经历过 Scavenge 回收,2. To 空间的内存占用比超过限制(25%);
对象晋升 老生代 的条件:
- 在默认情况下,V8 的对象分配主要集中在 From 空间中。对象从 From 空间中复制到 To 空间时,会检查它的内存地址来判断这个对象是否已经经历过一次 Scavenge 回收。如果已经经历过了,会将该对象从 From 空间复制到老生代空间中,如果没有,则复制到 To 空间中。
- To 空间的内存占用比。当要从 From 空间复制一个对象到 To 空间时,如果 To 空间已经使用了超过 25%,则这个对象直接晋升到老生代空间中。
- 设置 25% 限制原因是当这次 Scavenge 回收完成后,To 空间会变成 Form 空间,接下来的内存分配将在这个空间中进行,如果占比过高,会影响后续的内存分配(保证有足够的内存使用)。所以超过了25% 就放入老生代中。
对象晋升完成后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收处理算法。
2. 老生代内存垃圾回收算法 Mark-Sweep(标记清除) & Mark-Compact(标记整理)
先回顾一下上面讲的内容
- 老生代内存的特点:存活的对象多
- Scavenge 算法的特点:将内存一分为二,复制转移存活的对象
显然在老生代中,使用 Scavenge 算法效率是非常低的( 1. 存活的对象多,复制效率降低。2. 浪费一半空间 )。
Mark-Sweep(标记 清除)
分为两个阶段:标记、清除
Mark-Sweep 在标记阶段遍历堆中的所有对象,并标记活着的对象。 在随后的清除阶段中,只清除没有被标记的对象。 可以看出,Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象。
活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。
缺点:
在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题。因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。(我的理解:碎片化空间不连续,碎片空间不满足分配进来的大对象,会触发垃圾回收)
为了解决Mark-Sweep的内存碎片问题, Mark-Compact被提出来。(这样的话就连贯起来了)
Mark-Compact( 标记 整理 )
Mark-Compact 是在 Mark-Sweep 的基础上演变而来的(这两个不仅仅是递进的关系,在 V8 的回收策略中两者是结合使用的)
区别: 对象被标记死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
三种垃圾回收算法对比 :
| 回收算法 | Mark-Sweep | Mark-Compact | Scavenge |
|---|---|---|---|
| 速度 | 中等 | 最慢 | 最快 |
| 空间开销 | 少(有碎片) | 少(无碎片) | 双倍空间(无碎片) |
| 是否移动 | 否 | 是 | 是 |
标记整理需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8 主要使用 Mark-Sweep,在空间不足以对从新生代中晋升的对象进行分配时,才使用 Mark-Compact
3. Incremental Marking( 增量 标记 )
垃圾回收的三种算法执行时都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑。这种行为被称为:“全停顿” ( stop-the-world )
在 V8 的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置较小,且存活对象通常较少,所以影响不大。但是老生代通常配置得较大,且存活对象较多,全堆垃圾回收( full 垃圾回收 )的标记、清理、整理、等活动造成的停顿太大,需要改善。
为了降低全堆垃圾回收带来的停顿时间,V8 先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记( incremental marking ),也就是拆分为许多小“步进”,每做完一“步进”就让 JavaScript 应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。
V8 在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的 1/6 左右。V8 后续还引入了延迟清理( lazy sweeping )与增量式整理( incremental compaction ),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
V8 对内存限制的设置,在 Chrome 浏览器每个选项卡使用一个 V8 实例,绰绰有余、Node 编写服务器端来说,内存限制也并不影响正常场景下使用。
但是对于 V8 的垃圾回收特点和 JavaScript 在单线程上的执行情况,垃圾回收是影响性能的因素之一。
想要高性能的执行效率,尽量注意让垃圾尽量少回收,尤其是全堆垃圾回收。
以 Web 服务器中的会话实现为例,一般通过内存来存储,但在访问量大的时候会导致老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张,甚至溢出。
*本博客仅做个人学习总结归纳,如有写的地方错误还请指出批正,非常感谢!
我在语雀做的文档,标了很多颜色,但是没有显示,[有重要知识颜色标注的](www.yuque.com/yingxiongge… 《Node内存控制》)
参考书籍《深入浅出Node.js》*