本博主会持续更新各种前端的技术,如果各位道友喜欢,可以关注、收藏、点赞下本博主的文章。
像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和 free()。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。
不管什么程序语言,内存生命周期基本是一致的:
所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像 JavaScript 这些高级语言中,大部分都是隐含的。
JavaScript 的内存分配
值的初始化
为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。
const n = 123; // 给数值变量分配内存
const s = 'heath'; // 给字符串分配内存
const o = {
a: 1,
b: null,
}; // 给对象及其包含的值分配内存
// 给数组及其包含的值分配内存(就像对象一样)
const a = [1, null, 'abra'];
function f(a) {
return a + 2;
} // 给函数(可调用的对象)分配内存
// 函数表达式也能分配一个对象
someElement.addEventListener(
'click',
function () {
someElement.style.backgroundColor = 'blue';
},
false,
);
通过函数调用分配内存
有些函数调用结果是分配对象内存:
const d = new Date(); // 分配一个 Date 对象
const e = document.createElement('div'); // 分配一个 DOM 元素
有些方法分配新变量或者新对象:
const s = 'heath';
const s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。
const a = ['heath heath', 'nan nan'];
const a2 = ['generation', 'nan nan'];
const a3 = a.concat(a2);
// 新数组有四个元素,是 a 连接 a2 的结果
使用值
使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
当内存不再需要使用时释放
大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。
高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。
如上文所述自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。
引用
垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。
在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。
可达对象
这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。
实现原理
每个对象在创建的时候,就给这个对象绑定一个计数器。每当有一个引用指向该对象时,计数器加一;每当有一个指向它的引用被删除时,计数器减一。这样,当没有引用指向该对象时,该对象死亡,计数器为 0,这时就应该对这个对象进行垃圾回收操作。
let o = {
a: {
b: 2,
},
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
let o2 = o; // o2变量是第二个对“这个对象”的引用
o = 1; // 现在,“这个对象”只有一个o2变量的引用了,“这个对象”的原始引用o已经没有
let oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa
o2 = 'yo'; // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了
// 但是它的属性a的对象还在被oa引用,所以还不能回收
oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了
限制:循环引用
该算法有个限制:无法处理循环引用的事例。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。
function f() {
let o = {};
let o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
return 'heath';
}
f();
实际例子
IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收。该方式常常造成对象被循环引用时内存发生泄漏:
let div;
window.onload = function () {
div = document.getElementById('myDivElement');
div.circularReference = div;
div.lotsOfData = new Array(10000).join('*');
};
在上面的例子里,myDivElement 这个 DOM 元素里的 circularReference 属性引用了 myDivElement,造成了循环引用。如果该属性没有显示移除或者设为 null,引用计数式垃圾收集器将总是且至少有一个引用,并将一直保持在内存里的 DOM 元素,即使其从 DOM 树中删去了。如果这个 DOM 元素拥有大量的数据 (如上的 lotsOfData 属性),而这个数据占用的内存将永远不会被释放。
优点
缺点
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。
这个算法假定设置一个叫做根(root)的对象(在 Javascript 里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。
这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,参考“循环引用”。
从 2012 年起,所有现代浏览器都使用了标记-清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。
实现原理
循环引用不再是问题了
在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。
限制: 那些无法从根对象查询到的对象都将被清除
尽管这是一个限制,但实践中我们很少会碰到类似的情况,所以开发者不太会去关心垃圾回收机制。
容易产生内存碎片化空间
标记算法并未在清除未标记对象的时候,进行整理,所以在清除标记后,会产生大量的不连续的内存碎片。
当分配的内存大于现有连续的内存碎片,则会提前触发新一轮的垃圾回收动作;
当分配的内存小于现有连续的内存碎片,则可能会造成浪费。
实现原理
小编承接外包,有意者可加
QQ:1944300940
微信号:wxid_g8o2y9ninzpp12
限制大小
64 位为 1.4GB,32 位为 0.7GB
限制原因
V8 之所以限制了内存的大小,表面上的原因是 V8 最初是作为浏览器的 JavaScript 引擎而设计,不太可能遇到大量内存的场景,而深层次的原因则是由于 V8 的垃圾回收机制的限制。由于 V8 需要保证 JavaScript 应用逻辑与垃圾回收器所看到的不一样,V8 在执行垃圾回收时会阻塞 JavaScript 应用逻辑,直到垃圾回收结束再重新执行 JavaScript 应用逻辑,这种行为被称为“全停顿”(stop-the-world)。若 V8 的堆内存为 1.5GB,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1 秒以上。这样浏览器将在 1s 内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。
新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
V8 引擎的新生代内存大小 32MB(64 位)、16MB(32 位),老生代内存大小为 1400MB(64 位)、700MB( 32 位)。
将新生代对象移到老生代
晋升条件
新生代区域,采用复制算法, 因此其每时每刻内部都有空闲空间的存在(为了完成 From 到 To 的对象复制),但是新生代区域空间较小(32M)且被一分为二,所以这种空间上的浪费也是比较微不足道的。
老生代因其空间较大(1.4G),如果同样采用一分为二的做法则对空间大小是比较浪费,且老生代空间较大,存放对对象也较多,如果进行复制算法,则其消耗对时间也会更大。也就是是否使用复制算法来进行垃圾回收,是一个时间 T 关于内存大小的关系,当内存大小较小时,使用复制算法消耗的时间是比较短的,而当内存较大时,采用复制算法对时间对消耗也就更大。
增量标记
由于全停顿会造成了浏览器一段时间无响应,所以 V8 使用了一种增量标记的方式,将完整的标记拆分成很多部分,每做完一部分就停下来,让 JS 的应用逻辑执行一会,这样垃圾回收与应用逻辑交替完成。经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原来的 1/6 左右
惰性清理
由于标记完成后,所有的对象都已经被标记,不是死对象就是活对象,堆上多少空间格局已经确定。我们可以不必着急释放那些死对象所占用的空间,而延迟清理过程的执行。垃圾回收器可以根据需要逐一清理死对象所占用的内存空间
其他
V8 后续还引入了增量式整理(incremental compaction),以及并行标记和并行清理,通过并行利用多核 CPU 来提升垃圾回收的性能
##界定内存问题的标准
这里以 Google 浏览器为例,使用 Shift + Esc 唤起 Google 浏览器自带的任务管理器
模拟内存泄漏
在任务管理器里可以看到 JavaScript 内存持续上升
document.body.innerHTML = ``;
document.getElementById('add').addEventListener('click', function (e) {
simulateMemoryLeak();
});
let result = [];
function simulateMemoryLeak() {
setInterval(function () {
result.push(new Array(1000000).join('x'));
document.body.innerHTML = result;
}, 100);
}
这里以 Google 浏览器为例,使用 F12 开启调式,选择 Performance,点击 record(录制),进行页面操作,点击 stop 结束录制之后,开启内存勾选,拖动截图到指定时间段查看发生内存问题时候到页面展示,并定位问题。同时可以查看对应出现红点到执行脚本,定位问题代码。
这里以 Google 浏览器为例,在页面上进行相关操作后,使用 F12 开启调式,选择 Memory,点击 Take snapshot(拍照),在快照中查找 Detached HTMLElement,回到代码中查找对应的分离 dom 存在的代码,在相关操作代码之后,对分离 dom 进行释放,防止内存泄漏。
只有页面的 DOM 树或 JavaScript 代码不再引用 DOM 节点时,DOM 节点才会被作为垃圾进行回收。 如果某个节点已从 DOM 树移除,但某些 JavaScript 仍然引用它,我们称此节点为“已分离”。已分离的 DOM 节点是内存泄漏的常见原因。
模拟已分离 DOM 节点
document.body.innerHTML = ``;
document.getElementById('add').addEventListener('click', function (e) {
create();
});
let detachedTree;
function create() {
let ul = document.createElement('ul');
for (let i = 0; i < 10; i++) {
let li = document.createElement('li');
ul.appendChild(li);
}
detachedTree = ul;
}
基于 Benchmark.js
上图可以看出,test2 的性能要比 test1 的性能要好,从而得知,全局变量的执行速度,访问速度要低于局部变量
上图可以看出,test2 的性能要比 test1 的性能要好,从而得知,缓存全局变量后使用可以提升性能
上图可以看出,test2 的性能要比 test1 的性能要好,从而得知,通过原型对象添加方法与直接在对象上添加成员方法相比,原型对象上的属性访问速度较快。
闭包特点
function foo() {
let name = 'heath';
function fn() {
console.log(name);
}
return fn;
}
let a = foo();
a();
闭包使用不当很容易出现内存泄漏
function f5() {
// el 引用了全局变量document,假设btn节点被删除后,因为这里被引用着,所以这里不会被垃圾回收,导致内存泄漏
let el = document.getElementById('btn');
el.onclick = function (e) {
console.log(e.id);
};
}
f5();
function f6() {
// el 引用了全局变量document,假设btn节点被删除后,因为这里被引用着,所以这里不会被垃圾回收,导致内存泄漏
let el = document.getElementById('btn');
el.onclick = function (e) {
console.log(e.id);
};
el = null; // 我们这里手动将el内存释放,从而当btn节点被删除后,可以被垃圾回收
}
f6();
JavaScript 中的面向对象
上图可以看出,test2 的性能要比 test1 的性能要好不少,从而得知,直接访问属性,会比通过方法访问属性速度来的快。
上图可以看出,loop 遍历速度 forEach > 优化 for > for of > for > for in
上图可以看出,节点克隆(cloneNode)生成节点速度要快于创建节点。
上图可以看出,字面量声明的数据生成速度要快于单独属性赋值行为生成的数据。