Open li-jia-nan opened 1 year ago
在过去很长一段时间内,JavaScript开发者很少遇到需要对内存进行精确控制的场景,也缺乏控制的手段,说到内存泄漏,大家可能首先想到早期浏览器中的卡顿问题,如果内存占用过多,基本等不到代码进行垃圾回收,用户已经开始不耐烦的刷新网页了。
随着node的发展,JavaScript的应用场景早已不再局限在浏览器中,在浏览器中那些短时间执行的场景中,由于运行时间短,而且运行在用户的机器中,随着进程的退出,内存会释放,几乎没有内存管理的必要。但是,随着node在服务端的广泛应用,在其它语言里存在的问题在JavaScript中也逐渐暴露出来了。
我们在学习JavaScript的时候听说过垃圾回收机制,JavaScript就是由垃圾回收机制来进行自动内存管理的,这使得开发者在编写JavaScript的时候,不需要像其它语言那样时刻关注内存分配和释放的问题。只在浏览器中进行开发时,几乎很少有人遇到因为垃圾回收对项目构成性能影响的情况,Node极大的拓宽了JavaScript的应用场景,当应用场景从浏览器延伸到各种场景中时,我们就能发现,内存管理的好坏、垃圾回收状态的优良与否至关重要,而不管是在浏览器环境、还是node环境中,这一切都与V8引擎息息相关。
垃圾回收机制
自动内存管理
在一般的后端语言中,在基本的内存使用上是没有限制的,但是在node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统约为1.4G,32位系统约为0.7G),在这样的限制下,node无法直接将一个大文件读入内存进行处理,即使电脑的物理内存有16G,在单个node进程的情况下,内存也无法得到充足的使用。
造成这个问题的原因在于V8引擎,所以node中使用JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。这套管理机制在浏览器中使用起来绰绰有余,足以胜任前端页面中的所有需求,但是在node环境中,却限制了开发者对大内存文件的分析和处理。
尽管在服务端操作大内存也不是常见的需求场景,但有了限制之后,我们的行为就如同带着镣铐跳舞,如果在实际应用中不小心触碰到这个界限,会造成进程退出,如果是在浏览器环境中,会导致浏览器白屏、或者卡死。只有在知晓其原理后,才能避免问题,更好的进行内存管理。(这段话摘自《Node深入浅出》,个人认为写的很好)
在V8中,JavaScript对象是通过堆内存来进行分配的。Node提供了内存使用量的查看方式,在node环境中输入以下代码:
console.log(process.memoryUsage());
执行以上代码,将会得到输出的内存的使用信息(单位是字节):
在memoryUsage方法返回的参数中:
当我们在代码中申明变量并且赋值时,所使用的对象的内存就分配在堆中,如果已申请的堆的空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8引擎的限制为止。
那么问题来了,V8为何要限制堆的大小?
当然了,这个限制并不是死的,V8为我们提供了方法,可以手动打开限制,从而让我们使用更多的内存:
在命令行中输入以下代码:node --v8-options,然后我们会在命令行窗口中看到V8的选项,这里我们可以看到下面几个选项:
node --v8-options
在Node启动时,我们可以传递--max-old-space-size或者--max-new-space-size来调整内存限制大小,比如:
--max-old-space-size
--max-new-space-size
node --min-semi-space-size=1024 index.js
node --max-semi-space-size=1024 index.js
node --max-old-space-size=2048 index.js
上述参数在环境初始化时生效,一旦生效,就不能动态改变,只能手动调整,如果遇到内存不够的情况,可以用这个方法手动放宽限制,从而避免由内存问题引起的网页白屏或者奔溃,接下来让我们了解一下垃圾回收方面的策略,在限制的前提下,带着镣铐跳出的舞蹈并不一定就难看。(这段话摘自《Node深入浅出》,个人认为写的很好)
在展开介绍垃圾回收机制之前,有必要简略介绍下V8用到的各种回收算法
V8的垃圾回收算法主要基于分代式垃圾回收机制,在早期的垃圾回收中,人们发现没有一种算法能够胜任所有的场景,因为在实际应用中,对象的生存周期长短不一,不同的算法只能针对特定的情况发挥作用。因此,在现代的垃圾回收算法中,根据对象的存活时间将垃圾回收进行了不同分代,主要分为新生代和老生代,然后分别对不同分代的内存使用不同的算法。
分代式垃圾回收机制
在V8中,主要将内存分为新生代和老生代两种,新生代中的对象存活时间较短,老生代中的对象存活时间较长(或常驻内存中),如下图所示:
新生代
老生代
V8堆的整体大小就是新生代内存空间加上老生代内存空间,前面我们提到的两个命令行就可以用于设置这个空间的最大值,需要注意的是,这个最大值需要在启动的时候就指定,因此,V8使用的内存无法根据情况自动扩充,当内存分配过程中超过极限值的时候,就会引起进程出错,页面卡死,白屏。
在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收,具体实现中采用的是Cheney算法,这是一种采用复制的方式实现的垃圾回收算法,具体过程是:
简而言之,在新生代垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制,分代回收堆内存如下图所示:
流程图如下:
From空间
To空间
需要注意的是:
对象晋升
在单纯的Scavenge算法中,From空间中的对象会被复制到To空间中去,然后对两个空间进行角色对换(又称翻转)。但是在分代式垃圾回收的前提下,From空间中的对象在复制到To空间时会进行检查。在一定条件下,将存活时间上的对象移动到老生代中,也就是完成对象晋升。
需要注意的是,满足对象晋升的条件主要有以下两个:
Scavenge
25%
这个晋升流程可以用以下的流程图来表示:
stateDiagram-v2 [*] --> From From --> 经历过Scavenge From --> 未经历Scavenge 未经历Scavenge --> To 经历过Scavenge --> To内存>25% 经历过Scavenge --> To内存<25% To内存<25% --> To To内存>25% --> 移动到老生代
对象成功晋升后,将会在老生代内存空间中作为存活时间较长的对象来对待,通过新的回收算法处理。
在老生代中,因为有大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)和Mark-Compact(标记整理)来进行管理。
Mark-Sweep(标记清除)
Mark-Compact(标记整理)
Mark-Sweep(标记清除)分为标记和清除两个阶段,具体步骤如下:
标记
清除
Mark-Sweep最大的问题是在进行一次标记回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,为了解决这个内存碎片问题,Mark-Compact(标记整理)被提出来,这个算法是在Mark-Sweep(标记清除)的基础上演变来的,它们的差别在于对象在标记为死亡后,在整理的过程中,将存活对象向一端移动,移动完成后,直接清除掉边界外的内存。
Mark-Compact(标记整理)分为标记和清除和整理三个阶段,具体步骤如下:
整理
至此就完成了一次老生代垃圾回收的全部过程。下面的表格是三种垃圾回收算法的简单对比:
从上表中可以看到,标记清除不需要移动对象,其它两种算法需要移动对象,因此这两种算法的执行速度不如标记清除,所以在取舍上,V8主要使用Mark-Sweep(标记清除),在空间不足以对晋升的对象进行分配时才使用Mark-Compact(标记整理)。
标记清除
从V8垃圾回收机制的角度可以看出,新生代设计为一个较小的内存空间是合理的,而老生代空间过大,对于垃圾回收并无特别意义。V8对内存的限制对于浏览器页面而言,内存使用是绰绰有余了;而对于后端服务器来说,也能满足大多数场景了,并不会影响正常场景下的使用。但是对于垃圾回收的特点和JavaScript单线程的执行情况,垃圾回收是影响性能的因素之一,想要提高应用的性能,还是需要注意让垃圾回收尽量少的执行。
我们知道,作用域链上的对象访问只能向上,这样外部无法向内部访问,在JavaScript中,实现外部作用域访问内部作用域中的变量的方法就叫做闭包,这得益于高阶函数的特性:函数可以作为参数或者返回值
function foo() { function bar() { var local = "局部变量"; return function() { return local; }; }; var baz = bar(); console.log(baz()); };
一般而言,在bar()函数执行完成之后,局部变量local会随着作用域的销毁而被回收,但是这里的特点是,返回了一个匿名函数,而且这个匿名函数具备了访问local的条件,如果要在外部访问local,只需要通过这个中间函数稍作周转即可。
bar()
local
闭包是JavaScript的高级特性,可以利用它产生很多奇妙的效果,但是它的问题在于,一旦有变量引用了这个中间函数,这个中间函数将不会被释放,同时也会使原始的作用域得不到释放,作用域中产生的内存也不会被释放,除非不再有引用,才会被逐步释放。
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,从而触发回收机制。
var
window
null
在我们的应用中经常会有使用setTimeout或者setInterval等定时器的场景,定时器本身是一个非常有用的功能,但是如果我们稍不注意,忘记在适当的时间手动清除定时器,那么很有可能就会导致内存泄漏,正确的做法是,在定时器完成的时候,手动清除:
setTimeout
setInterval
// 在vue中 created() { this.id = setInterval(cb, 500); }, beforeDestroy() { clearInterval(this.id); }, // 在React中 useEffect(() => { const id = setInterval(cb, 500); return () => clearInterval(id); }, []);
removeEventListener()方法用于移除由addEventListener()方法添加的事件句柄,在组件销毁时移除事件处理函数,以减少内存泄漏,提高应用性能:
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)); }, []);
通过前几个示例我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题,除此之外,其实大量的console.log也会引起内存泄漏的问题,在生产环境中,我们应该清除大多数非必要的console,不清除的话会比较耗性能。如果是调用一两次就没什么,万一放到了循环里就很过分了
console.log
Node将JavaScript的主要场景从浏览器环境扩展到了服务器端,相应考虑的细节也和浏览器端不一样,总的来说,内存在Node中受到了一定限制,不能随心所欲地使用,但也不是完全不擅长,本文中主要参考了《Nodejs深入浅出》这本书,从不同方面讲解了Node的内存分配和V8引擎的垃圾回收机制,理论性知识比较多,虽然日常业务中可能用不到,但是相信对大家的面试有所帮助(我上次就被问到了),此外,由于V8引擎的源码是用C++实现的,所以这里没有做深入研究(主要是我不会C++),最后,感谢阅读,如果文中有错误的地方,还希望能够在评论区指正。
在过去很长一段时间内,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环境中输入以下代码:
执行以上代码,将会得到输出的内存的使用信息(单位是字节):
在memoryUsage方法返回的参数中:
当我们在代码中申明变量并且赋值时,所使用的对象的内存就分配在堆中,如果已申请的堆的空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8引擎的限制为止。
那么问题来了,V8为何要限制堆的大小?
当然了,这个限制并不是死的,V8为我们提供了方法,可以手动打开限制,从而让我们使用更多的内存:
在命令行中输入以下代码:
node --v8-options
,然后我们会在命令行窗口中看到V8的选项,这里我们可以看到下面几个选项:在Node启动时,我们可以传递
--max-old-space-size
或者--max-new-space-size
来调整内存限制大小,比如:node --min-semi-space-size=1024 index.js
设置新生代内存中单个半空间的内存最小值,单位MBnode --max-semi-space-size=1024 index.js
设置新生代内存中单个半空间的内存最大值,单位MBnode --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空间之间进行复制,分代回收堆内存如下图所示:
流程图如下:
From空间
中分配了三个对象A、B、CFrom空间
中的所有非存活对象全部清除From空间
中的内存已经清空,开始和To空间
完成一次角色互换From空间
中分配了一个新对象DTo空间
中进行保存From空间
中的所有非存活对象全部清除From空间
和To空间
继续完成一次角色对换需要注意的是:
对象晋升
。3.1.3 对象晋升(新 => 老)
在单纯的Scavenge算法中,From空间中的对象会被复制到To空间中去,然后对两个空间进行角色对换(又称翻转)。但是在分代式垃圾回收的前提下,From空间中的对象在复制到To空间时会进行检查。在一定条件下,将存活时间上的对象移动到老生代中,也就是完成对象晋升。
需要注意的是,满足对象晋升的条件主要有以下两个:
Scavenge
算法To空间
的内存占比是否已经超过25%
这个晋升流程可以用以下的流程图来表示:
对象成功晋升后,将会在老生代内存空间中作为存活时间较长的对象来对待,通过新的回收算法处理。
3.1.4 老生代(标记清除 & 标记整理)
在老生代中,因为有大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法
Mark-Sweep(标记清除)
和Mark-Compact(标记整理)
来进行管理。Mark-Sweep(标记清除)分为
标记
和清除
两个阶段,具体步骤如下:Mark-Sweep最大的问题是在进行一次标记回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,为了解决这个内存碎片问题,
Mark-Compact(标记整理)
被提出来,这个算法是在Mark-Sweep(标记清除)
的基础上演变来的,它们的差别在于对象在标记为死亡后,在整理的过程中,将存活对象向一端移动,移动完成后,直接清除掉边界外的内存。Mark-Compact(标记整理)分为
标记
和清除
和整理
三个阶段,具体步骤如下:流程图如下:
标记
阶段,将B、D、F、H标记为活动的:整理
阶段,将活动的对象往堆内存的一端移动:至此就完成了一次老生代垃圾回收的全部过程。下面的表格是三种垃圾回收算法的简单对比:
从上表中可以看到,
标记清除
不需要移动对象,其它两种算法需要移动对象,因此这两种算法的执行速度不如标记清除,所以在取舍上,V8主要使用Mark-Sweep(标记清除)
,在空间不足以对晋升的对象进行分配时才使用Mark-Compact(标记整理)
。3.1.5 垃圾回收小结
从V8垃圾回收机制的角度可以看出,新生代设计为一个较小的内存空间是合理的,而老生代空间过大,对于垃圾回收并无特别意义。V8对内存的限制对于浏览器页面而言,内存使用是绰绰有余了;而对于后端服务器来说,也能满足大多数场景了,并不会影响正常场景下的使用。但是对于垃圾回收的特点和JavaScript单线程的执行情况,垃圾回收是影响性能的因素之一,想要提高应用的性能,还是需要注意让垃圾回收尽量少的执行。
3.2 避免内存泄漏
3.2.1 少用闭包
我们知道,作用域链上的对象访问只能向上,这样外部无法向内部访问,在JavaScript中,实现外部作用域访问内部作用域中的变量的方法就叫做闭包,这得益于高阶函数的特性:函数可以作为参数或者返回值
一般而言,在
bar()
函数执行完成之后,局部变量local
会随着作用域的销毁而被回收,但是这里的特点是,返回了一个匿名函数,而且这个匿名函数具备了访问local的条件,如果要在外部访问local,只需要通过这个中间函数稍作周转即可。闭包是JavaScript的高级特性,可以利用它产生很多奇妙的效果,但是它的问题在于,一旦有变量引用了这个中间函数,这个中间函数将不会被释放,同时也会使原始的作用域得不到释放,作用域中产生的内存也不会被释放,除非不再有引用,才会被逐步释放。
3.2.2 少创建全局变量
在ES5中以,
var
声明的方式在全局作用域中创建一个变量时,或者在函数作用域中不以任何声明的方式创建一个变量时,都会无形地挂载到window
全局对象上。当进行垃圾回收时,在标记阶段因为window对象可以作为根节点,在window上挂载的属性均可以被访问到,并将其标记为活动的常驻内存,因此也就不会被垃圾回收,只有在整个进程退出时全局作用域才会被销毁。如果你遇到需要必须使用全局变量的场景,那么请保证一定要在全局变量使用完毕后将其设置为null
,从而触发回收机制。3.2.3 手动清除定时器
在我们的应用中经常会有使用
setTimeout
或者setInterval
等定时器的场景,定时器本身是一个非常有用的功能,但是如果我们稍不注意,忘记在适当的时间手动清除定时器,那么很有可能就会导致内存泄漏,正确的做法是,在定时器完成的时候,手动清除:3.2.4 手动清除事件监听器
removeEventListener()
方法用于移除由addEventListener()
方法添加的事件句柄,在组件销毁时移除事件处理函数,以减少内存泄漏,提高应用性能:3.2.5 养成清理log的好习惯
通过前几个示例我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题,除此之外,其实大量的
console.log
也会引起内存泄漏的问题,在生产环境中,我们应该清除大多数非必要的console,不清除的话会比较耗性能。如果是调用一两次就没什么,万一放到了循环里就很过分了4. 总结
Node将JavaScript的主要场景从浏览器环境扩展到了服务器端,相应考虑的细节也和浏览器端不一样,总的来说,内存在Node中受到了一定限制,不能随心所欲地使用,但也不是完全不擅长,本文中主要参考了《Nodejs深入浅出》这本书,从不同方面讲解了Node的内存分配和V8引擎的垃圾回收机制,理论性知识比较多,虽然日常业务中可能用不到,但是相信对大家的面试有所帮助(我上次就被问到了),此外,由于V8引擎的源码是用C++实现的,所以这里没有做深入研究(主要是我不会C++),最后,感谢阅读,如果文中有错误的地方,还希望能够在评论区指正。