在过去很长一段时间内,JavaScript开发者很少遇到需要对内存进行精确控制的场景,也缺乏控制的手段,说到内存泄漏,大家可能首先想到早期浏览器中的卡顿问题,如果内存占用过多,基本等不到代码进行垃圾回收,用户已经开始不耐烦的刷新网页了。

随着node的发展,JavaScript的应用场景早已不再局限在浏览器中,在浏览器中那些短时间执行的场景中,由于运行时间短,而且运行在用户的机器中,随着进程的退出,内存会释放,几乎没有内存管理的必要。但是,随着node在服务端的广泛应用,在其它语言里存在的问题在JavaScript中也逐渐暴露出来了。

我们在学习JavaScript的时候听说过垃圾回收机制,JavaScript就是由垃圾回收机制来进行自动内存管理的,这使得开发者在编写JavaScript的时候,不需要像其它语言那样时刻关注内存分配和释放的问题。只在浏览器中进行开发时,几乎很少有人遇到因为垃圾回收对项目构成性能影响的情况,Node极大的拓宽了JavaScript的应用场景,当应用场景从浏览器延伸到各种场景中时,我们就能发现,内存管理的好坏、垃圾回收状态的优良与否至关重要,而不管是在浏览器环境、还是node环境中,这一切都与V8引擎息息相关。

1. V8的内存限制

在一般的后端语言中,在基本的内存使用上是没有限制的,但是在node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统约为1.4G,32位系统约为0.7G),在这样的限制下,node无法直接将一个大文件读入内存进行处理,即使电脑的物理内存有16G,在单个node进程的情况下,内存也无法得到充足的使用。

造成这个问题的原因在于V8引擎,所以node中使用JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。这套管理机制在浏览器中使用起来绰绰有余,足以胜任前端页面中的所有需求,但是在node环境中,却限制了开发者对大内存文件的分析和处理。

尽管在服务端操作大内存也不是常见的需求场景,但有了限制之后,我们的行为就如同带着镣铐跳舞,如果在实际应用中不小心触碰到这个界限,会造成进程退出,如果是在浏览器环境中,会导致浏览器白屏、或者卡死。只有在知晓其原理后,才能避免问题,更好的进行内存管理。(这段话摘自《Node深入浅出》,个人认为写的很好)

2. V8的对象分配

在V8中,JavaScript对象是通过堆内存来进行分配的。Node提供了内存使用量的查看方式,在node环境中输入以下代码:

console.log(process.memoryUsage()); 复制代码

执行以上代码,将会得到输出的内存的使用信息(单位是字节):

1111111.jpg

在memoryUsage方法返回的参数中:

  • rss是resident set size的缩写,是进程的常驻内存

  • heapTotal是已经申请到的堆内存

  • heapUsed是当前使用的量

  • external代表绑定到Javascript对象的 C++ 对象的内存使用情况

当我们在代码中申明变量并且赋值时,所使用的对象的内存就分配在堆中,如果已申请的堆的空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8引擎的限制为止。

那么问题来了,V8为何要限制堆的大小?

  • 表层原因是JavaScript最初只运行在浏览器环境,几乎不会遇到大量使用内存的场景,所以对于网页来说,V8的限制已经绰绰有余

  • 深层原因是V8的垃圾回收机制。按官方的说法,以1.5G的垃圾回收的堆内存为例,V8做一次小的垃圾回收需求50ms以上,而做一次非增量式回收甚至需要1s以上,可见其耗时之久,而在这1s的时间内,应用的性能和响应时间会大大下降,这样的情况不仅后端无法接受,前端也无法接受,更重要的是,用户也无法接受。因此在当时的情况下,直接限制堆内存是一个好的选择。

当然了,这个限制并不是死的,V8为我们提供了方法,可以手动打开限制,从而让我们使用更多的内存:

在命令行中输入以下代码:node --v8-options,然后我们会在命令行窗口中看到V8的选项,这里我们可以看到下面几个选项:

222.jpg

在Node启动时,我们可以传递--max-old-space-size或者--max-new-space-size来调整内存限制大小,比如:

  • node --min-semi-space-size=1024 index.js 设置新生代内存中单个半空间的内存最小值,单位MB

  • node --max-semi-space-size=1024 index.js 设置新生代内存中单个半空间的内存最大值,单位MB

  • node --max-old-space-size=2048 index.js 设置老生代内存最大值,单位MB

上述参数在环境初始化时生效,一旦生效,就不能动态改变,只能手动调整,如果遇到内存不够的情况,可以用这个方法手动放宽限制,从而避免由内存问题引起的网页白屏或者奔溃,接下来让我们了解一下垃圾回收方面的策略,在限制的前提下,带着镣铐跳出的舞蹈并不一定就难看。(这段话摘自《Node深入浅出》,个人认为写的很好)

3. V8的垃圾回收机制

在展开介绍垃圾回收机制之前,有必要简略介绍下V8用到的各种回收算法

3.1 垃圾回收算法

V8的垃圾回收算法主要基于分代式垃圾回收机制,在早期的垃圾回收中,人们发现没有一种算法能够胜任所有的场景,因为在实际应用中,对象的生存周期长短不一,不同的算法只能针对特定的情况发挥作用。因此,在现代的垃圾回收算法中,根据对象的存活时间将垃圾回收进行了不同分代,主要分为新生代和老生代,然后分别对不同分代的内存使用不同的算法。

3.1.1 V8的内存分代

在V8中,主要将内存分为新生代老生代两种,新生代中的对象存活时间较短,老生代中的对象存活时间较长(或常驻内存中),如下图所示:

新生代内存空间老生代内存空间

V8堆的整体大小就是新生代内存空间加上老生代内存空间,前面我们提到的两个命令行就可以用于设置这个空间的最大值,需要注意的是,这个最大值需要在启动的时候就指定,因此,V8使用的内存无法根据情况自动扩充,当内存分配过程中超过极限值的时候,就会引起进程出错,页面卡死,白屏。

3.1.2 新生代(Scavenge算法)

在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收,具体实现中采用的是Cheney算法,这是一种采用复制的方式实现的垃圾回收算法,具体过程是:

  1. 先将堆内存一分为二,每个内存空间称为semispace(半空间)

  2. 在这两个semispace中,只有一个处于使用中,另一个处于闲置中

  3. 处于使用状态的空间称为From空间,处于闲置状态的空间称为To空间

  4. 当我们分配对象时,先是在From空间中进行分配

  5. 开始进行垃圾回收时,会检查From空间中存活的对象

  6. 存活的被复制到To空间中,非存活对象占用的空间被释放

  7. 完成复制后,From空间和To空间的角色发生对换

简而言之,在新生代垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制,分代回收堆内存如下图所示:

新生代内存空间老生代内存空间
semispace(From)semispace(To)

流程图如下:

  • 假设我们在From空间中分配了三个对象A、B、C aaa.jpg

  • 当程序主线程任务第一次执行完毕后进入垃圾回收时,发现对象A已经没有其他引用,则表示可以对其进行回收 bbb.jpg

  • 对象B和对象C此时依旧处于活跃状态,因此会被复制到To空间中进行保存 ccc.jpg

  • 接下来将From空间中的所有非存活对象全部清除 ddd.jpg

  • 此时From空间中的内存已经清空,开始和To空间完成一次角色互换 eee.jpg

  • 当程序主线程在执行第二个任务时,在From空间中分配了一个新对象D fff.jpg

  • 任务执行完毕后再次进入垃圾回收,发现对象D已经没有其他引用,表示可以对其进行回收 ggg.jpg

  • 对象B和对象C此时依旧处于活跃状态,再次被复制到To空间中进行保存 hhh.jpg

  • 再次将From空间中的所有非存活对象全部清除 lll.jpg

  • 最后,From空间To空间继续完成一次角色对换 iii.jpg 可以看到,它的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的,所以无法应用到所有的场景中,但是由于这个算法只复制存活对象,并且对于某些场景,存活对象只占少部分,所以它在时间效率上有优异的表现。所以说,Scavenge是典型的牺牲空间换取时间的算法。

需要注意的是:

  • 实际使用的堆内存是新生代中的两个semispace空间的大小和老生代所用内存大小的和。

  • 当一个对象经过多次复制仍然存活时,它将会被认为是生命周期较长的对象,这种对象随后会被移动到老生代堆内存中,采用新的算法进行处理,从新生代移动到老生代的过程被称为对象晋升

3.1.3 对象晋升(新 => 老)

在单纯的Scavenge算法中,From空间中的对象会被复制到To空间中去,然后对两个空间进行角色对换(又称翻转)。但是在分代式垃圾回收的前提下,From空间中的对象在复制到To空间时会进行检查。在一定条件下,将存活时间上的对象移动到老生代中,也就是完成对象晋升。

需要注意的是,满足对象晋升的条件主要有以下两个:

  • 对象是否经历过一次Scavenge算法

  • To空间的内存占比是否已经超过25%

这个晋升流程可以用以下的流程图来表示:

From

经历过Scavenge

未经历Scavenge

To

To内存>25%

To内存<25%

移动到老生代

对象成功晋升后,将会在老生代内存空间中作为存活时间较长的对象来对待,通过新的回收算法处理。

3.1.4 老生代(标记清除 & 标记整理)

在老生代中,因为有大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)Mark-Compact(标记整理)来进行管理。

Mark-Sweep(标记清除)分为标记清除两个阶段,具体步骤如下:

  1. 在标记阶段遍历堆中的所有对象

  2. 然后标记活着的对象

  3. 在清除阶段中,将未标记的对象进行清除

Mark-Sweep最大的问题是在进行一次标记回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,为了解决这个内存碎片问题,Mark-Compact(标记整理)被提出来,这个算法是在Mark-Sweep(标记清除)的基础上演变来的,它们的差别在于对象在标记为死亡后,在整理的过程中,将存活对象向一端移动,移动完成后,直接清除掉边界外的内存。

Mark-Compact(标记整理)分为标记清除整理三个阶段,具体步骤如下:

  1. 在标记阶段遍历堆中的所有对象

  2. 然后标记活着的对象

  3. 在清除阶段中,将未标记的对象进行清除

  4. 对内存空间进行整理,将存活对象向一端移动

  5. 移动完成后,直接清除掉边界外的内存

流程图如下:

  • 假设在老生代中有A、B、C、D、E、F、G、H八个对象: aaa.jpg

  • 在垃圾回收的标记阶段,将B、D、F、H标记为活动的: bbb.jpg

  • 在一轮清除之后,此时的内存空间变的不连续: ccc.jpg

  • 在垃圾回收的整理阶段,将活动的对象往堆内存的一端移动: eee.jpg

  • 最后,在垃圾回收的清除阶段,将活动对象右侧的内存全部回收: dddd.jpg Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收。另外可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象,存活对象在新生代内存里只占小部分,死亡对象在老生代内存里只占小部分,这也就是两种回收方式能高效处理的原因。

至此就完成了一次老生代垃圾回收的全部过程。下面的表格是三种垃圾回收算法的简单对比:

回收算法Mark-SweepMark-CompactScavenge
速度中等最慢最快
空间少(有碎片)少(无碎片)双倍空间(无碎片)
是否移动对象

从上表中可以看到,标记清除不需要移动对象,其它两种算法需要移动对象,因此这两种算法的执行速度不如标记清除,所以在取舍上,V8主要使用Mark-Sweep(标记清除),在空间不足以对晋升的对象进行分配时才使用Mark-Compact(标记整理)

3.1.5 垃圾回收小结

从V8垃圾回收机制的角度可以看出,新生代设计为一个较小的内存空间是合理的,而老生代空间过大,对于垃圾回收并无特别意义。V8对内存的限制对于浏览器页面而言,内存使用是绰绰有余了;而对于后端服务器来说,也能满足大多数场景了,并不会影响正常场景下的使用。但是对于垃圾回收的特点和JavaScript单线程的执行情况,垃圾回收是影响性能的因素之一,想要提高应用的性能,还是需要注意让垃圾回收尽量少的执行。

3.2 避免内存泄漏

3.2.1 少用闭包

我们知道,作用域链上的对象访问只能向上,这样外部无法向内部访问,在JavaScript中,实现外部作用域访问内部作用域中的变量的方法就叫做闭包,这得益于高阶函数的特性:函数可以作为参数或者返回值

function foo() { function bar() { var local = "局部变量"; return function() { return local; }; }; var baz = bar(); console.log(baz()); }; 

一般而言,在bar()函数执行完成之后,局部变量local会随着作用域的销毁而被回收,但是这里的特点是,返回了一个匿名函数,而且这个匿名函数具备了访问local的条件,如果要在外部访问local,只需要通过这个中间函数稍作周转即可。

闭包是JavaScript的高级特性,可以利用它产生很多奇妙的效果,但是它的问题在于,一旦有变量引用了这个中间函数,这个中间函数将不会被释放,同时也会使原始的作用域得不到释放,作用域中产生的内存也不会被释放,除非不再有引用,才会被逐步释放。

3.2.2 少创建全局变量


var a = 1; // 等价于 window.a = 1; 

function foo() { a = 1; } // 等价于 function foo() { window.a = 1; } 

function foo() { this.a = 1; } // 等价于 function foo() { window.a = 1; } 

在ES5中以,var声明的方式在全局作用域中创建一个变量时,或者在函数作用域中不以任何声明的方式创建一个变量时,都会无形地挂载到window全局对象上。当进行垃圾回收时,在标记阶段因为window对象可以作为根节点,在window上挂载的属性均可以被访问到,并将其标记为活动的常驻内存,因此也就不会被垃圾回收,只有在整个进程退出时全局作用域才会被销毁。如果你遇到需要必须使用全局变量的场景,那么请保证一定要在全局变量使用完毕后将其设置为null,从而触发回收机制。

3.2.3 手动清除定时器

在我们的应用中经常会有使用setTimeout或者setInterval等定时器的场景,定时器本身是一个非常有用的功能,但是如果我们稍不注意,忘记在适当的时间手动清除定时器,那么很有可能就会导致内存泄漏,正确的做法是,在定时器完成的时候,手动清除:

// 在vue中 
created() { this.id = setInterval(cb, 500); },
beforeDestroy() { clearInterval(this.id); }, 
// 在React中
useEffect(() => { const id = setInterval(cb, 500); return () => clearInterval(id); }, []); 

3.2.4 手动清除事件监听器

removeEventListener()方法用于移除由addEventListener()方法添加的事件句柄,在组件销毁时移除事件处理函数,以减少内存泄漏,提高应用性能:

// 在vue中
created() { 
    document.addEventListener('click', e => cb(e)); 
}, 
beforeDestroy() { 
    document.removeEventListener('click', e => cb(e));
 }, 
// 在React中
useEffect(
    () => { 
        document.addEventListener('click', e => cb(e)); 
        return () => document.removeEventListener('click', e => cb(e)); 
    }, []
);

3.2.5 养成清理log的好习惯

通过前几个示例我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题,除此之外,其实大量的console.log也会引起内存泄漏的问题,在生产环境中,我们应该清除大多数非必要的console,不清除的话会比较耗性能。如果是调用一两次就没什么,万一放到了循环里就很过分了

4. 总结

Node将JavaScript的主要场景从浏览器环境扩展到了服务器端,相应考虑的细节也和浏览器端不一样,总的来说,内存在Node中受到了一定限制,不能随心所欲地使用,但也不是完全不擅长,本文中主要参考了《Nodejs深入浅出》这本书,从不同方面讲解了Node的内存分配和V8引擎的垃圾回收机制,理论性知识比较多,虽然日常业务中可能用不到,但是相信对大家的面试有所帮助(我上次就被问到了),此外,由于V8引擎的源码是用C++实现的,所以这里没有做深入研究(主要是我不会C++),最后,感谢阅读,如果文中有错误的地方,还希望能够在评论区指正。

作者:三年没洗澡
链接:https://juejin.cn/post/6969875260472557582
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

说点什么吧...