前言
为什么要关注内存呢?
如果有内存溢出,程序占用的内存会越来越大,最终引起程序卡顿,甚至无响应等性能体验问题。这篇文章从内存的数据结构、内存空间管理、内存回收算法、内存泄露场景对内存机制进行一个全方面的介绍。
数据结构
理解数据结构是理解后面内存空间管理
和内存机制
的基础,我们接下来介绍下 JavaScript 中的数据结构的特点和使用场景。
JavaScript 中的数据结构主要有 栈、堆、队列 三种数据结构。
1.栈
栈 是一个后进先出(
LIFO
)的数据结构
可以把栈
想象成是一个杯子一样的容器,我们往杯子里放石头时,每次放的石头都在杯子的顶部;把石头取出来时,杯子顶部的石头(即后放进去的石头)会先被取出来,这就是 后进先出
注:
基本数据类型 的值
和 引用数据类型 的 地址
都存储在 栈 中
2.堆
堆 可以被看作是一棵
有一定规则的完全二叉树
的数组对象
堆有两个特点:
- 堆的某个节点总是不大于或不小于其父节点
- 堆总是一颗
完全二叉树
比如把数组对象 [ 10, 7, 2, 5, 1 ]
用二叉树(从上往下,从左往右)画出来:
是一个 完全二叉树 结构,且每个子节点都不大于父节点的值,是一个堆结构
。根结点最大的堆叫做大根堆
。
我们再把上面的数组反过来 [ 1, 5, 2, 7, 10 ]
,用二叉树画出来,也是一个完全二叉树,且每个子节点都不大于父节点,是一个堆结构
。根结点最小的堆叫做小根堆
。
注:
引用数据类型 的值
存放在 堆 里。
3.队列
队列 是一个先进先出(
FIFO
)的数据结构
队列
很好理解,可以想像成我们排队买肯德基,每次新来的人只能排在队列的最后面,先排队的人先点餐离开,这就是 先进先出
注:
队列是事件循环(Event Loop)的基础数据结构,后面再单独写一篇文章详细分析事件循环。
内存空间管理
JavaScript 的内存生命周期可以分为分配内存、使用内存、释放内存三个阶段。
1.分配内存
当我们声明变量、函数、对象时,系统会自动为他们分配内存。
我们已经知道,JavaScript 有栈内存
和堆内存
两种内存,那 JavaScript 是如何分配内存的呢?
在 变量存储类型 中,我们详细介绍了变量有 基本数据类型
和 引用数据类型
两种类型,系统会根据不同的数据额类型分配不同类型的内存(栈内存
或堆内存
)
- 1)
基本数据类型
的大小固定,系统在编译阶段就为其分配了栈内存
let age = 10; // 分配到栈内存
let name = '谷底飞龙'; // 分配到栈内存
复制代码
- 2)
引用数据类型
的值比较复杂(比如对象里可以包含多个基本类型等),值的大小不固定,因此,值是存储在堆内存
中的,栈内存
中存储的是其引用地址。
// 分配对象 info 的引用地址到栈内存,分配对象 info 及其包含的内容到堆内存
let info = {
name: '谷底飞龙',
age: 10
}
// 分配函数 f() 的引用地址到栈内存,将 f() 的内容以字符串的形式存储到堆内存中
function f(){
console.log('我是函数')
}
复制代码
2.使用内存
当我们读写变量和对象、赋值、调用函数的时候,就是在使用内存
基本数据类型:访问的时候,直接从栈内存中访问,速度比较快
let age = 10; // 分配到栈内存
let name = '谷底飞龙'; // 分配到栈内存
// 访问变量,使用内存
console.log(`my name is ${name}, my age is ${age}`)
复制代码
引用数据类型:访问的时候,先从栈内存
中访问引用地址,再通过地址去堆内存
中找到对应的值,速度会慢一点
// 分配对象 info 的引用地址到栈内存,分配对象 info 及其包含的内容到堆内存
let info = {
name: '谷底飞龙',
age: 10
}
// 访问对象,使用内存
console.log(info);
// 分配函数 f() 的引用地址到栈内存,将 f() 的内容以字符串的形式存储到堆内存中
function f(){
console.log('我是函数')
}
// 调用函数,使用内存
f();
复制代码
3.释放内存
JavaScript 有
自动垃圾收集机制
,垃圾收集器会每隔一段时间就执行一次释放操作,找出那些不再继续使用的值,然后释放其占用的内存
局部变量:函数内部定义的局部变量,在函数执行完成后,就会自动释放
function f() {
// 在函数内部定义变量 name 和对象 info
var name = '谷底飞龙'
var info = { name }
/* 函数执行结束,name、info占用的内存就会自动释放 */
}
// 执行函数
f();
复制代码
全局变量:全局变量什么时候需要自动释放内存空间则很难判断,所以在开发中尽量避免使用全局变量。
内存回收算法
早期浏览器的内存回收算法是引用计数算法
,2012年之后,所有的浏览器都改成标记清除算法
1.引用计数算法(旧算法)
记录每个值被引用的次数,当某个值被引用一次(赋值、被别的对象持用等),就将计数器
+1
,如果引用被释放,则计数器-1
,当引用数为0
时,表示这个值不再使用了,判定可以进行释放
引用计数算法有一个弊端:循环引用
var obj1 = {name: '谷底飞龙'};
var obj2 = {age: 28};
obj1.a = obj2; // obj1 持有 obj2
obj2.b = obj1; // obj2 又持有 obj1
// 验证是否存在循环引用
console.log(JSON.stringify(obj1));
复制代码
obj1
和 obj2
相互持有,就会存在循环引用的问题,使用引用计数算法,计数器永远不可能为0,也就是存在循环引用的对象的内存不会被回收。
注:
可以使用JSON.stringify()
来判断对象是否存在循环引用,如果存在,则会报错
2.标记清除算法(新算法)
标记清除算法
也可以理解为基于执行环境的清除算法,当进入执行环境时,标记为进入环境
,离开执行环境时,标记为离开环境
。
每个
执行环境
关联一个保存该环境所有变量和函数的变量对象
,当离开该执行环境时,即该环境内所有的代码执行完成时,该变量对象就会被销毁,执行环境的变量和函数占用的内存被回收
全局执行环境:JavaScript 中的全局执行环境是最外围的一层环境,在浏览器中,全局环境是window
,全局变量和全局函数都是挂在window下的,只有当程序退出或者关闭浏览器时,全局执行环境才会被销毁。
局部执行环境:一般是指函数执行环境
,每个函数在被调用的时候,都会产生一个局部执行环境,当函数执行完成的时候,函数执行环境就会被销毁。
function f(){
var name = '谷底飞龙'; // 标记为 “进入环境”
console.log(name);
}
f(); // 函数执行完成,name 被标记为“离开环境”,回收内存
复制代码
对执行环境理解不是很清楚的,建议看下我的这篇文章 一文吃透执行上下文和执行栈
使用标记清除算法
进行内存回收,可以解决循环引用的问题。
内存泄漏场景
简单的理解,内存泄露就是本应被销毁的变量,由于代码编写不当导致没有被回收内存
1.意外的全局变量
- 函数内部局部变量
未定义
,导致意外的变成全局变量
"use strict"
function f() {
// name 未定义,默认是全局变量,被挂在浏览器的全局对象 window 上
name = '谷底飞龙';
}
// 会打印出 ‘谷底飞龙’,说明 name 未定义时,被变成全局变量了
console.log(window.name);
复制代码
这里的 name
未定义,实际上会被挂在全局对象上(浏览器上的全局对象是window
),无法随着函数执行环境销毁,导致内存泄露。
this
导致的意外的全局变量
function f() {
// 这里 this 指向的是全局对象 window
this.name = '谷底飞龙';
}
f();
// 会打印出 ‘谷底飞龙’
console.log(window.name);
复制代码
这里调用函数 f()
时,等价于 window.f()
,因此,函数内部的this
指向的是全局对象window
,变量 name
变成全局变量。
解决方法:在代码文件头部使用use strict
,此时函数内部的 this
指向的是undefined
,执行时就会给出报错提示
"use strict"
function f() {
// 这里 this 指向的是 undefined
this.name = '谷底飞龙';
}
f();
复制代码
2.被遗忘的定时器或观察者
- 当不需要
setInterval
或者setTimeout
时,定时器没有被clear
,定时器的回调函数以及内部依赖的变量都不能被回收,导致内存泄露。
var someResource = getData();
setInterval(function() {
var node = document.getElementById('Node');
if(node) {
// 处理 node 和 someResource
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
复制代码
当计时器不再需要时,node 对象可以删除,整个回调函数也不需要了。可是,计时器回调函数仍然没被回收(计时器停止才会被回收)。同时,someResource 如果存储了大量的数据,也是无法被回收的。
解决方法:定时器不再需要时(如页面将被销毁等),记得调用clearTimeout()
或clearInterval()
停止计时器。
- 注册了事件监听,在不需要监听时(如页面销毁),没有移除观察者,在老版本的浏览器(使用
引用计数算法
回收内存)会导致内存泄露。
var element = document.getElementById('button');
function onClick(event) {
element.innerHTML = 'text';
}
element.addEventListener('click', onClick);
复制代码
对于观察者和循环引用,在新版本的浏览器(使用标记清除算法
回收内存),已经可以正确检测和处理循环引用了,回收节点内存时,不必非要调用 removeEventListener
了。
3.DOM 的引用
DOM
元素的生命周期取决于是否挂在DOM 树
上,当从DOM树移除了,就可以被销毁回收了。- 但如果
DOM
还被其它 js 对象引用,那DOM
元素的生命周期就取决于DOM 树
和持有它的 js 对象,需要把相关引用都释放掉,不然,就会出现内存泄露
var elements = {
button: document.getElementById('button')
};
function removeButton() {
// 按钮是 body 的后代元素
document.body.removeChild(document.getElementById('button'));
// 此时,仍旧存在一个全局的 #button 的引用
// elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
}
复制代码
如果把DOM 存成字典(JSON 键值对)或者数组,此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。那么将来需要把两个引用都清除。
4.闭包
闭包的关键是
匿名函数
可以访问父级作用域的变量。闭包可以维持函数内局部变量,使其得不到释放。
// 函数内部嵌套函数,形成闭包
function bindEvent() {
var obj = document.createElement('xxx')
obj.onclick = function () {
// Even if it is a empty function
}
}
复制代码
上例定义事件回调时,由于是函数内定义函数,并且内部函数–事件回调引用外部函数,形成了闭包。
解决方法:
- 将事件处理函数定义在外面
// 将事件处理函数定义在外面
function bindEvent() {
var obj = document.createElement('xxx')
obj.onclick = onclickHandler
}
复制代码
- 或者在定义事件处理函数的外部函数中,删除对dom的引用
// 在定义事件处理函数的外部函数中,删除对dom的引用
function bindEvent() {
var obj = document.createElement('xxx')
obj.onclick = function() {
// Even if it is a empty function
}
obj = null
}
复制代码
参考