JavaScript中运行时如何表示闭包和作用域

33

这主要是一个出于好奇的问题。考虑以下函数:

var closure ;
function f0() {
    var x = new BigObject() ;
    var y = 0 ;
    closure = function(){ return 7; } ;
}
function f1() {
    var x = BigObject() ;
    closure =  (function(y) { return function(){return y++;} ; })(0) ;
}
function f2() {
    var x = BigObject() ;
    var y = 0 ;
    closure = function(){ return y++ ; } ;
}
在每种情况下,函数执行后,我认为无法访问 x,因此只要x是对BigObject 的最后一个引用,就可以进行垃圾回收。 一个简单的解释器在评估函数表达式时会捕获整个作用域链。(其中之一是,需要这样做才能让对 eval 的调用起作用--下面有示例)。 更聪明的实现可能会避免在 f0 和 f1 中进行此操作。更聪明的实现将允许保留y,但不保留 x,以使 f2 更加高效。

我的问题是现代JavaScript引擎(JaegerMonkey、V8等)如何处理这些情况?

最后,这里有一个示例,显示了即使在嵌套函数中从未提到变量,它们也可能需要被保留。

var f = (function(x, y){ return function(str) { return eval(str) ; } } )(4, 5) ;
f("1+2") ; // 3
f("x+y") ; // 9
f("x=6") ;
f("x+y") ; // 11

然而,有一些限制防止人们以可能被编译器忽略的方式偷偷调用eval。


可能是使用Node.js进行垃圾回收的重复问题。 - Bergi
2个回答

36

并不存在阻止您调用eval函数的限制,这些限制会被静态分析所忽略:仅仅是因为对eval的间接和直接引用都在全局作用域中运行。请注意,这是ES5与ES3的不同之处,ES3中对eval的直接和间接引用均在本地作用域中运行。因此,我不确定是否有任何基于此事实的优化。

一个明显的测试方法是将BigObject设置为一个非常大的对象,并在运行f0-f2后强制进行垃圾回收。(因为嘿,尽管我认为我知道答案,但测试总是更好的选择!)

所以……

测试

var closure;
function BigObject() {
  var a = '';
  for (var i = 0; i <= 0xFFFF; i++) a += String.fromCharCode(i);
  return new String(a); // Turn this into an actual object
}
function f0() {
  var x = new BigObject();
  var y = 0;
  closure = function(){ return 7; };
}
function f1() {
  var x = new BigObject();
  closure =  (function(y) { return function(){return y++;}; })(0);
}
function f2() {
  var x = new BigObject();
  var y = 0;
  closure = function(){ return y++; };
}
function f3() {
  var x = new BigObject();
  var y = 0;
  closure = eval("(function(){ return 7; })"); // direct eval
}
function f4() {
  var x = new BigObject();
  var y = 0;
  closure = (1,eval)("(function(){ return 7; })"); // indirect eval (evaluates in global scope)
}
function f5() {
  var x = new BigObject();
  var y = 0;
  closure = (function(){ return eval("(function(){ return 7; })"); })();
}
function f6() {
  var x = new BigObject();
  var y = 0;
  closure = function(){ return eval("(function(){ return 7; })"); };
}
function f7() {
  var x = new BigObject();
  var y = 0;
  closure = (function(){ return (1,eval)("(function(){ return 7; })"); })();
}
function f8() {
  var x = new BigObject();
  var y = 0;
  closure = function(){ return (1,eval)("(function(){ return 7; })"); };
}
function f9() {
  var x = new BigObject();
  var y = 0;
  closure = new Function("return 7;"); // creates function in global scope
}

我已经为eval/Function添加了测试,因为这些也是有趣的案例。f5/f6之间的差异很有趣,因为基本上f5与f3完全相同,都是为闭包提供一个相同的函数;而f6只返回一些东西,一旦被评估,就会得到那个结果,由于eval尚未被评估,编译器不知道其中是否有x的引用。

SpiderMonkey

js> gc();
"before 73728, after 69632, break 01d91000\n"
js> f0();
js> gc(); 
"before 6455296, after 73728, break 01d91000\n"
js> f1(); 
js> gc(); 
"before 6455296, after 77824, break 01d91000\n"
js> f2(); 
js> gc(); 
"before 6455296, after 77824, break 01d91000\n"
js> f3(); 
js> gc(); 
"before 6455296, after 6455296, break 01db1000\n"
js> f4(); 
js> gc(); 
"before 12828672, after 73728, break 01da2000\n"
js> f5(); 
js> gc(); 
"before 6455296, after 6455296, break 01da2000\n"
js> f6(); 
js> gc(); 
"before 12828672, after 6467584, break 01da2000\n"
js> f7(); 
js> gc(); 
"before 12828672, after 73728, break 01da2000\n"
js> f8(); 
js> gc(); 
"before 6455296, after 73728, break 01da2000\n"
js> f9(); 
js> gc(); 
"before 6455296, after 73728, break 01da2000\n"

SpiderMonkey似乎对除了f3、f5和f6之外的所有东西都进行垃圾回收。

它尽可能地回收(即在可能的情况下回收y和x),除非任何仍然存在的函数的作用域链中存在直接eval调用。即使该函数对象本身已经被GC且不再存在,例如在f5中,理论上也可能回收x/y。

V8

gsnedders@dolores:~$ v8 --expose-gc --trace_gc --shell foo.js
V8 version 3.0.7
> gc();
Mark-sweep 0.8 -> 0.7 MB, 1 ms.
> f0();
Scavenge 1.7 -> 1.7 MB, 2 ms.
Scavenge 2.4 -> 2.4 MB, 2 ms.
Scavenge 3.9 -> 3.9 MB, 4 ms.
> gc();   
Mark-sweep 5.2 -> 0.7 MB, 3 ms.
> f1();
Scavenge 4.7 -> 4.7 MB, 9 ms.
> gc();
Mark-sweep 5.2 -> 0.7 MB, 3 ms.
> f2();
Scavenge 4.8 -> 4.8 MB, 6 ms.
> gc();
Mark-sweep 5.3 -> 0.8 MB, 3 ms.
> f3();
> gc();
Mark-sweep 5.3 -> 5.2 MB, 17 ms.
> f4();
> gc();
Mark-sweep 9.7 -> 0.7 MB, 5 ms.
> f5();
> gc();
Mark-sweep 5.3 -> 5.2 MB, 12 ms.
> f6();
> gc();
Mark-sweep 9.7 -> 5.2 MB, 14 ms.
> f7();
> gc();
Mark-sweep 9.7 -> 0.7 MB, 5 ms.
> f8();
> gc();
Mark-sweep 5.2 -> 0.7 MB, 2 ms.
> f9();
> gc();
Mark-sweep 5.2 -> 0.7 MB, 2 ms.

除了 f3、f5 和 f6 之外,V8 在所有其他地方都会进行 GC。与上面的分析相同,这和 SpiderMonkey 相同。(但是请注意,数字不够详细,无法确定 y 是否在 x 不进行 GC 时被 GC,我没有深入研究。)

Carakan

我不会再次运行此代码,但毫无疑问,其行为与 SpiderMonkey 和 V8 相同。没有 JS shell 更难测试,但可以通过一些时间来完成。

JSC(Nitro)和 Chakra

在 Linux 上构建 JSC 很繁琐,而 Chakra 在 Linux 上无法运行。我认为 JSC 与上述引擎具有相同的行为,如果 Chakra 没有相同的行为,我会感到惊讶。(要做得更好很快就变得非常复杂,而要做得更差,你几乎永远不会进行 GC 并且会有严重的内存问题...)


太遗憾了,我只能投一次票。我本来会给这个实验更多的赞的。 - Stephen Chung
顺便问一下,JeagerMonkey 上有什么实验可以进行吗?我知道它可以进一步进行优化,但出于正确性考虑,不太可能优化掉 f3、f5 和 f6。 - Stephen Chung
@Stephen:这实际上是使用JägerMonkey完成的。JIT行为根本不会影响GC。 - gsnedders
f7f8f9中的本地变量的GC是否存在错误? - David Murdoch

10

在常规情况下,函数中的局部变量会被分配到栈上 –– 当函数返回时它们会“自动”消失。我相信许多流行的JavaScript引擎在栈机架构上运行解释器(或JIT编译器),因此这一观察应该是合理有效的。

如果一个变量在闭包中被引用(即由本地定义并可能在以后调用的函数引用),那么“内部”函数将被分配一个“作用域链”,该链从最内层的作用域即该函数本身开始。下一个作用域是包含所访问的局部变量的外部函数。解释器(或编译器)将创建一个“闭包”,实际上是在上分配的一段内存(而不是栈),其中包含该作用域中的变量。

因此,如果局部变量在闭包中被引用,它们就不再分配到栈上(当函数返回时会消失)。它们像普通的长期存在的变量一样被分配,并且“作用域”包含指向它们每一个的指针。内部函数的“作用域链”包含指向所有这些“作用域”的指针。

一些引擎通过省略被遮蔽(即在内部作用域中被本地变量覆盖)的变量来优化作用域链,因此在您的情况下只有一个BigObject保留,只要变量“x”仅在内部作用域中访问,并且外部作用域中没有“eval”调用。一些引擎(我认为V8就是这样做的)会“展平”作用域链以实现快速变量解析——这只能在没有“eval”调用之间(或者没有调用可能执行隐式eval的函数,例如setTimeout)进行。

我邀请一些JavaScript引擎专家提供比我更详细的有趣的细节。


3
稍后我会发布更完整的答案,但是:SpiderMonkey是唯一仍然基于堆栈的主要JS引擎;所有其他主要的JS引擎(即Chakra、JSC、V8和Carakan)都是基于寄存器的。 - gsnedders
真的吗?请发表你的答案。这很有趣! - Stephen Chung
1
另外,请看看能否详细说明作用域链上的优化。谢谢! - Stephen Chung

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接