内存管理
V8 内存限制
限制大小
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 垃圾回收策略
- 采用分代回收的思想
- 内存分为新生代、老生代
- 针对新、老生代采用不同算法来提升垃圾回收的效率
新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
V8 新生代、老生代内存大小
V8 引擎的新生代内存大小 32MB(64 位)、16MB(32 位),老生代内存大小为 1400MB(64 位)、700MB( 32 位)。
新生代对象回收实现
- 回收过程采用复制算法+标记整理
- 新生代内存区被等分为两个空间
- 使用空间为 From,空闲空间为 To
- 标记整理后将活动对象拷贝至 To
- From 和 To 交换空间完成释放
晋升
将新生代对象移到老生代
晋升条件
- 一轮 GC 还存活的新生代需要晋升
- 对象从 From 空间复制到 To 空间时,如果 To 空间已经被使用了超过 25%,那么这个对象直接被复制到老生代
老生代对象回收实现
- 主要采取标记清除、标记整理、增量标记算法
- 首先使用标记清除完成垃圾空间的回收
- 采用标记整理进行空间优化
- 采用增量标记进行效率优化
细节对比
新生代区域,采用复制算法, 因此其每时每刻内部都有空闲空间的存在(为了完成 From 到 To 的对象复制),但是新生代区域空间较小(32M)且被一分为二,所以这种空间上的浪费也是比较微不足道的。
老生代因其空间较大(1.4G),如果同样采用一分为二的做法则对空间大小是比较浪费,且老生代空间较大,存放对对象也较多,如果进行复制算法,则其消耗对时间也会更大。也就是是否使用复制算法来进行垃圾回收,是一个时间 T 关于内存大小的关系,当内存大小较小时,使用复制算法消耗的时间是比较短的,而当内存较大时,采用复制算法对时间对消耗也就更大。
V8 的优化
增量标记
由于全停顿会造成了浏览器一段时间无响应,所以 V8 使用了一种增量标记的方式,将完整的标记拆分成很多部分,每做完一部分就停下来,让 JS 的应用逻辑执行一会,这样垃圾回收与应用逻辑交替完成。经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原来的 1/6 左右
惰性清理
由于标记完成后,所有的对象都已经被标记,不是死对象就是活对象,堆上多少空间格局已经确定。我们可以不必着急释放那些死对象所占用的空间,而延迟清理过程的执行。垃圾回收器可以根据需要逐一清理死对象所占用的内存空间
其他
V8 后续还引入了增量式整理(incremental compaction),以及并行标记和并行清理,通过并行利用多核 CPU 来提升垃圾回收的性能
监控内存
内存问题的外在表现
- 页面出现延迟加载或经常性暂停: 可能存在频繁当 GC 操作,存在一些代码瞬间吃满了内存。
- 页面出现持续性的糟糕性能: 程序为了达到最优的运行速度,向内存申请了一片较大的内存空间,但空间大小超过了设备所能提供的大小。
- 页面使用随着时间延长越来越卡:可能存在内存泄漏。
界定内存问题的标准
- 内存泄漏:内存使用持续升高
- 内存膨胀:在多数设备上都存在性能问题
- 频繁垃圾回收:通过内存变化时序图进行分析
监控内存方式
任务管理器
这里以 Google 浏览器为例,使用 Shift + Esc 唤起 Google 浏览器自带的任务管理器
- Memory(内存) 列表示原生内存。DOM 节点存储在原生内存中。如果此值正在增大,则说明正在创建 DOM 节点。
- JavaScript Memory(JavaScript 内存) 列表示 JS 堆。此列包含两个值。您感兴趣的值是实时数字(括号中的数字)。实时数字表示您的页面上的可到达对象正在使用的内存量。如果此数字在增大,要么是正在创建新对象,要么是现有对象正在增长。
模拟内存泄漏
在任务管理器里可以看到 JavaScript 内存持续上升
document.body.innerHTML = `<button id="add">add</button>`;
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);
}
Timeline 记录内存
这里以 Google 浏览器为例,使用 F12 开启调式,选择 Performance,点击 record(录制),进行页面操作,点击 stop 结束录制之后,开启内存勾选,拖动截图到指定时间段查看发生内存问题时候到页面展示,并定位问题。同时可以查看对应出现红点到执行脚本,定位问题代码。
利用浏览器内存模块,查找分离 dom
这里以 Google 浏览器为例,在页面上进行相关操作后,使用 F12 开启调式,选择 Memory,点击 Take snapshot(拍照),在快照中查找 Detached HTMLElement,回到代码中查找对应的分离 dom 存在的代码,在相关操作代码之后,对分离 dom 进行释放,防止内存泄漏。
只有页面的 DOM 树或 JavaScript 代码不再引用 DOM 节点时,DOM 节点才会被作为垃圾进行回收。如果某个节点已从 DOM 树移除,但某些 JavaScript 仍然引用它,我们称此节点为“已分离”。已分离的 DOM 节点是内存泄漏的常见原因。
模拟已分离 DOM 节点
document.body.innerHTML = `<button id="add">add</button>`;
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;
}
如何确定频繁对垃圾回收
- GC 工作时,程序是暂停的,频繁/过长的 GC 会导致程序假死,用户会感知到卡顿。
- 查看 Timeline 中是否存在内存走向在短时间内频繁上升下降的区域。浏览器任务管理器是否频繁的增加减少。
代码优化
Perf(JavaScript 性能测试)
基于 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 中的面向对象
- JS 不需属性的访问方法,所有属性都是外部可见的
- 使用属性访问方法只会增加一层重定义,没有访问的控制力
上图可以看出,test2 的性能要比 test1 的性能要好不少,从而得知,直接访问属性,会比通过方法访问属性速度来的快。
遍历速度
上图可以看出,loop 遍历速度 forEach > 优化 for > for of > for > for in