在过去很长一段时间内,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()); 复制代码
执行以上代码,将会得到输出的内存的使用信息(单位是字节):
在memoryUsage方法返回的参数中:
当我们在代码中申明变量并且赋值时,所使用的对象的内存就分配在堆中,如果已申请的堆的空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8引擎的限制为止。
那么问题来了,V8为何要限制堆的大小?
表层原因是JavaScript最初只运行在浏览器环境,几乎不会遇到大量使用内存的场景,所以对于网页来说,V8的限制已经绰绰有余
深层原因是V8的垃圾回收机制。按官方的说法,以1.5G的垃圾回收的堆内存为例,V8做一次小的垃圾回收需求50ms以上,而做一次非增量式回收甚至需要1s以上,可见其耗时之久,而在这1s的时间内,应用的性能和响应时间会大大下降,这样的情况不仅后端无法接受,前端也无法接受,更重要的是,用户也无法接受。因此在当时的情况下,直接限制堆内存是一个好的选择。
当然了,这个限制并不是死的,V8为我们提供了方法,可以手动打开限制,从而让我们使用更多的内存:
在命令行中输入以下代码:node --v8-options
,然后我们会在命令行窗口中看到V8的选项,这里我们可以看到下面几个选项:
在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算法,这是一种采用复制的方式实现的垃圾回收算法,具体过程是:
先将堆内存一分为二,每个内存空间称为semispace(半空间)
在这两个semispace中,只有一个处于使用中,另一个处于闲置中
处于使用状态的空间称为From空间,处于闲置状态的空间称为To空间
当我们分配对象时,先是在From空间中进行分配
开始进行垃圾回收时,会检查From空间中存活的对象
存活的被复制到To空间中,非存活对象占用的空间被释放
完成复制后,From空间和To空间的角色发生对换
简而言之,在新生代垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制,分代回收堆内存如下图所示:
新生代内存空间 | 老生代内存空间 |
| |
semispace(From) | semispace(To) |
| |
流程图如下:
假设我们在From空间
中分配了三个对象A、B、C
当程序主线程任务第一次执行完毕后进入垃圾回收时,发现对象A已经没有其他引用,则表示可以对其进行回收
对象B和对象C此时依旧处于活跃状态,因此会被复制到To空间中进行保存
接下来将From空间
中的所有非存活对象全部清除
此时From空间
中的内存已经清空,开始和To空间
完成一次角色互换
当程序主线程在执行第二个任务时,在From空间
中分配了一个新对象D
任务执行完毕后再次进入垃圾回收,发现对象D已经没有其他引用,表示可以对其进行回收
对象B和对象C此时依旧处于活跃状态,再次被复制到To空间
中进行保存
再次将From空间
中的所有非存活对象全部清除
最后,From空间
和To空间
继续完成一次角色对换 可以看到,它的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的,所以无法应用到所有的场景中,但是由于这个算法只复制存活对象,并且对于某些场景,存活对象只占少部分,所以它在时间效率上有优异的表现。所以说,Scavenge是典型的牺牲空间换取时间的算法。
需要注意的是:
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(标记清除)分为标记
和清除
两个阶段,具体步骤如下:
在标记阶段遍历堆中的所有对象
然后标记活着的对象
在清除阶段中,将未标记的对象进行清除
Mark-Sweep最大的问题是在进行一次标记回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,为了解决这个内存碎片问题,Mark-Compact(标记整理)
被提出来,这个算法是在Mark-Sweep(标记清除)
的基础上演变来的,它们的差别在于对象在标记为死亡后,在整理的过程中,将存活对象向一端移动,移动完成后,直接清除掉边界外的内存。
Mark-Compact(标记整理)分为标记
和清除
和整理
三个阶段,具体步骤如下:
在标记阶段遍历堆中的所有对象
然后标记活着的对象
在清除阶段中,将未标记的对象进行清除
对内存空间进行整理,将存活对象向一端移动
移动完成后,直接清除掉边界外的内存
流程图如下:
假设在老生代中有A、B、C、D、E、F、G、H八个对象:
在垃圾回收的标记
阶段,将B、D、F、H标记为活动的:
在一轮清除之后,此时的内存空间变的不连续:
在垃圾回收的整理
阶段,将活动的对象往堆内存的一端移动:
最后,在垃圾回收的清除阶段,将活动对象右侧的内存全部回收: Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收。另外可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象,存活对象在新生代内存里只占小部分,死亡对象在老生代内存里只占小部分,这也就是两种回收方式能高效处理的原因。
至此就完成了一次老生代垃圾回收的全部过程。下面的表格是三种垃圾回收算法的简单对比:
回收算法 | Mark-Sweep | Mark-Compact | Scavenge |
速度 | 中等 | 最慢 | 最快 |
空间 | 少(有碎片) | 少(无碎片) | 双倍空间(无碎片) |
是否移动对象 | 否 | 是 | 是 |
从上表中可以看到,标记清除
不需要移动对象,其它两种算法需要移动对象,因此这两种算法的执行速度不如标记清除,所以在取舍上,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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。