前言
不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。
诸如 C 语言这般的比较底层语言一般都有底层的内存管理接口,比如 malloc() 和free()。而另外一些高级语言,比如 JavaScript, 其在变量(对象,字符串等等)创建时分配内存,然后在它们不再使用时"自动"释放。后者被称为垃圾回收。"自动"的确可以让开发者省心少,并但是也会给JavaScript(和其他高级语言)开发者带来一个错误印象:他们可以不用关心内存管理。
的确我们通常不会关注这方面问题,做甩手掌柜,一切交给JS引擎料理,但是了解JavaScript内存机制有助于开发人员能够清晰的认识到自己写的代码在执行的过程中发生过什么,帮助我们提高项目的代码质量,避免内存泄露等问题。
一、基础概念
1.堆空间与栈空间
JavaScript 中的变量分为基本类型和引用类型。
- 基本类型:undefined,null,boolean,number,string,在内存中占有固定的大小,他们的值保存在栈空间中,我们通过按值来访问。
- 引用类型:Object,Array,Function,则在堆内存中为其分配空间,然后把它的内存地址保存在栈内存中。
内存中存放的只是对象的访问地址, 在堆内存中为这个值分配空间 。 由于这种值的大小不固定,因此不能把它们保存到栈内存中。但内存地址大小的固定的,因此可以将内存地址保存在栈内存中。 这样,当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。
(如果熟悉操作系统层面的内存管理知识的话会注意到,里面也有堆和栈两个空间,并且在一些策略上是一样的,栈空间用来存指针,然后堆空间用来存动态内存分配出来的东西,如对象和数组)

ini
const male = true;
const name = 'john Doe';
const age = 24;
const adult = true;

ini
const person = {
id:1,
name: 'John',
age:25
}
const dog = {
name:'puppy',
personId:1
}
function getOwner(dog,persons) {
return persons.find((person) =>
person.id == dog.person
)
}
const name = 'John';
const newPerson = person;

2.内存生命周期
不管什么程序语言,内存生命周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放、归还
在所有语言中第一和第二部分都很清晰。最后一步在比较底层的语言(例如C语言)中很清晰,但是在像JavaScript等高级语言中,这一步依赖于垃圾回收机制,不是那么清晰明确。
二、垃圾回收机制
1.如何识别垃圾
要进行垃圾回收,那么我们要做的第一件事就是找到垃圾,所谓垃圾就是不会再被使用的东西,那么如何判断某个变量或者说对象不再会被使用了呢,两种方法:
1.1引用计数法(最简单):
引用计数的判断原理很简单,就是看一份数据是否还有指向它的引用,若是没有任何东西再指向它,那么垃圾回收器就会回收,其策略是跟踪记录每个变量值被使用的次数
- 当声明了一个变量并且将一个引用类型赋值给该变量时,这个对象的引用次数就为 1
- 如果同一个引用类型又被赋给另一个变量,那么引用数再加 1
- 如果该变量改为引用别的对象了,则引用次数减 1
- 当这个对象的引用次数变为 0 的时候,则说明没有变量在使用,没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的对象占用的内存
引用计数存存在一个致命的缺陷,当对象间存在循环引用时,引用次数始终不会为0,因此垃圾回收器不会释放它们。
ini
function f() {
var o1 = {};
var o2 = {};
o1.a = o2; // o1 引用 o2
o2.a = o1; // o2 引用 o1
return;
};
比如上面这个,原本函数 f 在执行过后,其执行环境的内存应该都会被清理,包括这些执行过程中产生的临时变量和对象o1 ,o2在内应该都会被垃圾回收,但是就因为o1引用了o2,o2也引用了o1,形成了一个环。导致二者的引用次数都不为0,那么再引用计数法策略下就没法被回收。
在 IE8 以及更早版本的 IE 中, BOM 和 DOM中的对象并不是原生的JS对象,而是使用C++以 COM对象的形式实现的,而 COM对象的垃圾收集机制采用的就是引用计数策略。因此,即使 IE 的 JavaScript引擎是使用标记清除策略来实现的,但JavaScript访问的DOM对象依然是基于引用计数策略的。换句话说,只要在IE8及以下版本中涉及 DOM对象,就会存在循环引用的问题。下面这个简单的例子,展示了使用 DOM对象导致的循环引用问题;
ini
var element = document.getElementById("some_element");
var myObject = new Object{);
myObject.element = element;
element.someObject = myObject;
// 这个例子在一个DOM元素(element)与一个原生 JavaScript对象(myobject)之间创建了循环引用。
// 而想要解决循环引用,需要将引用地址置为 null 来切断变量与之前引用值的关系,
// 当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。
myObject.element = null;
element.SomeObject = null;

1.2 标记-清除算法 :
标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法。
故名思意,此算法可以分为两个阶段,一个是标记阶段(mark),一个是清除阶段(sweep)。
1.2.1 标记:
标记阶段,所有堆上的活跃对象都会被标记,每个内存页有一个用来标记对象的位图,位图中的每一位对应的内存页中的一个字,这个位图需要占据一定的空间。另外还有两位用来标记对象的状态,一共三种状态:
- 如果一个对象为白对象,表示还未被垃圾回收器发现
- 如果一个对象为灰对象,表示已经被垃圾回收器发现,但其邻接对象尚未处理
- 如果一个对象为黑对象,表示已经被垃圾回收器发现,其邻接对象已全部处理
怎么理解标记的过程?内存管理方式实际上基于图的概念。标记的过程正是以由GC Root(GC Root是内存的根节点,在浏览器中它是window对象)建立的图为基础,来实现对象的标记,标记算法的核心是深度优先搜索,大致实现如下:
- 初始时,所有对象都是白对象。
- 从根对象(GC Root)到达的对象会被染为灰色,放到一个单独的队列中。
- 标记阶段,每次都会从队列中取出一个对象,并将其转变为黑对象,其邻接对象转变为灰,然后把其邻接对象加入到队列中。
- 如果队列为空或者所有对象都变成黑对象,则结束。
这个算法实现起来还是蛮繁琐的,从图的角度来看,其实标记的过程实际上是区分活节点和垃圾节点的过程。
- 从GC Root开始遍历图,所有能到达的节点称为活节点。
- GC Root不能到达的节点 ,该节点就成为垃圾,将会被回收。
标记结束后,所有的对象非黑(活跃节点)即白(垃圾节点)。
标记时间取决于必须标记的活跃对象的数目,对于一个大的web应用,整个堆栈的标记可能需要超过100ms。由于全停顿会造成了浏览器一段时间无响应,所以V8使用了一种增量标记的方式标记活跃对象,将完整的标记拆分成很多小的步骤,每做完一部分就停下来,让JavaScript的应用线程执行一会,这样垃圾回收与应用线程交替执行。V8可以让每个标记步骤的持续时间低于5ms。
文字看起来不够直观,举个例子:
ini
window.ob = 2;
window.oa = {
b1 : 3,
b2 : {
c1 : 4,
c2 : "字符串"
}
};
window.ob = undefined;

可以看到,最后所有的节点都成为了黑色节点,除了数值2这个节点仍然是白状态,它原来代表ob变量值,当window.ob = undefined后,此节点与GC Root连接的路径ob被切断了,它就成了垃圾,将会被回收。
1.2.2 清除:
标记结束过后,既然已经知道了哪些是需要被回收的垃圾,那么就要开始执行清理步骤了。清理阶段的逻辑就很简单,垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象(白色状态),那么就将此对象占用的内存回收,并且将原来标记为可到达对象的标识清除(染黑的置回白色),以便进行下一次垃圾回收操作。
1.2.3 整理(Compact)
由于执行清除之后,空闲出来的内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。

标记整理 正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础上进行修改,将其清除阶段变为紧缩极端,在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存,这样剩下的活跃对象都缩在内存的一端,剩余的空白内存空间都是连续的大块的,方便后续使用。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片。
2.v8引擎的垃圾回收机制
2.1 分代策略
Chrome 浏览器所使用的 V8 引擎采用的分代回收策略。脚本中,绝大多数对象的生存期很短,只有某些对象的生存期较长。为利用这一特点,V8将堆进行了分代。对象起初会被分配在新生区 。在新生区的内存分配非常容易:我们只需保有一个指向内存区的指针,不断根据新对象的大小对其进行递增即可。当该指针达到了新生区的末尾,就会有一次清理(小周期),清理掉新生区中不活跃的死对象。对于活跃超过2个小周期的对象,则需将其移动至老生区(晋升机制)。而在老生区则使用标记清除的算法来进行垃圾回收。V8通过分别对新生代对象和老生代对象使用不同的垃圾回收算法来提升来及回收的效率。这就是所谓的分代策略。

2.2 新生区的半空间分配策略
新生代使用半空间分配策略,其中新对象最初分配在新生代的活跃半空间内。一旦半空间已满,一个Scavenge操作将活跃对象移出到另外一个半空间中,被认为是长期驻存的对象会被晋升为老生代。一旦活跃对象已被移出,则在旧的半空间中剩下的任何不活跃的对象将被丢弃。
具体的如下:
新生区被平分为两部分空间From和To,所有内存从To空间被分配出去,当To满时,开始触发GC(垃圾回收),执行Scavenge。
举个例子:
某时刻,To空间已经为A、B和C分配了内存,当前它只剩下一小块内存未分配。而From所有的内存都空闲着。此时,一个程序需要为D分配内存,但D需要的内存大小超出了To未分配的内存,此时触发GC,页面停止执行

首先对from区域的垃圾进行标记:
将存活的对象复制到to区域中,并且有序地排列起来,复制后的to区域就没有内存碎片了;

然后就会将from区域清空,因为活跃的已经复制到To区保存,剩下的都是需要回收的垃圾:

接着From和To进行对换,即原来的To空间被标志为From,From被标志为To。

此时,To空间有了足够的空间容纳D之后,D被分配到To空间,最后的情况如下:

至此,整个新生代区的GC完成,以上过程就是Scavenge算法的过程,此过程中页面会阻塞,所以要尽可能的快。
2.2.1 对象的晋升
当一个新生代的对象在满足一定条件下,会从新生代被移到老生代,这就是对象的晋升。具体的移动的标准有两种:
- 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否经历过一次新生代的清理结果,如果是(说明存活了两个周期了),则赋值到老生代中,否则则赋值到To空间中。
- 对象从From空间复制到To空间时,如果To空间已经被使用了超过25%,那么这个对象直接被复制到老生代区。设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成对象区域,继续进行对象内存的分配,若占比过大,将会影响后续内存分配
2.3 老生代的处理:
老生代区的垃圾回收采用的即是之前介绍过的标记整理算法。这里不再赘述。

2.4 何时执行垃圾回收:
由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法

这个思路和操作系统调度进程时使用的时间片轮转策略很相似,交替执行JS脚本和GC,使得GC不会长时间阻塞脚本运行,也不会使得一直运行脚本而没空执行GC。
三、闭包
闭包,简单来说是指有权访问另一个函数作用域中的变量的函数。
一个简单、经典的闭包的例子:
scss
function create(){
let i=0;
// 返回一个函数,暂且称之为函数A
return function(){
i++; // 注意,A函数中引用了create作用域中的 i 变量
console.log(i);
}
}
var c = create(); // c是一个函数,就是create返回的函数A
c(); // 函数执行
c(); // 再次执行
c(); // 第三次执行
在上面的例子中,create()返回的是一个函数,暂且称之为函数A。在函数A中,有两条语句,一条是变量i自增(i++),一条是输出语句(console.log)。第一次执行执行c()时会产生什么样的结果?输出自增后的变量i,也就是输出1;第二次执行c()会输出2;第三次执行c()时会输出3,依次累加,因为返回的函数用到了create作用域中变量,所以形成了闭包,i会被保存下来,三次操作的都是同一个变量 i,所以实现了累加的效果。
3.1 两个概念
3.1.1 执行环境与变量对象
执行环境是JavaScript中一个重要的概念,它决定了变量或函数是否有权访问其他的数据,决定了它们各自的行为。
当某个函数第一次被调用时,会创建一个执行环境(分配一片栈内存)及相应的作用域链,并把作用域链赋值给一个特殊的内部属性(即[[Scope]])。每个执行环境都有一个与之对应的变量对象,执行环境中定义的所有变量和函数都保存在这个对象中。虽然我们的代码无法访问这个对象,但是解析器在处理数据时会在后台使用它。
全局执行环境是最外层的一个执行环境。在web浏览器中,全局执行环境被认为是window对象,因为所有的全局变量和全局函数都是作为window对象的属性和方法创建的。某个执行环境中的所有代码执行完毕后,该环境被销毁 ,保存在其中的所有变量和函数定义也随之销毁(全局执行环境直到应用程序退出------例如关闭网页或者浏览器时才会被销毁),被垃圾回收机制回收。
每个函数都有自己的执行环境。当执行流进入一个函数时,该函数的环境就会被推入到一个环境栈中。而在函数执行后,栈将其环境弹出,把控制权返回给之前的执行环境。
3.1.2 作用域链
作用域链是当代码在一个环境中执行时创建的,作用域链的用途就是要保证执行环境中能有效有序地访问所有变量和函数。作用域链的最前端始终都是当前执行的代码所在环境的变量对象,下一个变量对象是来自其父亲环境,再下一个变量对象是其父亲的父亲环境,直到全局执行环境。
标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直到找到标识符为止(如果找不到标识符,通常会导致错误发生)。其实,通俗点说就是:在本作用域内找不到变量或者函数,则在其父亲的作用域内寻找,再找不到则到父亲的父亲作用域内寻找,直到在全局的作用域内寻找。
无论什么时候在函数中访问一个变量时,就会从作用域链中搜索具有相应名字的变量。一般来讲,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)。但是,闭包的情况又有所不同。
3.2 理解闭包:
再次回到刚才闭包的例子:
scss
function create(){
let i=0;
// 返回一个函数,暂且称之为函数A
return function(){
i++; // 注意,A函数中引用了create作用域中的 i 变量
console.log(i);
}
}
var c = create(); // c是一个函数,就是create返回的函数A
c(); // 函数执行
c(); // 再次执行
c(); // 第三次执行
c=null; // 解除引用,释放内存
从上面的"每个函数都有自己的执行环境"可以知道:create()函数是一个执行环境,函数A(create返回的函数)也是一个执行环境,且函数A的执行环境在create()的里面。这样就形成了一个作用域链:window->create->A。当执行c(),其实就是调用函数A,现在就处在A的执行环境中,函数A就会先在自己的执行环境中寻找变量i,可是没有找到,那么只能顺着作用域链向后找;在create()的执行环境中找到了,那么就可以使用了变量i了。
不过还有一个问题,按照之前的说法,函数create()执行完毕后,它执行环境就会被销毁回收,里面的变量和方法应该被销毁了,可是为什么这里的 i 被保存了下来并实现了累加的效果呢。这就是闭包的独特之处。
从create的角度看,函数create()执行完毕后,虽然它的作用链 window -> create 这个作用链会消失,但是其返回的A函数(被c引用)的作用链中依然还引用着create中的 i 变量,所以其实还存在着window->create->A这一作用链,导致垃圾回收机制不能回收create()的变量对象,create()的变量对象仍然停留在内存中。除非window->create->A这一作用链不再存在,也就是直到函数A[c]被销毁后,create作用域中的变量对象才会被销毁。所以需要手动解除引用,加一句 c=null,这样A就会被销毁回收,最后剩余的作用链也不再存在,create的执行环境及其中相关变量才会被回收释放。
从上面的分析中可以看到,闭包会携带包含它的函数的作用域(比如上面因为 i 生成的闭包导致create的执行环境及其变量对象都被保留下来),因此会比其他函数占用更多的内存。当页面中存在过多的闭包,或者闭包的嵌套很多很深时,会导致内存占用过多。另外,如果闭包使用结束后没有及时地手动解除引用,释放掉这些内存,这些内存就会一直被存下来无法被回收,导致内存泄漏 。因此,在这里建议:慎用闭包。