闭包中外层函数未被直接引用的变量何时被 GC 回收


举一个简单的闭包例子:


 function A() {
    var i = 1;
    var j = 2;
    return function(){
        return i;
    }();
}

var B = A();

在语句 return i 这一行设置断点,调试如下:

请输入图片描述

之前看过有关闭包的资料时知道由于函数 A 中的变量被引用,所以函数 A 不会被 GC 回收,那么这个不回收指的是整个函数 A 都不会被回收,还是被直接引用的部分不会被回收呢?
再举一例:


 function A() {
    var i = {x : 1};
    var j = i;
    return function(){
        return j;
    }();
}

var B = A();

此时, i j 引用,所以 i j 指向同一个内存空间,但是断点调试时依然访问不到未被直接引用的变量 i

请输入图片描述

我的猜想是,当我设置断点进行调试时由于是全局作用域,所以我访问不到外层函数内部未被闭包暴露在全局作用域下的变量,而并不是因为该变量已经被 GC 回收了。

那么我最终的问题是:未被闭包直接引用的外层函数的变量在我设断点调试时到底有没有被 GC 回收呢?如果未被回收,为何我调试时访问不到呢?是因为作用域的问题吗?如果此时未被回收,那么该变量何时会被回收呢?

Firefox Safari 调试时居然是可以访问到未引用的变量 j 的,截图如下:

1. Firefox
请输入图片描述

请输入图片描述

2. Safari

请输入图片描述

现在基本明白了,可能是 debugger 的问题?

测试代码如下:


 var v0 = 'i am at level 0';
var f1 = function () {
    var v1 = 'i am at level 1';
    var f2 = function (){
        var i = 1;
        var j = 2;
        var f3 = function (){
            console.log(i);
        }
        f3();
    }
    f2();
}
f1();

Chorme

请输入图片描述

Firefox

请输入图片描述
请输入图片描述

Safari

请输入图片描述

closure JavaScript 闭包

吐槽不吐葡萄皮 10 years, 10 months ago

这事就不用费心了。这个问题如你所说是debugger实现上的不同导致的变量可见性上的差别,但跟GC无关,也跟标准无关,标准没有规定debugger和GC的具体实现。

GC的工作方式有很多种,不过它不是想像中的「无用就立即GC」,要理解v8的GC工作方式,不如直接参阅这篇文章: http://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
但我觉得这里理解的误点其实在于认为:凡是我在代码中无法访问到的对象就一定 已经 被GC了。
注意这个「已经」两字,这种认识显然是错误的。凡是我们在代码中可以访问到的对象一定没有被GC这是当然的。但凡是我们在代码中无法访问到的对象,它是 应该 被GC的,但它什么时候被GC,是根据具体GC的实现策略来的。由于JS对象没有destroy的hook,所以不方便用代码直观展示这一点。不过了解一点点Java的都应该熟悉下面的代码:


 public class GC {
  public static void main(String args[]) {
    for (int i=0;i<10;i++) {
      new GC();
      System.out.println("Not GC");
    }
    System.gc();
  }
  protected void finalize() {
    System.out.println("GC");
  }
}

问:上述代码“Not GC”和“GC”会是交错输出的吗?什么时候会输出“GC”呢?
答案是不确定,一般情况下“Not GC”和“GC”是不会交错输出的,如果不指定ForceInvokeFinalizeMethod的话,程序退出了甚至都不输出一个GC!

另外,你的示例代码用的是 Primitive 数据类型,这是没有说服力的。因为Primitive值不可改变,如示例中的small integers 类的值可以安全地作为一个immediate 31-bit integer value使用Stack甚至Register传值,而不是传一个Heap上数据的地址,这种情况下就压根不关GC的事。只有分配在Heap上的数据才需要GC。

补充:关于闭包在什么情况下会引起Memory Leaks
之所以想要补充这个示例,是因为前次看到某人用一个具有误导性的示例代码证明JS的闭包总是会引起内存泄漏,进而将JS的闭包归为设计错误,这显然是瞎说。原来的代码如下:


 function ref(f, i) {//返回的闭包函数没有用到f
  //但其认为f仍然被闭包捕获而不能被GC释放
  return function() { console.log(i) } 
}
var f = function(){},max=1000000000;
for(var i = 0; i < max; i++) f = ref(f, i);

按道理来讲循环结束后,f应该是一个等同于 function () {console.log(max)} 的函数,中间过程产生的函数都应该成了Garbage。但如果你在浏览器中运行这段代码,会观察到内存使用飞涨之后,迟迟不会降下来,也就是那些临时Garbage函数没有被回收掉。看到这个结果,或许你会认为闭包会始终捕获外层变量,不管用到没用到,因此总是产生内存泄漏。但这样的推论是非常不严谨的,因为我们不能通过肉眼看看就轻信任务管理器中进程的内存使用报告。而且,这个结论也是由对GC工作方式的误解产生的。
我将这个示例改成一段简单且足以说明问题的代码,并且使用Node.js来运行,借助Node.js的GC及 proccess.memoryUsage().heapUsed 报告来分析,注意要使用 node --exposed-gc gc.js 来运行下面的代码, --exposed-gc 选项使得我们能在JS中直接调用 gc() 强制进行垃圾回收(或者辅以 node --trace-gc ):


 //gc.js
var util = require('util');
function ref(a2, i) {
  return function() {console.log(i);}
}
//运行之前的内存使用
console.log("       Start:"+
  util.inspect(process.memoryUsage()));
var a= new Array(9999999).join("X");
//创建一个巨大的数组之后的内存使用
console.log("Create Array:"+
  util.inspect(process.memoryUsage()));
var f=ref(a,"i");//f闭包不应保持对a的引用
a=null;//将a设置成null ,那么a就成garbage了
//但heapUsed并没有恢复到运行之前的内存使用的程度
console.log("    Set Null:"+
  util.inspect(process.memoryUsage()));

gc(); //force gc

//GC之后内存恢复初始水平
console.log("    After GC:"+
  util.inspect(process.memoryUsage()));

好了,看到运行结果大家应该就放心了,闭包不可能这么容易导致内存泄漏的。那闭包在什么情况下会导致内存泄漏呢?要解释这个就得解释下闭包的工作机制,这方面的资料已经很多了,我就不重复了,并且我在这里也解释过了: http://segmentfault.com/q/1010000000491499#a-1020000000502480

以前面的ref函数为例,它大致是这样:


 function ref(a2, i) {
  ref.Scope+={a2:&a2,i:&i};
  var lambda =function() {console.log(lambda.Scope.i);};
  lambda.Scope+=ref.Scope;
  return lambda;
}

从原理上来讲它是大概这么解释的,但实现时解释器显然可以很容易区分哪些外部变量没有被闭包引用,没有用到的就不必捕获妨碍GC。但有些情况下解释器无法确定外部变量是否一定没有用到,于是它就将外部变量全部捕获以保证不出错,这类情况就是 eval with 将上面gc.js中的ref函数修改成这样,就会发现无法GC:


 function ref(a2, i) {
  return function() {console.log(i);with({a2:343}) {console.log(a2)};}
}

with很简单,你访问的变量名和外层的变量名重复了,那解释器就会认为a2变量有可能会被用到,就不能GC它。这里重点讨论一下eval,eval的情况较为复杂:


 function ref(a2, i) {
  return function() {console.log(i);eval('a2.toString()');}
}

由于 Direct eval 调用,是将StringCode放到当前函数作用域中执行,所以这里代码就能访问到a2变量。eval参数可以是一个从任何地方传来的String,解释器是无法静态分析eval的代码有没有使用变量a2的,因此它不得不将所有外层变量放到闭包中,即使上面ref返回的闭包函数中写的是 eval('Nothing')
注意我上面说的是 Direct eval 调用,什么叫Direct eval调用?这个在ES5规范中有定义,但要将它的定义解释清楚就太麻烦了:

这里说最容易识别的方式,就是它是一个最简单的函数调用,并且函数标识符是eval(不管实际上它是不是真的eval函数),如下面ref函数是Direct eval,因此会导致内存泄漏:


 function ref(a2, i,eval) {
  return function() {console.log(i);eval('a2.toString()');}
}
f=ref(a,3,parseInt);//尽管传入的不是eval
//下面也是一样会影响GC
function ref(a2, i) {
  return function() {console.log(i);
    var eval="谁让你用eval作函数名?";
    eval('a2.toString()');}
}

但下面的是Indirect eval,Indirect eval会将代码在全局作用域中执行,因此解释器在闭包上可以无视Indirect eval:


 function ref(a2, i) {
  return function() {console.log(i);
    var e=eval;e('a2.toString()');} //会报错,因为它在全局作用域下将字符串作为代码执行,因此访问不到a2变量
}
f=ref(a,3);
a=null;//不会导致内存泄漏
//下面的也是一样不影响GC
function ref(a2, i) {
  return function() {console.log(i);this.eval('a2.toString()');}
}

至此应该大概清楚了,只要不使用Direct eval,闭包中没用到的变量就能被正常GC,不会这么容易就内存泄漏。至于什么是Indirect eval我想也不用多管了,反正我们压根不会在生产环境用到eval。不过等等,这并不是说只要你不使用Built-in eval就不会影响GC,而是说你不能调用任何名为eval的函数。限制这个很简单, 'use strict;' 就行了,Strict mode禁止用eval作变量名。

对了,这个问题中的debugger行为可能也是和eval类似的。浏览器的JS解释器仅在遇到有debuger语句时,就将所有外层变量全部放到闭包中,以方便调试时能访问所有外层变量。

warhys9 answered 10 years, 10 months ago

Your Answer