理解闭包与内存泄漏

闭包,是指有权访问另一个函数作用域中变量的函数

文章转载自前海拾贝

闭包的定义

从定义上我们可以知道,闭包是函数,并且是被另一个函数包裹的函数。所以需要用一个函数去包裹另一个函数,即在函数内部定义函数被包裹的函数则称为闭包函数包裹的函数(外部的函数)则为闭包函数提供了一个闭包作用域,所以形成的闭包作用域的名称为外部函数的名称

我们先来看一个常见的闭包例子,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
let foo;
function outer() {
// outer函数内部为闭包函数提供一个闭包作用域(outer)
let bar = "bar";
let inner = function () {
console.log(bar);
debugger; // 打一个debuuger断点,以便查看闭包作用域
console.log("inner function run.");
};
return inner;
}
foo = outer(); // 执行外部函数返回内部函数
foo(); // 执行内部函数

我们在浏览器上执行该段代码后,会停在断点位置,此时我们可以看到形成的闭包作用域如图所示:

img01

从图中我们可以看到,形成的闭包作用域名称为外部的 outer 函数提供的作用域,闭包作用域内有一个变量 bar 可以被闭包函数访问到。

形成闭包的条件

从上面的闭包例子在,看起来形成的闭包的条件就是,一个函数被另一个函数包裹,并且返回这个被包裹的函数供外部持有。其实,闭包函数是否被外部变量持有并不重要,形成闭包的必要条件就是,闭包函数(被包裹的函数)中必须要使用到外部函数中的变量

1
2
3
4
5
6
7
8
9
10
11
function outer() {
// outer函数内部为闭包函数提供一个闭包作用域(outer)
let bar = "bar";
let inner = function () {
console.log(bar);
debugger;
console.log("inner function run.");
};
inner(); // 直接在外部函数中执行闭包函数inner
}
outer();

我们稍微修改一下上面的例子,外部函数 outer 不将内部函数 inner 返回,而是直接在 outer 内执行

img02

从执行结果可以看到,仍然形成了闭包,所以说这个被包裹的闭包函数是否被外部持有并不是形成闭包的条件

1
2
3
4
5
6
7
8
9
10
11
function outer() {
// outer函数内部为闭包函数提供一个闭包作用域(outer)
let bar = "bar";
let inner = function () {
// console.log(bar); // 注释该行,内部inner函数不再使用外部outer函数中的变量
debugger;
console.log("inner function run.");
};
inner(); // 直接在外部函数中执行闭包函数inner
}
outer();

我们再修改一下上面的例子,将console.log(bar)这行代码注释掉,这样inner 函数中将不再使用外部 outer 函数中的变量

img03

从执行结果上可以看到,没有形成闭包。所以形成闭包的必要条件就是,被包裹的闭包函数必须使用外部函数中的变量

当然上面的结论也太过绝对了些,因为外部函数可以同时包裹多个闭包函数,也就是说,**(外部)函数内部定义了多个函数,这种情况下,就不需要每个闭包函数都使用到外部函数中的变量,因为闭包作用域是内部所有闭包函数共享的只要有一个内部函数使用到了外部函数中的变量即可形成闭包**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function outer() {
// outer函数内部为闭包函数提供一个闭包作用域(outer)
let bar = "bar";
let unused = function () {
console.log(bar); // 再创建一个闭包函数,并在其中使用外部函数中的变量
};
let inner = function () {
// console.log(bar); // 注释该行,内部inner函数不再使用外部outer函数中的变量
debugger;
console.log("inner function run.");
};
inner(); // 直接在外部函数中执行闭包函数inner
}
outer();

我们继续修改一下上面的例子,在 outer 函数内部再创建一个 unused 函数,这个函数只是定义但不会执行,同时unused 函数内部使用了外部 outer 函数中的变量,inner 函数仍然不使用外部 outer 函数中的变量。

img04

从执行结果可以看到,又形成了闭包。所以形成的闭包条件就是,存在内部函数中使用外部函数中定义的变量

内存泄漏

内存泄漏常常与闭包紧紧联系在一起,很容易让人误以为闭包就会导致内存泄漏。其实闭包只是让内存常驻,而滥用闭包才会导致内存泄漏
内存泄漏,从广义上说就是,内存在使用完毕之后对于不再要的内存没有及时释放或者无法释放不再需要的内存使用完毕之后肯定需要释放掉,否则这个块内存就浪费掉了,相当于内存泄漏了。但是在实际中,往往不会通过判断该内存或变量是否不再需要使用来判断。因为内存测试工具很难判断该内存是否不再需要。所以我们通常会重复多次执行某段逻辑链路,然后每隔一段时间进行一次内存 dump,然后判断内存是否存在不断增长的趋势,如果存在,则可用怀疑存在内存泄漏的可能。

内存 dump

浏览器中抓取内存的 dump 相对来说简单些,直接通过谷歌浏览器的调试工具找到 memory 对应的 tab 页面,然后点击 Load即可开始抓取内存 dump,如:

img05

在 NodeJS 中,我们也可以通过引入 heapdump 来抓取内存 dump,直接通过 npm 安装 heapdump 模块即可。

1
npm install heapdump

安装完成之后,即可直接在应用程序中使用了,用法非常简单,如:

1
2
3
4
5
6
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump

// 应用code部分

heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

应用程序执行完成后,会在应用根目录中生成 start.heapsnapshot 和 end.heapsnapshot 两个内存 dump 文件,我们可以通过判断两个文件的大小变化来判断是否存在内存泄漏

当然并不是说内存 dump 文件的大小不断增大就存在内存泄漏,如果应用的访问量确实在一直增大,那么内存曲线只增不减也属于正常情况,我们只能根据具体情况判断是否存在内存泄漏的可能

常见的内存泄露

闭包循环引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump
let foo = null;
function outer() {
let bar = foo;
function unused() {
// 未使用到的函数
console.log(`bar is ${bar}`);
}

foo = {
// 给foo变量重新赋值
bigData: new Array(100000).join("this_is_a_big_data"), // 如果这个对象携带的数据非常大,将会造成非常大的内存泄漏
inner: function () {
console.log(`inner method run`);
},
};
}
for (let i = 0; i < 1000; i++) {
outer();
}
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

在这个例子中,执行了 1000 次 outer 函数,start.heapsnapshot 文件的大小为 2.4M,而 end.heapsnapshot 文件的大小为 4.1M,所以可能存在内存泄漏。
前面讲解闭包的过程中,我们已经可以知道 outer 函数内部是存在闭包的,因为outer 函数内部定义了 unused 和 inner 两个函数,虽然 inner 函数中没有使用到 outer 函数中的变量,但是unused 函数内部使用到了 outer 函数中的 bar 变量,故形成闭包,inner 函数也会共享 outer 函数提供的闭包作用域
由于闭包的存在,bar 变量不能释放,即相当于inner 函数隐式持有了 bar 变量,所以存在**…–>foo–>inner–>bar–>foo(赋值给 bar 的 foo,即上一次的 foo)…
这里 inner 隐式持有 bar 变量怎么理解呢?因为
inner 是一个闭包函数可以使用 outer 提供的闭包作用域中的 bar 变量,由于闭包的关系,bar 变量不能释放,所以bar 变量一直在内存中,而bar 变量又指向了上一次赋值给 bar 的 foo 对象**,所以会存在这样一个引用关系。

那怎么解决呢?由于 bar 变量常驻内存不能释放,所以我们可以在 outer 函数执行完毕的时候手动释放,即将 bar 变量置为 null,这样之前赋值给 bar 的 foo 对象就没有被其他变量引用了,就会被回收了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump
let foo = null;
function outer() {
let bar = foo;
function unused() {
// 未使用到的函数
console.log(`bar is ${bar}`);
}

foo = {
// 给foo变量重新赋值
bigData: new Array(100000).join("this_is_a_big_data"), // 如果这个对象携带的数据非常大,将会造成非常大的内存泄漏
inner: function () {
console.log(`inner method run`);
},
};
bar = null; // 手动释放bar变量,解除bar变量对上一次foo对象的引用
}
for (let i = 0; i < 1000; i++) {
outer();
}
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

手动释放 bar 变量是一种相对比较好的解决方式。关键在于要解除闭包解除 bar 变量对上一次 foo 变量的引用。所以我们可以让 unused 方法内不使用 bar 变量,或者将 bar 变量的定义放在一个块级作用域中,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump
let foo = null;
function outer() {
{
// 将bar变量定义在一个块级作用域内,这样outer函数中就没有定义变量了,自然inner也不会形成闭包
let bar = foo;
function unused() {
// 未使用到的函数
console.log(`bar is ${bar}`);
}
}

foo = {
// 给foo变量重新赋值
bigData: new Array(100000).join("this_is_a_big_data"), // 如果这个对象携带的数据非常大,将会造成非常大的内存泄漏
inner: function () {
console.log(`inner method run`);
},
};
}
for (let i = 0; i < 1000; i++) {
outer();
}
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

重复注册事件

比如页面一进入就重复注册 1000 个同名事件(一次模拟每次进入页面都注册一次事件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump
const events = require("events");
class Page extends events.EventEmitter {
onShow() {
for (let i = 0; i < 1000; i++) {
this.on("ok", () => {
console.log("on ok signal.");
});
}
}
onDestory() {}
}
let page = new Page();
page.setMaxListeners(0); // 设置可以注册多个同名事件
page.onShow();
page.onDestory();
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

这个例子中 Page 页面一进入就会同时注册 1000 个同名的 ok 事件,start.heapsnapshot 文件的大小为 2.4M,而 end.heapsnapshot 文件的大小为 2.5M,所以可能存在内存泄漏。
解决方式就是,在页面离开的时候移除所有事件,或者在页面创建的时候仅注册一次事件,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump
const events = require("events");
class Page extends events.EventEmitter {
onCreate() {
this.on("ok", () => {
// 仅在页面创建的时候注册一次事件,避免重复注册事件
console.log("on ok signal.");
});
}
onShow() {
// for (let i = 0; i < 1000; i++) {
// this.on("ok", () => {
// console.log("on ok signal.");
// });
// }
}
onLeave() {
this.removeAllListeners("ok"); // 或者在离开页面的时候移除所有ok事件
}
}
let page = new Page();
page.setMaxListeners(0); // 设置可以注册多个同名事件
page.onCreate();
page.onShow();
page.onLeave();
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

意外的全局变量

这是我们常常简单的内存泄漏例子,实际上内存工具很难判断意外的全局变量是否存在内存泄漏,除非应用程序不断的往这个全局变量中加入数据,否则对于一个恒定不变的意外全局变量内存测试工具是无法判断出是否存在内存泄漏的,所以我们尽量不要随意使用全局变量来保存数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump

function createBigData() {
const bigData = [];
for (let j = 0; j < 100; j++) {
bigData.push(new Array(10000).join("this_is_a_big_data"));
}
return bigData;
}

function fn() {
foo = createBigData(); // 意外的全局变量导致内存泄漏
}
for (let j = 0; j < 100; j++) {
fn();
}
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

该例子执行后,end.heapsnapshot 文件的大小为 2.5M 也变成了 2.5M,执行 fn 函数的时候意外产生了一个全局变量 foo,并赋值为了一个很大的数据,如果 foo 变量用完后我们不再需要,那么我们就要主动释放,否则常驻内存造成内存泄漏,如果这个全局变量我们后续还需要使用到,那么就不算内存泄漏。
解决方法就是,将 foo 定义成局部变量,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump

function createBigData() {
const bigData = [];
for (let j = 0; j < 100; j++) {
bigData.push(new Array(10000).join("this_is_a_big_data"));
}
return bigData;
}

function fn() {
// foo = createBigData(); // 意外的全局变量导致内存泄漏
const foo = createBigData(); // 将foo定义为局部变量,避免内存泄漏
}
for (let j = 0; j < 100; j++) {
fn();
}
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

事件未及时销毁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump
const events = require("events");
function createBigData() {
const bigData = [];
for (let j = 0; j < 100; j++) {
bigData.push(new Array(100000).join("this_is_a_big_data"));
}
return bigData;
}

class Page extends events.EventEmitter {
onCreate() {
const data = createBigData();
this.handler = () => {
this.update(data);
};
this.on("ok", this.handler);
}

update(data) {
console.log("开始更新数据了"); // 接收到ok信号,可以开始更新数据了
}

onDestory() {}
}
let page = new Page();
page.onCreate();
page.onDestory();
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

此例中页面 onCreate 的时候会注册一个 ok 事件,事件处理函数为 this.handler,this.handler 的定义会形成一个闭包,导致 data 无法释放,从而内存溢出。
解决办法就是移除事件清空 this.handler,因为 this.handler 这个闭包函数被两个变量持有,一个是page 对象的 handler 属性持有,另一个是事件处理器由于注册事件后被事件处理器所持有。所以需要释放 this.handler 并且移除事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const heapdump = require("heapdump");
heapdump.writeSnapshot("start.heapsnapshot"); // 记录应用开始时的内存dump
const events = require("events");
function createBigData() {
const bigData = [];
for (let j = 0; j < 100; j++) {
bigData.push(new Array(100000).join("this_is_a_big_data"));
}
return bigData;
}

class Page extends events.EventEmitter {
onCreate() {
const data = createBigData();
this.handler = () => {
this.update(data);
};
this.on("ok", this.handler);
}

update(data) {
console.log("开始更新数据了"); // 接收到ok信号,可以开始更新数据了
}

onDestory() {
this.removeListener("ok", this.handler); // 移除ok事件,解决事件处理器对this.handler闭包函数的引用
this.handler = null; //解除page对象对this.handler闭包函数的引用
}
}
let page = new Page();
page.onCreate();
page.onDestory();
heapdump.writeSnapshot("end.heapsnapshot"); // 记录应用结束时的内存dump

解除 page 对象和事件处理器对象对 this.handler 闭包函数的引用后,this.handler 闭包函数就会被释放,从而解除闭包,data 也会得到释放。