简述
在大多数情况下,我们不需要关心 JavaScript 的内存,因为 JavaScript 最开始作为浏览器脚本语言,功能相对比较单一,很少出现内存泄露甚至是内存溢出的问题。但是随着类似单页面这种技术出现,页面上往往同时运行着大量的 JavaScript 代码。而出于安全考虑,系统分配给浏览器的内存通常比桌面软件的要少很多。当内存耗尽的时候,很多时候会引起系统的崩溃。
随着基于 V8 引擎的 Node 的出现,JavaScript 的使用范围被扩大到了后端上。这极大的促进了 JavaScript 的发展,也带来了一些问题,其中就有内存管理。
在服务器端这种时刻都在进行高负荷运行的地方,如果不压榨出每一个内存的作用,很有可能造成服务器的服务的宕机。
最关键的是,内存管理也算是面试高频题了。所以,xdm,是时候重视内存管理问题!最近,我在网上找了大量的文章,也有了些收获。这篇文章,既是总结也是交流~
在接下来的文章中,你将会学到以下知识点:
- 内存周期的概念
- JavaScript内存的分配
- JavaScript内存的使用
- JavaScript内存的常见回收策略
- 回收策略各自的优缺点
- V8 引擎 对垃圾回收策略的优化
- 常见引发内存问题的情况和对应的解决方案
- 内存泄漏和内存溢出的区别
JavaScript的内存回收时“自动”的
不像C语言这样的底层语言都有内存管理接口,比如 malloc()
和free()
,并且,这些都需要用户去手动去管理内存。 JavaScript 是在创建变量(对象,字符串等)时“自动”进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。也正是这种”自动”的操作,让我们忽视了内存管理的存在。
内存周期
不管什么程序语言,内存生命周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放、归还
内存分配
在 JavaScript 中,内存一般分为栈内存和堆内存。基本类型储存在栈内存,栈内存由操作系统管理;引用类型储存在堆内存,堆内存由引擎管理。这就涉及到 V8 的垃圾回收了,在文章的后面会讲到。
内存的使用
值的使用是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
当值不在被使用的时候,所对应的内存就会被回收。大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。
就像很多高级语言一样,JavaScript 都内嵌了垃圾回收器
内存的回收和释放
对于内存的管理,最终的一个环节就是这个回收和释放。
首先我们有这样子的一个概念,就是常说的内存管理是在堆内存上的。内存的回收,也就是我们俗称的垃圾回收机制是有算法(策略)的。最常见的有两种,一种是引用计数,还有一个是标记-清除。
引用计数
最早期的垃圾回收策略是引用计数(reference counting)。其思路就是对每个值都记录它的引用次数。声明变量并给他赋一个引用值时,这个值得引用数就会加一,类似地,如果保存对值引用的变量被其他值覆盖了,那么引用数就减1.当一个值得引用为0 时,就说明没办法再访问到这个值了。因此可以安全地回收内存了。垃圾回收程序下次运行得时候就会释放引用数为0的内存。
这里我们要注意,内存回收的工作是周期性。因为,JavaScript是单线程的,如果频繁地去执行垃圾回收,会阻塞其他任务的执行。具体的我们会在下面讲到。
引用计数有一个严重的问题:循环引用。所谓的循环引用,就是对象A有一个指针指向对象B,而对象B也引用了对象A。比如:
function problem(){
let objectA = new Object();
let objectB = new Object();
// A 对象的属性引用了 B 对象,B 对象的属性也引用了 A
objectA.someOtherObject = objectB;
pbjectB.anotherObject = objectA;
}
复制代码
在上面的例子中,objectA 和 objectB 通过各自的属性互相引用,意味着它们的引用数都是 2,而且在当前回收策略下它们永远不会被回收。这就会造成内存泄漏。
事实上,引用计数策略应发的问题还不止这些。
由于早期 IE 浏览器中的 BOM 和 DOM 对象是 C++ 实现的组件对象模型 COM。而 COM 对象使用的是引用计数实现垃圾回收。所以,早期的 IE 浏览器的页面体验不是很好。
也正是由于上面的原因,现在的大部分浏览器都采用了标记清除策略。
标记清除
标记清理(mark-and-sweep),正如其名字所言,这个策略分为标记
和清除
两个阶段。标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。
那么标记清除是怎么解决上面的问题的呢。这里就要引入一个名词,就是可达性。
可达性,顾名思义就是可以到达,因为堆内存中往往都存在根对象(不是传统意义上的对象)。我们标记也是从根对象上去开始递归遍历的。当一个访问一个对象的路径内切断了,他就是不可达的。那么就需要被清理。
所以,当函数执行完毕的时候,当前函数就和全局断开了连接。这就是我们一般是没法访问函数内部定义的变量的原因(特殊的闭包除外)。这时候这些变量就会因为没法到达而无法标记,所以就会在下次的 GC 的时候被回收。可以看出,标记清除和引用计数的最大不用就是,回收的标准的不用。零引用的内存一定不可到达,但是非零引用的内存不一定可达到。
你可能会疑惑怎么给变量加标记?其实有很多种办法,比如当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表,当前还有很多其他办法。其实,怎样标记对我们来说并不重要,重要的是其策略
引擎在执行 GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 根
对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象
、文档DOM树
等
整个标记清除算法大致过程就像下面这样
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
- 然后从各个根对象开始遍历,把不是垃圾的节点改成1
- 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
- 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收
两种回收策略的优缺点
标记清除优缺点
优点:
实现简单。打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记。(V8 引擎有优化,后面的文章会讲到)。
缺点:
内存碎片化。在回收内存后,原来对象占用的位置被空下来,这就造成了内存的不完整性。
当然,如何将这些碎片化的内存重新分配是有方案的,常见的方案有三种:
- First-fit,就是找到大于或者等于需要分配内存大小的内存后,就立刻返回。
- Best-fit,查找整个内存,直到找到符合待分配大小的最小的内存块。
- Worst-fit,查找整个内存,找到最大的内存块,先切除需要分配大小的内存部分,然后返回剩下的。
从执行效率上来讲的话,一般都是First-fit比较常用。但是,这也是需要时间的,所以也算是标记清除的另外一个缺点:分配速度慢
标记清除的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续。这时候标记整理算法就出来了。
标记整理的算法原理其实就是在标记结束后多干一件事情,将活着的对象向内存的一端移动,最后清理掉边界的内存。
引用计数优缺点
优点:
立即回收。当一个内存的引用为0的时候,这部分的内存会被立即回收。
减少程序的暂停。应用程序在执行的过程当中,必然会对内存进行消耗。当前执行平台的内存肯定是有上限的,所以内存肯定有占满的时候。由于引用计数算法是时刻监控着内存引用值为0的对象,举一个极端的情况就是,当他发现内存即将爆满的时候,引用计数就会立马找到那些数值为0的对象空间对其进行释放。这样就保证了当前内存是不会有占满的时候,也就是所谓的减少程序暂停的说法。
缺点:
循环引用对象无法被回收。正如上文中提到的,这算是引用计数的最大的问题。按照引用计数的回收标准,函数内部的循环引用的对象是没法被回收的,因为他们的引用数永远不会是0。
计数比较耗时。要实现计数就需要频繁的去查看哪些对象引用数为0了,当维护的对象数大的时候,查找的过程就比较耗时了。引用计数的GC会占用主线程,会阻塞其他任务的运行。当然,这不是引用计数独有的问题,标记清除同样是存在。
综上,我们看出,JavaScript 的自动内存管理并不是完美的,只不过是目前来说标记清除是最优的方案而已。
接下来,我们就来看看大名鼎鼎的V8引擎是如何优化垃圾回收机制的。
V8 引擎的垃圾回收机制
V8 的垃圾回收也是基于标记清除算法,只不过,他对有一些优化和加工处理。
分代式垃圾回收
首先我们先回顾一下,引用技术的优点中有一个是 立即回收内存,而标记清除算法的垃圾回收时周期性的。那如果标记清除算法也能实现立即回收内存那是不是效率会提高上去呢?这时候,v8 提出了他们的解决方案–分代式垃圾回收
分代式垃圾回收原理
将堆内存分为新生代和老生代两个区域,然后两个区域采用不同的垃圾回收策略。
新生代
新生代的对象一般是存活时间比较短且比较小的对象。这部分通常只有很小的内存分配,一般是 1~8M 的容量。
之所以这么分配其实是和其垃圾回收算法有关。下面我们就讲讲新生代中的垃圾回收算法。
新生代采用的是名为 Scavenge 的算法,而这个算法使用的其实叫做Cheney算法 。
Cheney算法将新生代中的内存一分为二,一个是出于使用状态的区域,我们称之为 使用区,一个是出于闲置状态的 称之为空闲区。
这个算法的流程大致是这样子的:
新生成的对象会被存放在使用区,当使用区快内写满的时候,这时候就需要垃圾清理了。
首先先进入标记流程,新生代垃圾回收器找出使用区内的活动的对象进行标记,接着就是将被标记的对象复制到空闲区并排序。随后,进入垃圾清洁阶段,将使用区中的未被标记的空间清理掉,最后将两个区域角色互换一下。如下图
从上图我们可以看出一个问题,就是新的变量的数量不断增加,使用区里面的空间总会被占满的。怎么办呢,其实很简单,那就选出一些让他们渡劫飞升,咳咳,就是晋升。还记得咱们还有老生代吗,咱们就是通过一些规则,让新生代里面的一些变量晋升为老生代。
晋升规则总得来说可以分为两种:
- 在一个变量的移动阶段,如果这个变量所占用的空间的大小超过了空闲区的25%,那么这个变量会直接晋升为老生代。
- 当一个变量已经进过了多次交换后还存活,那么这个变量也会晋升为老生代。
老生代
相对于新生代,老生代的垃圾回收就比较容易理解了。V8引擎所采用的优化方法就是上我们上文提到的标记整理算法。在标记的实现过程中,其实也有不少的概念,下面我就简单讲讲。
我们都知道 JavaScript 是一门单线程的语言,他运行在主线程上,那做垃圾回收的时候势必会 阻塞 JavaScript 脚本的执行,等垃圾回收完毕后再恢复脚本的执行,我们把这种行为叫做全停顿(Stop-The-World)
我们可以想象,如果一次的垃圾回收的过程特别漫长,那么主线程的脚本就会被阻塞很久,造成页面卡顿等问题。
在V8 新的垃圾回收项目中(Orinoco)中得到了解决,它利用最新的和最好的垃圾回收技术来降低主线程挂起的时间, 比如:并行(parallel)垃圾回收,增量(incremental)垃圾回收和并发(concurrent)垃圾回收。
并行垃圾回收
并行垃圾回收是主线程和协助线程同时执行同样的工作,但是这仍然是一种 ‘stop-the-world’ 的垃圾回收方式。这个很重要的特征就是虽然是多个线程通知回收,但是要保证不会操作同一个对象就行。
增量标记
增量标记其实是在主线程上交替进行脚本的执行和垃圾回购,和之前不同的地方其实就是,Orinoco 将大块的垃圾回收拆分成很多小的垃圾回收任务。
这种标记法并没有减少总的垃圾回收时间,甚至于会增加一点。但是这个避免了垃圾回收影响用户的操作等。
并发标记
并发标记是主线程和垃圾回收线程同时运行。主线程执行 JavaScript ,垃圾回收线程专注于垃圾回收。这种方式最麻烦,因为在主线程运行的时候,内存中的对象的状态是时刻都在变,从而使之前做的工作完全无效。最重要的是,现在有读/写竞争(read/write races),主线程和辅助线程极有可能在同一时间去更改同一个对象。这种方式的优势也非常明显,主线程不会被挂起,JavaScript 可以自由地执行 ,尽管为了保证同一对象同一时间只有一个辅助线程在修改而带来的一些同步开销。
为啥需要分代式
正如我们开始说的,分代式并不是一种新的垃圾回收策略,他只不过是对标记清除算法的一种优化。
这种优化吸收了引用计数这种策略的优点,将空间划分为两种不同回收频率的部分,使用不同的策略去整理内存。这种机制很多程度上提高了垃圾回收的效率。
其实讲到这里,我们应该对 JavaScript 的内存管理有了总体上的认识了。其实,V8 引擎对于垃圾回收策略的优化不知这些,还有很多细节。比如新生代垃圾回收机制,最开始的时候是单线程 Cheney’s 半空间复制,后来又升级为 并行 Mark-Evacuate。利用并行的方式提交复制和回收效率。
还有标记算法中还有 三色标记法 和 强三色标记法。这些概念很多,其实可以看看v8官方如何解释新的垃圾回收机制。
我们在简单的总结一下新生代和老生代内的对象的区别:
新生代:生命周期短,占用内存小
老生代:生命周期长或者占用大小比较大。
内存泄漏和内存溢出的区别
内存泄漏:程序运行过程中,分配给内存的临时变量,用完之后却没有被回收。
内存溢出:简单的说就是程勋运行过程中申请的内存大于系统能提供的内存,导致无法申请到足够内存。
他们之间的关系应该是:过多的内存泄漏最终会造成内存溢出。
常见的内存泄漏
特殊的闭包
想必大家对闭包并不陌生。对于闭包的概念不去讲,我们首先要明确一个概念:只有应用了函数的内部变量的闭包才算是引发内存泄漏的闭包。
我们来看看下面的代码
function fn1(){
let test = new Array(1000).fill('isboyjc')
return function(){
console.log('hahaha')
}
}
let fn1Child = fn1()
fn1Child()
复制代码
显然上面是闭包,但是它并没有造成内存泄漏。因为返回的函数中并没有对fn1 函数内部的引用。也就是说 test 变量完全是可以被回收的。
那么,什么样的闭包才会引发内存泄漏呢?我们来看下面的代码:
function fn2(){
let test = new Array(1000).fill('isboyjc')
return function(){
console.log(test)
return test
}
}
let fn2Child = fn2()
fn2Child()
复制代码
上面的闭包就会造成了内存泄漏,因为test 变量的使用被外部使用了。所以他不能被回收。
优化
我们最好在用完闭包的时候,记得将其置为null
隐式全局变量
function fn(){
// 没有声明从而制造了隐式全局变量test1
test1 = new Array(1000).fill('isboyjc1') // 函数内部this指向window,制造了隐式全局变量test2
this.test2 = new Array(1000).fill('isboyjc2')
}
fn()
复制代码
这里面使用test1
就会被隐式的声明为全局变量。对于全局变量来说,垃圾回收很难判断什么时候不会被需要的。所以全局变量统称不会被回收。
优化
和上面一样,我们记得要在不用变量的时候将其置空;
使用let
const
等去声明变量。因为在 es6 中由let
和 const
声明的变量不会被绑定在全局对象window上。
被遗忘的 DOM 引用
如果一个很大的DOM对象被引用而被忘记清除也会造成内存泄漏。
优化
和上面一样,我们记得要在不用变量的时候将其置空;
被遗忘的定时器
我们明白像setTimeout 和 setInerval这种的定时器在不被清除的时候,是不会消失的。如果这个定时器里面引用了很大的对象,那么这个对象所占用的空间也不会被释放。
和上面一样,我们记得要在不用变量的时候将其置空;
被遗忘的事件监听器
事件监听器和上面的定时器是一个原理,都需要手动去解除监听。
未被清理的 console 输出
写代码的过程中,肯定避免不了一些输出,在一些小团队中可能项目上线也不清理这些 console
,殊不知这些 console
也是隐患,同时也是容易被忽略的,我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console
如果输出了对象也会造成内存泄漏
优化
即使清除代码中的console.log
Map和Set
当使用 Map
或 Set
存储对象时,同 Object
一致都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收。
优化
使用WeakMap
以及 WeakSet
到了这里,本篇文章也接近尾声了。如果我写的内容对大家有帮助,还请点赞哦。当然,如果有其他疑问或者错误还请指正~