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

IT技术 javascript garbage-collection closures
2021-02-20 04:32:17

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

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,因此BigObject可以被垃圾收集,只要x是对它的最后一个引用。每当对函数表达式求值时,头脑简单的解释器都会捕获整个作用域链。(一方面,您需要这样做才能调用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。

2个回答

确实存在阻止您调用静态分析会遗漏的 eval 的限制是不正确的:只是对 eval 的此类引用在全局范围内运行。请注意,这是 ES5 中 ES3 的一个变化,其中对 eval 的间接和直接引用都在本地范围内运行,因此,我不确定是否真的有任何基于此事实的优化。

一个明显的测试方法是让 BigObject 成为一个真正的大对象,并在运行 f0-f2 后强制执行 gc。(因为,嘿,尽管我认为我知道答案,但测试总是更好的!)

所以…

考试

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 的引用。

蜘蛛猴

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 之外的所有内容上都出现了 GC“x”。

除非在任何仍然存在的函数的作用域链内有直接的 eval 调用,否则它看起来尽可能多(即,如果可能,y 和 x 一样)。(即使该函数对象本身已被 GC 处理并且不再存在,就像 f5 中的情况一样,这在理论上意味着它可以 GC 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 x 的所有内容都显示为 V8。这与 SpiderMonkey 相同,请参见上面的分析。(但是请注意,这些数字不够详细,无法判断 y 是否被 GC 处理,而 x 不是,我没有费心去调查。)

卡拉坎

我不打算再次运行它,但不用说,行为与 SpiderMonkey 和 V8 相同。没有 JS shell 很难测试,但随着时间的推移是可行的。

JSC(硝基)和查克拉

在 Linux 上构建 JSC 很痛苦,而 Chakra 不能在 Linux 上运行。我相信 JSC 与上述引擎具有相同的行为,如果 Chakra 也没有,我会感到惊讶。(做任何更好的事情很快变得非常复杂,做任何更糟的事情,好吧,你几乎永远不会做 GC 并且有严重的内存问题......)

@Stephen:这实际上是使用 JägerMonkey 完成的。JIT 行为根本不影响 GC。
2021-04-18 04:32:17
在本地变量的GC f7f8和F9的错误吗?
2021-04-20 04:32:17
顺便说一句,在 JeagerMonkey 上可以进行任何实验吗?我知道它做了进一步的优化,但不太可能为了正确性而优化掉 f3、f5 和 f6。
2021-04-28 04:32:17
太糟糕了,我只能投票一次。我本来会给这个实验更多的赞成票。
2021-05-06 04:32:17

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

现在,如果一个变量在闭包中被引用(即通过一个本地定义的函数,稍后可能会被调用),“内部”函数被分配一个“作用域链”,从最内部的作用域开始,即函数本身. 下一个作用域是外部函数(包含访问的局部变量)。解释器(或编译器)将创建一个“闭包”,本质上是在(不是堆栈)上分配的一块内存,其中包含作用域中的这些变量。

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

一些引擎通过省略被遮蔽的变量(即被内部作用域中的局部变量覆盖)来优化作用域链,因此在您的情况下,只剩下一个 BigObject,只要变量“x”仅在内部作用域中访问,并且在外部作用域中没有“eval”调用。一些引擎“扁平化”作用域链(我认为 V8 做到了这一点)以实现快速变量解析——只有在两者之间没有“eval”调用(或者没有调用可能执行隐式 eval 的函数,例如设置超时)。

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

我稍后会发布一个更完整的答案,但是:SpiderMonkey 是唯一仍然基于堆栈的主要 JS 引擎;所有其他主要的 JS 引擎(即 Chakra、JSC、V8 和 Carakan)都是基于寄存器的。
2021-04-27 04:32:17
真的吗?请务必发布您的答案。这很有趣!
2021-05-12 04:32:17
另外,看看您是否可以详细说明对作用域链所做的优化。谢谢!
2021-05-16 04:32:17