关于闭包、词法环境和 GC

IT技术 javascript garbage-collection closures
2021-01-15 15:49:51

作为ECMAScriptv5,每次控件输入代码时,引擎都会创建一个LexicalEnvironment(LE)和一个VariableEnvironment(VE),对于函数代码,这两个对象是完全相同的引用,是调用NewDeclarativeEnvironment( ECMAScript v5 10.4. 3 ),函数代码声明的所有变量都保存在VariableEnvironment ( ECMAScript v5 10.5 )环境记录组件中,这就是闭包的基本概念

让我感到困惑的是垃圾收集如何使用这种闭包方法,假设我有如下代码:

function f1() {
    var o = LargeObject.fromSize('10MB');
    return function() {
        // here never uses o
        return 'Hello world';
    }
}
var f2 = f1();

在 line 之后var f2 = f1(),我们的对象图将是:

global -> f2 -> f2's VariableEnvironment -> f1's VariableEnvironment -> o

因此,据我所知,如果 javascript 引擎使用引用计数方法进行垃圾收集,则该对象o至少有1引用并且永远不会被 GC。显然这会导致内存浪费,因为o永远不会使用但总是存储在内存中。

可能有人会说引擎知道f2的VariableEnvironment没有使用f1的VariableEnvironment,所以整个f1的VariableEnvironment都会被GCed,所以还有一段代码可能会导致更复杂的情况:

function f1() {
    var o1 = LargeObject.fromSize('10MB');
    var o2 = LargeObject.fromSize('10MB');
    return function() {
        alert(o1);
    }
}
var f2 = f1();

在这种情况下,f2使用o1存储在f1 的 VariableEnvironment 中对象,因此f2 的 VariableEnvironment必须保留对f1 的 VariableEnvironment的引用,这导致o2也无法进行 GC,从而进一步浪费内存。

所以我会问,现代 javascript 引擎(JScript.dll / V8 / SpiderMonkey ...)如何处理这种情况,是否有标准的指定规则或它是否基于实现,以及 javascript 引擎处理此类对象图的确切步骤是什么执行垃圾收集。

谢谢。

3个回答

tl;dr 回答: “只有从内部 fns 引用的变量才会在 V8 中分配堆。如果您使用 eval,则假定所有变量都被引用。” . 在您的第二个示例中,o2可以在堆栈上分配并在f1退出后丢弃


我不认为他们可以处理它。至少我们知道有些引擎不能,因为众所周知这是导致许多内存泄漏的原因,例如:

function outer(node) {
    node.onclick = function inner() { 
        // some code not referencing "node"
    };
}

whereinner关闭node,形成一个循环引用inner -> outer's VariableContext -> node -> inner,它永远不会在例如 IE6 中被释放,即使 DOM 节点从文档中删除。不过,有些浏览器可以很好地处理这个问题:循环引用本身不是问题,而是 IE6 中的 GC 实现才是问题所在。但现在我离题了。

打破循环引用的一种常见方法是在outer. 即,设置node = null那么问题是现代 javascript 引擎是否可以为您做到这一点,他们能否以某种方式推断出一个变量未在其中使用inner

我认为答案是否定的,但我可以被证明是错误的。原因是下面的代码执行得很好:

function get_inner_function() {
    var x = "very big object";
    var y = "another big object";
    return function inner(varName) {
        alert(eval(varName));
    };
}

func = get_inner_function();

func("x");
func("y");

使用此 jsfiddle 示例亲自查看没有对xyinside 的引用inner,但仍然可以使用eval. (令人惊讶的是,如果你eval给别的东西起别名,比如myeval,然后调用myeval,你不会得到一个新的执行上下文——这甚至在规范中,参见 ECMA-262 中的第 10.4.2 和 15.1.2.1.1 节。)


编辑:根据你的评论,似乎一些现代引擎实际上做了一些聪明的技巧,所以我试着多挖掘一点。我遇到了这个讨论这个问题的论坛帖子,特别是一个关于如何在 V8 中分配变量的推文的链接它还专门涉及这个eval问题。似乎它必须解析所有内部函数中的代码。并查看引用了哪些变量,或者是否eval使用,然后确定每个变量应该分配在堆上还是堆栈上。挺整洁的。这是另一个博客,其中包含有关 ECMAScript 实现的大量详细信息。

这意味着即使内部函数从不“逃避”调用,它仍然可以强制在堆上分配变量。例如:

function init(node) {

    var someLargeVariable = "...";

    function drawSomeWidget(x, y) {
        library.draw(x, y, someLargeVariable);
    }

    drawSomeWidget(1, 1);
    drawSomeWidget(101, 1);

    return function () {
        alert("hi!");
    };
}

现在,因为init已经完成了它的调用,someLargeVariable不再被引用并且应该有资格被删除,但我怀疑它不是,除非内部函数drawSomeWidget已经被优化掉(内联?)。如果是这样,当使用自执行函数来模仿具有私有/公共方法的类时,这可能会经常发生。


回答下面的雷诺斯评论。我在调试器中尝试了上述场景(稍作修改),结果如我所料,至少在 Chrome 中是这样:

Chrome 调试器的屏幕截图 当内部函数正在执行时, someLargeVariable 仍在作用域内。

如果我注释掉someLargeVariable内部drawSomeWidget方法中的引用,则会得到不同的结果:

Chrome 调试器 2 的屏幕截图 现在someLargeVariable不在范围内,因为它可以在堆栈上分配。

非常感谢,eval它真的是 GC 的怪物,最近我被一个朋友告诉我,如果没有evalininner,一些引擎(在他的例子中是 SpiderMonkey)可以处理高级 GC 到闭包变量因为 javascript 引擎可以收集所有将inner通过简单地检查函数代码中的所有Identifier来使用的变量名称,但是当inner包含对 的任何调用时eval,它不会进行任何优化。在我看来,eval区别对待真的是一个精心设计的策略:)
2021-03-13 15:49:51
@waxwing 内部函数drawSomeWidgetget 在init调用结束时收集垃圾,然后someLargeVariable消失。
2021-03-17 15:49:51
@Raynos:你确定吗?我是这样看的:drawSomeWidgetuses someLargeVariable,所以someLargeVariable必须是堆分配的。现在,即使没有从返回的函数到 的直接引用,通过返回函数someLargeVariable的 [[scope]](或 VariableContext,如果您愿意),也有一个 b。
2021-03-26 15:49:51
@waxwing 返回的函数没有对drawSomeWidget. drawSomeWidget被销毁,因为 tehre 是零计数。someLargeVariable计数减少,因为drawSomeWidget被破坏了。someLargeVariabelcount 被销毁,因为 tehre 是零计数。
2021-04-10 15:49:51
哦,有趣。我在一些库中看到他们使用 Function 构造函数new Function(code-as-string)作为 的替代eval,根据我的测试,它不会遇到继承范围的“问题”。我想知道是否setIntervalsetTimeout必须以不同的方式处理?
2021-04-11 15:49:51

GC 没有标准的实现规范,每个引擎都有自己的实现。我知道一些 v8 的概念,它有一个非常令人印象深刻的垃圾收集器(停止世界,分代,准确)。如上例 2,v8 引擎有以下步骤:

  1. 创建名为 f1 的 f1 的 VariableEnvironment 对象。
  2. 创建该对象后,V8 创建了一个名为 H1 的初始隐藏类 f1。
  3. 表示 f1 的点在根级别到 f2。
  4. 创建另一个隐藏类 H2,基于 H1,然后向 H2 添加信息,将对象描述为具有一个属性 o1,将其存储在 f1 对象中的偏移量 0 处。
  5. 更新 f1 指向 H2 表明 f1 应该使用 H2 而不是 H1。
  6. 创建另一个隐藏类 H3,基于 H2,并添加属性 o2,将其存储在 f1 对象中的偏移量 1 处。
  7. 更新 f1 指向 H3。
  8. 创建名为 a1 的匿名 VariableEnvironment 对象。
  9. 创建一个名为 A1 的 a1 的初始隐藏类。
  10. 表明 a1 父是 f1。

在解析函数文字时,它会创建 FunctionBody。只在函数被调用时解析FunctionBody。下面的代码表明它在解析时不会抛出错误

function p(){
  return function(){alert(a)}
}
p();

所以在GC的时候H1,H2会被清扫,因为没有那个引用点。在我看来,如果代码是懒惰编译的,没有办法表明a1中声明的o1变量是对f1的引用,它使用JIT。

V8 同时使用了 Scavenge 和 Mark-Sweep-Compact,Scavenge 是参考 Cheney 的算法 en.wikipedia.org/wiki/Cheney%27s_algorithm
2021-03-15 15:49:51

如果 javascript 引擎使用引用计数方法

大多数javascript引擎使用压缩标记和清除垃圾收集器的某种变体,而不是简单的引用计数GC,因此引用循环不会引起问题。

他们还倾向于做一些技巧,以便涉及 DOM 节点(由浏览器在 JavaScript 堆外进行引用计数)的循环不会引入不可收集的循环。 XPCOM 循环收集器 为 Firefox 执行此操作。

循环收集器大部分时间都在积累(并忘记)指向可能涉及垃圾循环的 XPCOM 对象的指针。这是收集器操作的空闲阶段,其中nsAutoRefCnt注册和取消注册的特殊变体在收集器中非常迅速地通过“可疑”引用计数事件(从 N+1 到 N,对于非零 N)。

收集器会定期唤醒并检查任何已在其缓冲区中放置一段时间的可疑指针。这是收集器操作的扫描阶段。在这个阶段,收集器反复向每个候选者询问单例循环收集帮助器类,如果该帮助器存在,收集器会要求帮助器描述候选者的(拥有的)孩子。通过这种方式,收集器构建了可从可疑对象访问的所有权子图的图片。

如果收集器找到一组都相互引用的对象,并确定对象的引用计数都由组内的内部指针计算,则它认为该组是循环垃圾,然后尝试将其释放。这是收集器操作的取消链接阶段。在这个阶段,收集器遍历它找到的垃圾对象,再次咨询它们的帮助对象,要求帮助对象将每个对象与其直接子对象“断开链接”。

请注意,收集器还知道如何遍历 JS 堆,并且可以定位传入和传出它的所有权循环。

EcmaScript 和谐很可能也包含ephemerons以提供弱引用。

您可能会发现“XPCOM 内存管理的未来”很有趣。