abbshr / abbshr.github.io

人们往往接受流行,不是因为想要与众不同,而是因为害怕与众不同

Home Page:http://digitalpie.cf

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Little JavaScript Book『拾贰』———闭包、作用域链与内存泄漏

abbshr opened this issue · comments

讨论闭包(Closure)

闭包是JavaScript中的一个基本概念。咋一看感觉没什么了不起:不就是在函数中定义了一个嵌套函数,并且嵌套函数能够访问外部变量,然后这个嵌套函数就起个名叫Closure嘛~
没错,在计算机科学文献中闭包的定义不过如此,但你不一定会用。举个例子:

var 一只变量 = 'global';
function 只是测试() {
    var 一只变量 = 'local';
    function inside() {
        return 一只变量;
    }
    return inside;
} 
只是测试()();

在这个例子中外部函数返回了一个闭包inside,然后在全局作用域下调用了返回的这个函数,然后最终返回‘global’。
看上去很简单,不是么?
如果要真是这样的话那就没意思了。。。真实的情况是调用闭包后返回“local”!

为什么?正常情况下在全局声明了一个变量,然后在全局中调用一个函数:

function inside() {
    return 一只变量;
}

理论上一只变量并没有在inside函数内部声明,因此他应该是个全局变量,这个函数应该返回‘global’才对啊~~
不过这次注意inside函数是怎么来的:在一个外部函数中返回了这个inside闭包!

我想曾经学过类C语言的同学会对其函数有这样的印象:当函数执行完毕后,其内部定义的任何变量都被释放,并且不允许任何外部引用接触到内部的变量。因此大部分人对JavaScript的闭包存有误解:即使返回了一个内部定义的函数,函数不过是函数,像个模型一样等着任意变量去填充,所以内部定义的变量应该与inside内的变量无关并且误认为应该已经被释放了。

不过C语言不是一门函数式语言,也没有Closure一说。

所以事实是这样的:inside被返回后,由于闭包的特性,它内部会保持对其外部变量的引用,并且函数中定义的局部变量就不会被释放。这里有段来自权威指南中的底层解释:

……基于栈的CPU架构:如果一个函数局部变量定义在CPU的栈中,那么当函数返回时他们就不存在了……

那么闭包如何做到引用外部函数定义的变量呢?下面来说说JavaScript中的作用域链

作用域链(Scope Chain)

作用域对象

在浏览器的全局作用域下,window为对应环境的全局变量,任何直接在该环境中声明或直接调用的变量都将成为window对象的属性。
类比window对象,可以这么理解作用域:每个作用域都对应着一个‘对象’,称其为‘该作用域的全局对象’。
因此,在函数中定义的变量可理解为该函数作用域的作用域对象的属性,也就是所说的变量属于这个局部作用域

scope chain

当作用域形成一级一级的嵌套,便形成了作用域链。它是一个由每个作用域对应的作用域对象链接而成的一个对象链表

在顶级作用域window,作用域链就包含window对象,函数作用域内,作用域链至少包含window对象和当前作用域对象,当嵌套时还有更多的函数作用域对象挂载到链上。
作用域链的特点就是:1.每执行一次函数,都会新建一个作用域对象并挂载到作用域链上,这个作用域对象保存了当前环境下的局部变量和函数参数。这是很好理解的,因为每次执行同一个函数,他们之间的作用域都互不干扰。2.当函数执行完毕,就将这个作用域对象从作用域链中删除(也就是局部变量被释放无法使用),但这成立与否基于一个前提,也就是前面所说的是否返回闭包的情况。

反观Closure

有了作用域对象的概念,就能进一步说解释包调用了。

……如果函数体内不存在嵌套的函数……或者存在嵌套的函数但他们都在父函数中保留了下来……,那么等父函数返回时,对应的作用域对象便会被删除掉。……但如果存在嵌套函数,且父函数返回了这个闭包并被引用/调用(或者将这个闭包存储为一个属性)……这时,就会有一个外部引用指向这个嵌套函数,则这种情况下父作用域对象便不会被释放……

以上是来自权威指南中的一段关于‘闭包如何保存外部变量’的描述。

词法作用域

但究其作用域对象不被释放的根本,还需要词法作用域这个概念,其规则是:函数定义时的作用域链到函数执行时依然有效!
JavaScript函数执行时用到了作用域链,而这个作用域链是在函数定义时确定的。由于嵌套函数定义在函数作用域内,其作用域链包含父函数的作用域对象,所以不管在何时何地执行闭包,这条作用域链都是有效的!

这回闭包的profile是不是就清晰了呢?然后让我们来看看闭包在实际应用中最常见的问题——内存泄漏。

JavaScript中的内存泄漏 [ Update at 2015.6.3 ]

就在昨天,我在浏览器中测试的一段代码引发了内存泄露,并且无法阻止,最后只好关掉重启浏览器。也正是这次的经历,让我对内存泄漏记忆颇深,并且决对不是空谈。

what's 内存泄漏?

所谓内存泄漏是指分配给应用的内存既 不能被回收不能被重新分配利用

循环引用中的内存泄漏

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。 --from MDN

举个循环引用的例子,例如:

//obj1与obj2相互引用
var obj1, obj2;
obj1.pro = obj2;
obj2.pro = obj1;

//obj的自身引用
var obj;
obj.pro = obj;

// 函数作用域对象对外部对象的引用, 外部对象同样引用着函数
a= obj;
a.f = function () {};

垃圾回收算法

引用计数垃圾收集

这是最简单的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

如果解释器采用了这种GC算法, 那么循环引用肯定会导致内存泄露, 因为目标对象的引用计数始终大于0. 不过这种算法已被现代浏览器所淘汰了, 大都经过改进, 结合了另一种GC算法:

标记-清除算法

这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。
假定设置一个叫做根的对象(在Javascript里,根是全局对象)。定期的,垃圾回收器将从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和所有不能获得的对象。最后清除无法获得的对象.

链式引用引发的内存泄露

正常来讲,这不会出现什么问题。但是当引用的对象之一关联着特占内存的大型数据(像超长字符串、超长哈希映射或DOM对象),问题就来了:

sth = null

rep = () ->
  org = sth
  unused = () -> org or null
  sth =
    str: [1..1000000].join ''
    fun: () -> unused null

  console.log process.memoryUsage()

setInterval rep, 1000

这是一个蹩脚的由链式引用引起的内存泄漏。
从浏览器和Node环境下的内存监测来看,每次GC似乎都没有彻底释放内存,显然内存泄漏了。我们来分析一下代码看看这是为什么:

首先,我定义一个显式引用org的闭包unused并且没有调用它. org在rep函数的公共词法作用域中.

第二,sth和org一个道理, 会被unused隐式引用.

最后, unused能被sth.fun函数引用(虽然很明显这个函数没有使用它)。但是因为unused也在rep公共词法作用域内,fun还会保持对unused的引用。

这样就形成了一个链式引用:org指向上一次的sth, unused引用org,而sth.func引用了unused。在当前时刻, 全局变量sth的属性fun作用域中隐式引用了unused, 也就间接引用了unused作用域的引用.

理论上, 这样就保存了一个引用链:

reflink
所以,尽管我们每次调用rep函数都会有一个新建的sth对象把sth覆盖,但有引用链的存在,原先的值不但永远不会被清除,还会在每秒不断增加循环调用的个数从而增加内存占用量.

那这个问题该如何解决?对此,有两个解决方案:

1.由于unused函数引用了org,所以可以从这里入手,把unused这个函数中的引用去掉,这样就没有循环引用的闭包了,也就没有泄漏了。

2.将unused和sth引用的根源org做下处理:

//在rep函数体最后加:
org = null;

我测试了100s内的内存占用情况变化, 并做了可视化:

其中灰色代表原始代码, 粉色代表方案二, 淡蓝色代表方案一

ml

这与我们之前的分析稍微有些出入. 严格来讲, 现代JavaScript引擎已经做到足够智能和优化(就像之前提到的两个GC算法), 如果沿着全局对象不能访问到目标对象, 那么他将被标记为不可用, 因此将被GC. 这就是为什么灰色的原始曲线也会有大幅波动出现.

还没读V8GC的代码, 暂时有这两个猜测 (接下来两段为猜想...)
既然可以被GC, 那么灰色曲线的持续增长又是什么原因? 这里我只能猜测引用链里有显示引用(unused对org)的原因导致每次GC时无法做出准确判断到底该不该回收, 只好等到内存使用量太大并且程序确实不会再用到他们时才GC一次.

或者是GC可以回收那些内存, 只是速率不及内存使用的快, 并且对于包含显示引用的引用关系回收效果甚微(就是不能一次回收干净, 要一点点的拆除ref)
脑补结束, 欢迎指明误区并参与讨论!!

而对于两种解决方案来说, 第一种能够"化显为隐"(unused中不存在对org的显示引用了). 第二种则更直接的断开了引用链(被显示引用的org变成null), 导致上一次产生的str及不可访问又失去了最后一个引用. 这就能让GC快速准确的回收内存.

可以看出, 两种方案的效果基本一样, 内存占用基本保持在一个小范围内波动, 只不过_赋值null_能比_去掉unused引用_更早的进行垃圾收集. 但未经处理的代码就不一样的了, 可以清除的看到灰色区域的内存占用量始终成上涨趋势, 虽然可以看到有进行内存收集的迹象, 但仍抵不住疯狂增长的大潮.

闭包中的链式引用

再来看下面这个明显的例子:

//这是昨天我测试的用例2:
var run = function () {
    //一个占1MB内存的字符串
    var str = new Array(1000000).join(''*'');
    var doSth = function () {
        if (str === 'something')
        console.log("str was something");
    };
    doSth();
    var log = function () {
        console.log('interval');
    };
    setInterval(log, 100);
};
setInterval(run, 1000);

显而易见,setInterval(run, 1000)执行后run函数将会在每秒执行一次;log函数将会在一秒内连续执行10次。
这里的setInterval(log, 100)保存了对log闭包的外部引用,因为str字符串在公共词法作用域内,所以log保持着对str的引用使str不会在run函数结束时被释放,并且在每次执行run时内存占用量都会增加。

(完)

增加了内存泄露部分内存占用的可视化图表

增加内存泄露章节的详细解释与个人猜想