前言

像C语言这样的底层语言一般都有底层的内存管理接口。而对于JavaScript来说,会在创建变量时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收。 因为自动垃圾回收机制的存在,让大多Javascript开发者感觉他们可以不关心内存管理,所以会在一些情况下导致内存泄漏。

一、内存结构

内存分为堆(heap)和栈(stack)。

栈:静态内存分配

栈是JavaScript用来存储静态数据的数据结构。静态数据是引擎在编译时知道大小的数据。在JavaScript中,这包括原始值(字符串、数字、布尔值、undefined和null)和指向对象和函数的引用。

由于引擎知道大小不会改变,它将为每个值分配固定数量的内存。

在执行之前分配内存的过程称为静态内存分配。

堆:动态内存分配

堆是存储数据的不同空间,JavaScript在其中存储对象和函数。

与堆栈不同,引擎不会为这些对象分配固定数量的内存。相反,将根据需要分配更多空间。

以这种方式分配内存也称为动态内存分配。

在访问数据时,如果是引用数据类型,先从栈内寻找相应数据的存储地址,再根据获得的地址,找到堆内该变量真正存储的内容读取出来。

在这幅图中,我们可以观察到不同的值是如何存储的。注意person和newPerson如何指向同一个对象。

举例说明:

const person = {name: 'John',age: 24,
};

这将在堆中创建一个新对象,并在堆栈中创建对它的引用。

一种特殊情况:闭包变量是存在堆内存中的

function count() {let num = -1;return function() {num++;return num;}
}
count()(); // 0

上述闭包,在调用count函数时,创建num变量,return时清空栈,如果闭包变量是存在栈中,那return的时候就被清空了,整个输出结果就不是0了,所以闭包变量是存在堆内存中的。

二、对象及数组的存储

在JS中,一个对象可以任意添加和移除属性,似乎没有限制(实际上需要不能大于 2^32 个属性)。而JS中的数组,不仅是变长的,可以随意添加删除数组元素,每个元素的数据类型也可以完全不一样,更不一般的是,这个数组还可以像普通的对象一样,在上面挂载任意属性,这都是为什么呢?

Object 存储

首先了解一下,JS是如何存储一个对象的。

JS在设计复杂类型存储的时候面临的最直观的问题就是,选择一种数据结构,需要在读取,插入和删除三个方面都有较高的性能。

数组形式的结构,读取和顺序写入的速度最快,但插入和删除的效率都非常低下;

链表结构,移除和插入的效率非常高,但是读取效率过低,也不可取;

复杂一些的树结构等等,虽然不同的树结构有不同的优点,但都绕不过建树时较复杂,导致初始化效率低下;

综上所属,JS 选择了一个初始化,查询和插入删除都能有较好,但不是最好的性能的数据结构 -- 哈希表。

哈希表

哈希表存储是一种常见的数据结构。所谓哈希映射,是把任意长度的输入通过散列算法变换成固定长度的输出。

对于一个 JS 对象,每一个属性,都按照一定的哈希映射规则,映射到不同的存储地址上。在我们寻找该属性时,也是通过这个映射方式,找到存储位置。当然,这个映射算法一定不能过于复杂,这会使映射效率低下;但也不能太简单,过于简单的映射方式,会导致无法将变量均匀的映射到一片连续的存储空间内,而造成频繁的哈希碰撞。

对象生命周期

当创建一个对象时,JavaScript 会自动为该对象分配适当的内存。从这一刻起,垃圾回收器就会不断对该对象进行评估,以查看它是否仍是有效的对象。

垃圾回收器定期扫描对象,并计算引用了每个对象的其他对象的数量。如果一个对象的引用数量为 0(没有其他对象引用过该对象),或对该对象的惟一引用是循环的,那么该对象的内存即可回收。

Array 存储

JS 的数组为何也比其他语言的数组更加灵活呢?因为 JS 的 Array 的对象,就是一种特殊类型的数组!

所谓特殊类型,就是指在 Array 中,每一个属性的 key 就是这个属性的 index;而这个对象还有 .length 属性;还有 concat, slice, push, pop 等方法;

于是这就解释了:

  • 为何 JS 的数组每个数据类型都可以不一样?因为他就是个对象,每条数据都是一个新分配的类型连入链表中;

  • 为何 JS 的数组无需提前设置长度,是可变数组?答案同上;

  • 为何数组可以像 Object 一样挂载任意属性?因为他就是个对象;

  • 为何数组可以直接根据索引取得对应的元素,不管取第1个值还是第n个值的速度都是一样的(时间复杂度都是 O(1));

等等一系列的问题。

三、内存生命周期

JS 环境中分配的内存有如下生命周期:

  1. 内存分配:当我们声明变量、函数、对象的时候,系统会自动为他们分配内存
  2. 内存使用:即读写内存,也就是使用变量、函数等
  3. 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在, 当函数运行结束,没有其他引用,那么该变量会被标记回收。

全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。

四、垃圾回收

垃圾回收最常用的方法是:引用计数垃圾回收和标记清除算法。

引用计数垃圾收集

这是最初级的垃圾回收算法。

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。

 var o = { a: {b:2}}; // 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o// 很显然,没有一个可以被垃圾收集var o2 = o; // o2变量是第二个对“这个对象”的引用o = 1;      // 现在,“这个对象”的原始引用o被o2替换了var oa = o2.a; // 引用“这个对象”的a属性// 现在,“这个对象”有两个引用了,一个是o2,一个是oao2 = "yo"; // 最初的对象现在已经是零引用了// 他可以被垃圾回收了// 然而它的属性a的对象还在被oa引用,所以还不能回收oa = null; // a属性的那个对象现在也是零引用了// 它可以被垃圾回收了

由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。

如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。

来看一个循环引用的例子:

function f(){var o = {};var o2 = {};o.a = o2; // o 引用 o2o2.a = o; // o2 引用 o  这里return "azerty";
}f();

上面我们申明了一个函数 f ,其中包含两个相互引用的对象。 在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。 但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

再来看一个实际的例子:

var div = document.createElement("div");
div.onclick = function() {console.log("click");
};

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。 此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。 一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。

为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。

标记清除算法

标记清除算法将“不再使用的对象”定义为“无法达到的对象”。 简单来说,就是从根对象(浏览器中的根是window对象,而在NodeJS中是global对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的。 那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

工作流程:

  1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
  2. 从根部出发将能触及到的对象的标记清除。
  3. 那些还存在标记的变量被视为准备删除的变量。
  4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。

再看之前循环引用的例子:

function f(){var o = {};var o2 = {};o.a = o2; // o 引用 o2o2.a = o; // o2 引用 oreturn "azerty";
}f();

函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取他们的引用。 因此,他们将会被垃圾回收器回收。

五、V8引擎内存限制

谷歌浏览器的V8引擎只能使用系统的一部分内存,具体来说,在64位系统下,V8最多只能分配1.4G, 在 32 位系统中,最多只能分配0.7G。

V8 为什么要给它设置内存上限?明明我的机器大几十G的内存,只能让我用这么一点?

究其根本,是由两个因素所共同决定的,一个是JS单线程的执行机制,另一个是JS垃圾回收机制的限制。

首先JS是单线程运行的,这意味着一旦进入到垃圾回收,那么其它的各种运行逻辑都要暂停; 另一方面垃圾回收其实是非常耗时间的操作,V8 官方是这样形容的:

以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式的垃圾回收甚至要 1s 以上。

可见其耗时之久,而且在这么长的时间内,我们的JS代码执行会一直没有响应,造成应用卡顿,导致应用性能和响应能力直线下降。因此,V8 做了一个简单粗暴的选择,那就是限制堆内存,也算是一种权衡的手段,因为大部分情况是不会遇到操作几个G内存这样的场景的。

不过,如果你想调整这个内存的限制也不是不行。配置命令如下:

// 这是调整老生代这部分的内存,单位是MB。后面会详细介绍新生代和老生代内存
node --max-old-space-size=2048 xxx.js 

V8 把堆内存分成了两部分进行处理——新生代内存和老生代内存。顾名思义,新生代就是临时分配的内存,存活时间短, 老生代是常驻内存,存活的时间长。V8 的堆内存,也就是两个内存之和。

六、内存泄露

内存泄漏,指任何对象在你不再拥有或需要它之后未能释放仍然存在,造成内存的浪费。

内存泄漏的识别方法

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。 这就要求实时查看内存的占用情况。

在 Chrome 浏览器中,我们可以这样查看内存占用情况

  1. 打开开发者工具,选择 Performance 面板
  2. 在顶部勾选 Memory
  3. 点击左上角的 record 按钮
  4. 在页面上进行各种操作,模拟用户的使用情况
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况

来看一张效果图:

我们有两种方式来判定当前是否有内存泄漏:

  1. 多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏
  2. 某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏

使用 Chrome 浏览器控制台 Memory 提供的 Heap Profile 管理内存

在服务器环境中使用 Node 提供的 process.memoryUsage 方法查看内存情况

console.log(process.memoryUsage());
// {
//     rss: 27709440,
//     heapTotal: 5685248,
//     heapUsed: 3449392,
//     external: 8772
// }

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。

该对象包含四个字段,单位是字节,含义如下:

  • rss(resident set size):所有内存占用,包括指令区和堆栈。
  • heapTotal:"堆"占用的内存,包括用到的和没用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎内部的 C++ 对象占用的内存。

判断内存泄漏,以 heapUsed 字段为准。

常见的内存泄露案例

意外的全局变量

在浏览器的JavaScript中,如果省略var、const或let,变量将附加到window对象。

users = getUsers();

除了意外地将变量添加到根对象之外,在许多情况下,您可能会故意这样做。

您当然可以使用全局变量,但请确保在不再需要数据时释放空间。

要释放内存,请将全局变量指定为null。

window.users = null;

被遗忘的定时器和回调函数

被遗忘的定时器

const object = {};
const intervalId = setInterval(function() {doSomething(object);
}, 2000);

上面的代码每2秒运行一次函数。如果您的项目中有这样的代码,您可能不需要一直运行此代码。

只要interval没有取消,interval中引用的对象就不会被垃圾回收。

一旦不再需要,一定要清除interval。

clearInterval(intervalId);

这在 SPA 项目中尤其重要。即使离开需要此interval的页面,它仍将在后台运行。

被遗忘的回调函数

当您不再需要事件侦听器时,最好删除它们:

const element = document.getElementById('button');
const onClick = () => alert('hi');element.addEventListener('click', onClick);element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

DOM 引用

const elements = [];
const element = document.getElementById('button');
elements.push(element);function removeAllElements() {elements.forEach((item) => {document.body.removeChild(document.getElementById(item.id))});
}

当您删除这些元素中的任何一个时,您可能需要确保也从数组中删除这个元素。

否则,无法收集这些DOM元素。

const elements = [];
const element = document.getElementById('button');
elements.push(element);function removeAllElements() {elements.forEach((item, index) => {document.body.removeChild(document.getElementById(item.id));++ elements.splice(index, 1); // 增加这一行});
}

由于每个DOM元素也保留对其父节点的引用,因此将阻止垃圾回收器收集元素的父节点和子节点。

七、视图类型(连续内存)

通过上面的介绍可以知道,我们使用的数组实际上是伪数组。这种伪数组给我们的操作带来了极大的方便性,但这种实现方式也带来了另一个问题,及无法达到数组快速索引的极致,像文章开头时所说的上百万的数据量的情况下,每次新添加一条数据都需要动态分配内存空间,数据索引时都要遍历链表索引造成的性能浪费会变得异常的明显。

好在 ES6 中,JS 新提供了一种获得真正数组的方式:ArrayBuffer,TypedArray 和 DataView。

ArrayBuffer

ArrayBuffer 代表分配的一段定长的连续内存块。但是我们无法直接对该内存块进行操作,只能通过 TypedArray 和 DataView 来对其操作。

TypedArray

TypeArray 是一个统称,他包含 Int8Array / Int16Array / Int32Array / Float32Array等等。

DataView

DataView 相对 TypedArray 来说更加的灵活。每一个 TypedArray 数组的元素都是定长的数据类型,如 Int8Array 只能存储 Int8 类型;但是 DataView 却可以在传递一个 ArrayBuffer 后,动态分配每一个元素的长度,即存不同长度及类型的数据。

八、参考