进阶篇:内存泄漏
内存泄漏是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
内存泄漏缺陷具有隐蔽性、积累性的特征,比其他内存非法访问错误更难检测。因为内存泄漏的产生原因是内存块未被释放,属于遗漏型缺陷而不是过错型缺陷。此外,内存泄漏通常不会直接产生可观察的错误症状,而是逐渐积累,降低系统整体性能,极端的情况下可能使系统崩溃。
对于内存泄露的检测,Chrome 提供了性能分析工具 Performance 和 Memory 看板,可以比较方便的查看内存的占用情况等。
内存管理机制
像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc() 和 free()。
相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。
不管什么程序语言,内存生命周期基本是一致的:
- 分配需要的内存;
- 使用分配到的内存(读、写);
- 不需要时将其释放、归还;
垃圾回收机制会定期(周期性)找出那些不再继续使用的变量,然后释放其内存,注意,垃圾回收的时间间隔,各个浏览器互不相同。
常用的两种回收方法:引用计数、标记清除。
引用计数法
引用计数是最初级的垃圾收集算法,它用一张表保存了内存里面所有的值(通常是引用类型)的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到了,因此可以将这块内存释放。
var o = { // +1,引用次数 1
a: {
b:2
}
}
var o2 = o // +1,引用次数 2
var oa = o2.a // +1,引用次数 3
o = 1 // -1,引用次数 2
o2 = "yo" // -1,引用次数 1
oa = null // -1,引用次数 0,可以释放内存
如果一个值不再需要了,引用数却不为 0,垃圾回收机制无法释放这块内存,从而导致内存泄漏:
var arr = [1, 2, 3, 4]
console.log('引用次数 1')
解决方法: 当不需要用到 arr 时,手动设置 arr = null ,这样 [1, 2, 3, 4, ..., 100000] 的引用次数变成了 0,内存就可以释放出来了。
引用计数法的缺陷是,在循环的情况下,会造成内存泄漏:
function fn () {
let obj1 = { }
let obj2 = { }
obj1.a = obj2
obj2.b = obj1
}
fn()
标记清除法
标记清除当变量进入执行环境时标记为“进入环境”,当变量离开执行环境时则标记为“离开环境”,被标记为“进入环境”的变量是不能被回收的,因为它们正在被使用,而标记为“离开环境”的变量则可以被回收。
环境可以理解为我们的作用域,但是全局作用域的变量只会在页面关闭才会销毁。
var b = 2 // b 被标记进入全局环境
function t() {
let a = 1 // 函数执行时,a 被标记进入环境
return a + b
}
t() // 函数执行结束,a 被标记离开环境,被回收,但是 b 就没有被标记离开环境
如何发现内存泄漏?
定位内存问题之前,先梳理一遍的代码,看一下哪部分内存的升高是合理的,哪部分内存的升高是不合理的,避免把一个正常的内存增长当作内存泄漏来排查。比如,在开发过程中,对部分数据进行缓存,这部分缓存的数据其实也会导致内存占用的升高,但它是符合预期的。
然后,可以用 Chrome 开发者工具的 Performance 面板和 Memory 面板查看页面是否存在内存泄漏问题。
Performance(性能)
当怀疑页面发生了内存泄漏的时候,可以先用 Performance 录制一段时间内页面的性能变化。
- 打开开发者工具,选择 Performance 面板;
- 在顶部勾选 "内存";
- 点击左上角的 "录制" 按钮(每次开始前都要点击垃圾回收);
- 在页面上进行各种操作,模拟用户的使用情况;
- 一段时间后,点击对话框的 "停止" 按钮;
- 勾选 "JS堆",查看这段时间的内存占用情况。

之后,可用以下两种方式来判定当前是否有内存泄漏:
- 多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏;
- 某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏。
如果存在内存泄漏,就使用 Memory 看板进一步分析。
Memory(内存)
拍摄内存快照,找到内存泄漏的原因。
- 打开开发者工具,选择 Memory 面板;
- 选择 "堆快照";
- 点击左上角的 "拍摄堆快照" 按钮(先点击下垃圾回收),生成快照 1;
- 操作疑似泄漏的操作再生成快照 2;
- 切换到比较,观察 "增量" 列增长较大的对象,可能存在内存泄漏的问题;
- 重复以上的操作,直到定位到具体的原因。

拍摄快照中,尽可能使用 production 模式编译出来的代码(避免热更新等占用的内存)、屏蔽浏览器插件( vue-devtools 等)。
哪些操作会造成内存泄漏?
对 Javascript 而言,用户一般不会在一个 Web 页面停留比较久,即使有一点内存泄漏,重载页面内存也会跟着释放,并且浏览器也有自动回收内存的机制。因此,Javascript 并没有像 C、C++ 这类语言一样,特别关注内存泄漏的问题。
一般来说,我们只需要意识到一点:JavaScript 的内存回收机制虽然能自动回收绝大部分的垃圾内存,但是还是存在回收不了的情况。
意外的全局变量
全局变量的生命周期最长,直到页面销毁前,它都存活着,所以全局变量上的内存一直都不会被回收。
由于 JavaScript 对未声明变量的处理方式是在全局对象上创建该变量的引用。如果在浏览器中,全局对象就是 window 对象。变量在窗口关闭或重新刷新页面之前都不会被释放,如果未声明的变量缓存大量的数据,就会导致内存泄露。
function fn() {
c = 2
}
// 相当于
function fn() {
this.c = 2 // 浏览器下,this 指向 window
}
被遗忘的计时器
setTimeout 和 setInterval 是由浏览器定时器触发线程来维护它的生命周期,所以当在某个页面使用了定时器,当该页面销毁时,没有手动去释放清理这些定时器的话,那么这些定时器还是存活着的。
也就是说,定时器的生命周期并不挂靠在页面上,所以当在当前页面的 JavaScript 里通过定时器注册了某个回调函数,而该回调函数内又持有当前页面某个变量或某些 DOM 元素时,就会导致即使页面销毁了,由于定时器持有该页面部分引用而造成页面无法正常被回收,从而导致内存泄漏了。
如果此时再次打开同个页面,内存中其实是有双份页面数据的,如果多次关闭、打开,那么内存泄漏会越来越严重。而且这种场景很容易出现,因为使用定时器的人很容易遗忘清除。
// 页面关闭后,定时器没有清除,内部占用的内存无法释放
setInterval(function() {
var dom = document.getElementById('id');
if(dom) {
dom.innerHTML = 'massData...'
}
}, 1000)
解决方法: 在定时器完成工作后,手动清除定时器。
被遗忘的回调函数
当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。
window.addEventListener("resize", doSomething)
var button = document.getElementById('button')
function onClick(event) {
button.innerHTML = 'text'
}
button.addEventListener('click', onClick)
解决方法: 手动移除 DOM 事件监听。
脱离 DOM 的引用
DOM 元素的生命周期正常是取决于是否挂载在 DOM 树上,当从 DOM 树上移除时,也就可以被销毁回收了。
但如果某个 DOM 元素,在 JavaScript 中也持有它的引用时,那么它的生命周期就由 JavaScript 和是否在 DOM 树上两者决定了,因此,移除时,两个地方都需要清理才能正常回收它。
var dom = document.getElementById('id')
document.body.removeChild(dom) // DOM 树中移除 dom,但 dom 变量还在,内存无法释放
解决方法: 手动设置 dom = null。
闭包
函数本身会持有它定义时所在的词法环境的引用,但通常情况下,使用完函数后,该函数所申请的内存都会被回收了。但当函数内再返回一个函数时,由于返回的函数持有外部函数的词法环境,而返回的函数又被其他生命周期所持有,导致外部函数虽然执行完了,但内存却无法被回收。
function fn () {
var a = "I'm a"
return function () {
console.log(a)
}
}
解决方法: 将事件处理函数定义在外部,或者在定义事件处理函数的外部函数中。
// bad
for (var k = 0; k < 10; k ++) {
var t = function (a) { console.log(a) } // 函数对象创建了10次
t(k)
}
// good
function t(a) { console.log(a) }
for (var k = 0; k < 10; k ++) {
t(k)
}
console.log
console.log 向 Web 开发控制台打印一条消息,常用来在开发时调试分析。有时在开发时,需要打印一些对象信息,但发布时却忘记去掉 console.log 语句,这可能造成内存泄露。
在传递给 console.log 的对象是不能被垃圾回收,因为在代码运行之后,需要在开发工具能查看对象信息。
Javascript如何释放内存?
在 JavaScript 中,数据类型分为两类:基本类型和引用类型。
- 基本类型: 基本类型保存在栈内存。在栈区中使用的变量等是通过值访问,当变量指向另一个值了,意味着原值没有其他引用了,原值所占的内存会被自动释放。
- 引用类型: 引用类型的对象保存在堆内存,栈内存中存放指向对象的指针。引用类型的变量实际是指向栈内存中的指针,当该变量删除或者赋值其他值后,其原来存在堆内存中的对象,还可能被其他变量引用,不能直接销毁。此时就需要通过算法计算该堆区变量是否属于不再需要的变量,从而决定是否需要进行内存回收。
栈内存保存基本类型的值、指向引用类型的指针,内存大小是固定的,由操作系统自动释放,使用的是一级缓存,被调用时处于存储空间,调用完毕立刻释放。
而堆内存保存的是引用类型的值,如数组和对象,内存大小是不固定的,系统无法进行自动释放,需要垃圾回收算法来决定,再由 JavaScript 引擎来手动的释放这些内存。
Javascript强引用和弱引用
在 JavaScript 的垃圾回收机制中,会通过一些回收算法,例如,引用计数法和标记清除法,来找出不再使用引用的变量或属性,由 JavaScript 引擎按照固定时间间隔周期性的释放其所占的内存空间。
因此,JavaScript 中的对象或属性是否存活与它们的引用相关。
强引用
JavaScript 中最常见的将一个对象赋值给一个变量或者其他对象的属性,这种情况就是强引用。
强引用是每次引用一个对象,该对象的引用计数会加 1,只有引用计数为 0 时, 也就是没有任何变量和其它对象的属性引用时,该对象的内存才会被回收。
var refer = { a: 1 }
var a = refer
var b = {
c: refer.a
}
以上示例,只有将引用 { a: 1 } 对象的变量或对象属性一一手动清除, { a: 1 } 占用的内存才能被释放。
refer = null
a = null
b.c = null
弱引用
弱引用是在引用对象时,不会触发对象的引用计数加 1,当该对象只被弱引用引用时,该对象占用的内存会自动释放。因此,使用弱引用可以防止内存泄漏。
ES6 新引入多种数据类型,其中就包括 WeakSet 和 WeakMap 两种弱引用类型。它们对值的引用都是不计入垃圾回收机制的,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存。
以 WeakMap 为例:
WeakMap 结构与 Map 结构类似,也是用于生成键值对的集合,它们的区别有两点:
- WeakMap 只接受对象作为键名(null除外),不接受其他类型的值作为键名;
- WeakMap 的键名所指向的对象,不计入垃圾回收机制。
let refer1 = { a: 1 }
const m1 = new Map()
m1.set(refer1, 1)
refer1 = null // 清除引用,但其指向对象还在内存中
for (let value of m1.values()) { // 仍可用 m1.values() 来获取之前的值
console.log(value) // 1
}
let refer2 = { a: 1 }
const m2 = new WeakMap()
m2.set(refer2, 2)
refer2 = null // 清除引用,其指向对象也会自动释放
// m2 的 refer2 键自动消失
WeakMap 没有遍历操作(keys()、values()、entries()),也没有 size 属性。因为没有办法列出所有键名,某个键名是否存在完全不可预测,跟垃圾回收机制是否运行相关。这一刻可以取到键名,下一刻垃圾回收机制突然运行了,这个键名就没了,为了防止出现不确定性,就统一规定不能取到键名。
什么是尾调用?
尾调用是函数式编程的一个重要概念,就是指某个函数的最后一步是调用另一个函数。
function f(x){
return g(x)
}
函数 f 的最后一步是调用函数 g,这就叫尾调用。
以下两种情况,都不属于尾调用。
// 情况一:调用函数 g 之后,还有别的操作,所以不属于尾调用,即使语义完全一样
function f(x){
let y = g(x)
return y
}
// 情况二:调用后还有操作,即使写在一行内
function f(x){
return g(x) + 1
}
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}
尾调用优化
Javascript 代码执行是基于执行栈的,所以当在一个函数里调用另一个函数时,会保留当前的执行上下文,然后再新建另外一个执行上下文加入栈中。使用尾调用的话,因为已经是函数的最后一步,所以可以不必再保留当前的执行上下文,从而节省了内存,这就是尾调用优化。
注意: ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
尾递归
尾递归是尾调用优化的一种特殊场景,函数执行完后,返回的是函数本身。
递归非常耗费内存,因为需要同时保存成千上百个执行栈,很容易发生“栈溢出”错误。但对于尾递归来说,由于只存在一个执行栈,所以永远不会发生“栈溢出”错误。
相关问题
什么是内存溢出?
内存溢出 就是程序运行所需的内存大于可用内存,就出现内存溢出错误。比如,最简单的就是写一个千万级别的循环,然后用浏览器打开,浏览器会非常卡,甚至直接报错内存不足,崩溃了。
内存溢出一般是内存泄漏造成的,占用的内存不需要用到时,没有及时释放,内存泄漏积累的多了,轻的话影响系统性能,严重直接引起内存溢出系统崩溃。