首页 > 应用开发 > Javascript > 理解闭包与内存泄漏
一、闭包的定义
闭包,是指 有权访问另一个函数作用域中变量的函数。从定义上我们可以知道, 闭包是函数,并且是 被另一个函数包裹的函数。所以需要 用一个函数去包裹另一个函数,即 在函数内部定义函数。 被包裹的函数则称为 闭包函数, 包裹的函数 ( 外部的函数 ) 则为闭包函数提供了一个闭包作用域 ,所以 形成的闭包作用域的名称为外部函数的名称。
我们先来看一个常见的闭包例子,如 :
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(); // 执行内部函数
我们在浏览器上执行该段代码后,会停在断点位置,此时我们可以看到形成的闭包作用域。我们可以看到, 形成的闭包作用域名称为外部的 outer 函数提供的作用域 ,闭包作用域内有一个变量 bar 可以被闭包函数访问到。
二、形成闭包的条件
从上面的闭包例子在,看起来形成的闭包的条件就是,一个函数被另一个函数包裹,并且返回这个被包裹的函数供外部持有。其实, 闭包函数是否被外部变量持有并不重要,形成闭包的必要条件就是, 闭包函数 ( 被包裹的函数 ) 中必须要使用到外部函数中的变量 。
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
内执行
。
从执行结果可以看到,仍然形成了闭包,所以说
这个被包裹的闭包函数是否被外部持有并不是形成闭包的条件。
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
函数中的变量
。
从执行结果上可以看到,
没有形成闭包。所以形成闭包的必要条件就是,
被包裹的闭包函数必须使用外部函数中的变量。
当然上面的结论也太过绝对了些,因为 外部函数可以同时包裹多个闭包函数,也就是说, ( 外部 ) 函数内部定义了多个函数 ,这种情况下,就不需要每个闭包函数都使用到外部函数中的变量,因为 闭包作用域是内部所有闭包函数共享的, 只要有一个内部函数使用到了外部函数中的变量即可形成闭包。
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
函数中的变量。
从执行结果可以看到,
又形成了闭包。所以形成的闭包条件就是,
存在内部函数中使用外部函数中定义的变量。
三、内存泄漏
内存泄漏常常与闭包紧紧联系在一起,很容易让人误以为闭包就会导致内存泄漏。其实
闭包只是让内存常驻,而
滥用闭包才会导致内存泄漏。
内存泄漏,从广义上说就是,
内存在使用完毕之后,
对于不再要的内存没有及时释放或者无法释放。
不再需要的内存使用完毕之后肯定需要释放掉,否则这个块内存就浪费掉了,相当于内存泄漏了。
但是在实际中,往往不会通过判断该内存或变量是否不再需要使用来判断。因为内存测试工具很难判断该内存是否不再需要。所以我们通常会
重复多次执行某段逻辑链路,然后
每隔一段时间进行一次内存
dump
,然后
判断内存是否存在不断增长的趋势,如果存在,则可用怀疑存在内存泄漏的可能。
四、内存 dump
浏览器中抓取内存的 dump 相对来说简单些, 直接通过谷歌浏览器的调试工具找到 memory 对应的 tab 页面 ,然后 点击 Load 即可开始抓取内存 dump ,如 :
在 NodeJS 中,我们也可以通过引入 heapdump 来抓取内存 dump ,直接通过 npm 安装 heapdump 模块即可
> npm install heapdump
安装完成之后,即可直接在应用程序中使用了,用法非常简单,如 :
const heapdump = require ( 'heapdump' );
heapdump.writeSnapshot( 'start.heapsnapshot' ); // 记录应用开始时的内存 dump
// 应用 code 部分
heapdump.writeSnapshot( 'end.heapsnapshot' ); // 记录应用结束时的内存 dump
应用程序执行完成后,会在 应用根目录中生成 start.heapsnapshot 和 end.heapsnapshot 两个内存 dump 文件,我们可以 通过判断两个文件的大小变化来判断是否存在内存泄漏。
当然 并不是说内存 dump 文件的大小不断增大就存在内存泄漏 , 如果应用的访问量确实在一直增大,那么内存曲线 网站监控 只增不减也属于正常情况 , 我们只能根据具体情况判断是否存在内存泄漏的可能。
五、常见的内存泄漏
① 闭包循环引用
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 对象就没有被其他变量引用了 ,就会被回收了。
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 变量的定义放在一个块级作用域中 ,如 :
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 个同名事件 ( 一次模拟每次进入页面都注册一次事件 )
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
,所以可能存在内存泄漏。
解决方式就是,
在页面离开的时候移除所有事件,或者
在页面创建的时候仅注册一次事件,如
:
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
③ 意外的全局变量 ,这是我们常常简单的内存泄漏例子,实际上内存工具很难判断意外的全局变量是否存在内存泄漏,除非应用程序不断的往这个全局变量中加入数据,否则 对于一个恒定不变的意外全局变量内存测试工具是无法判断出是否存在内存泄漏的,所以我们 尽量不要随意使用全局变量来保存数据。
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
定义成局部变量,如
:
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
④ 事件未及时销毁
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
并且移除事件监听。
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 也会得到释放。
本文转自: https://segmentfault.com/a/1190000039132414
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/69994154/viewspace-2754805/ ,如需转载,请注明出处,否则将追究法律责任。