JavaScript垃圾收集器的实验
#javascript #网络开发人员 #性能 #调试

Web应用程序中的内存泄漏是广泛的,众所周知难以调试。如果我们想避开它们,它有助于了解垃圾收集器如何决定可以和不能收集哪些物体。在本文中,我们将探讨一些情况,其行为可能会让您感到惊讶。

如果您不熟悉垃圾收藏的基础知识,那么一个好的起点将是Lin Clark或MDN上的Memory ManagementA Crash Course in Memory Management。考虑在继续之前阅读其中之一。

检测物体处置

最近,我了解到JavaScript提供了一个名为FinalizationRegistry的类,该类允许您通过编程检测对象何时收集垃圾。它可在所有主要的Web浏览器和Node.js。

中使用。

基本用法示例:

const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};
    registry.register(x, 'x has been collected');
}

example();

// Some time later: "x has been collected"

example()函数返回时,x引用的对象不再可接触,并且可以处置。

不过,

很可能不会立即处理。发动机可以首先决定处理更重要的任务,或者等待更多对象变得无法到达,然后批量处理它们。但是,您可以通过单击DevTools µMemory选项卡中的小垃圾图标来强制垃圾收集。 node.js没有垃圾图标,但是使用--expose-gc flag启动时提供了一个全局的gc()函数。

DevTools Memory Tab

使用FinalizationRegistry在我的工具包中,我决定检查一些情况,我不确定垃圾收集器的表现如何。我鼓励您查看下面的示例,并就它们的行为方式做出自己的预测。

示例1:嵌套对象

const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};
    const y = {};
    const z = { x, y };

    registry.register(x, 'x has been collected');
    registry.register(y, 'y has been collected');
    registry.register(z, 'z has been collected');

    globalThis.temp = x;
}

example();

在这里,即使example()函数返回后不再存在变量xx引用的对象仍由globalThis.temp变量持有。另一方面,zy无法从全局对象或执行堆栈中达到,并且将被收集。如果我们现在运行globalThis.temp = undefined,也将收集以前称为x的对象。这里没有惊喜。

示例2:关闭

const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};
    const y = {};
    const z = { x, y };

    registry.register(x, 'x has been collected');
    registry.register(y, 'y has been collected');
    registry.register(z, 'z has been collected');

    globalThis.temp = () => z.x;
}

example();

在此示例中,我们仍然可以通过致电globalThis.temp()到达x。我们不能再达到zy。但是这是什么,尽管不再可以到达,但zy没有收集。

可能的理论是,由于z.x是属性查找,因此引擎不知道它是否可以直接引用x替换查找。例如,如果x是一个Getter,该怎么办。因此,发动机被迫保留对z的引用,因此被迫对y进行引用。要测试该理论,让我们修改以下示例:globalThis.temp = () => { z; };。现在显然无法到达z,但仍未收集。

我认为正在发生的事情是,垃圾收集器仅关注以下事实:分配给temp的封闭式的z is in the lexical scope,并且看起来并没有更多。遍历整个对象图并标记仍然“活着”的对象,这是一个至关重要的操作,需要快速。即使从理论上讲,垃圾收集器可以弄清楚不使用z,但这将是昂贵的。而且不是特别有用,因为您的代码通常不包含其中的变量。

示例3:评估

const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = {};

    registry.register(x, 'x has been collected');

    globalThis.temp = (string) => eval(string);
}

example();

在这里,我们仍然可以通过致电temp('x')从全球范围中达到x。发动机无法安全地收集eval词汇范围内的任何对象。而且它甚至没有尝试分析评估收到的论点。即使是像globalThis.temp = () => eval(1)这样无辜的东西也可以防止垃圾收集。

如果评估隐藏在别名后面,例如globalThis.exec = eval?或者,如果没有明确提及而使用它,该怎么办?例如:

console.log.constructor('alert(1)')(); // opens an alert box

这是否意味着每个函数呼叫都是可疑的,没有任何安全收集的东西?幸运的是,不。 JavaScript在direct and indirect eval之间有所区别。只有当您直接调用eval(string)时,它将在当前词汇范围中执行代码。但是,即使是小小的直接的任何内容,例如eval?.(string),都会在全局范围中执行代码,并且无法访问封闭函数的变量。

示例4:DOM元素

const registry = new FinalizationRegistry(message => console.log(message));

function example() {
    const x = document.createElement('div');
    const y = document.createElement('div');
    const z = document.createElement('div');

    z.append(x);
    z.append(y);

    registry.register(x, 'x has been collected');
    registry.register(y, 'y has been collected');
    registry.register(z, 'z has been collected');

    globalThis.temp = x;
}

example();

此示例与第一个示例有些相似,但是它使用DOM元素而不是普通对象。与普通的物体不同,DOM元素具有与父母和兄弟姐妹的链接。您可以通过temp.parentElementy通过temp.nextSibling到达z。因此,这三个要素都将保持生命。

现在,如果我们执行temp.remove()yz将被收集,因为x已与父母分离。但是x不会被收集,因为temp仍然引用了它。

示例5:承诺

警告:此示例是一个更复杂的示例,展示了涉及异步操作和承诺的场景。随意跳过它,然后跳到下面的摘要。

从未解决或拒绝的承诺会发生什么?它们是否与.then的整个链条相连的整个链条都保持在记忆中?

作为一个现实的例子,这是React项目中的常见anti-pattern

function MyComponent() {
    const isMounted = useIsMounted();
    const [status, setStatus] = useState('');

    useEffect(async () => {
        await asyncOperation();
        if (isMounted()) {
            setStatus('Great success');
        }
    }, []);

    return <div>{status}</div>;
}

如果asyncOperation()从未解决,那么效果功能会发生什么?即使在组件卸下后,它也会继续等待承诺吗?它会保持isMountedsetStatus吗?

让我们将此示例简化为不需要反应的更基本的形式:

const registry = new FinalizationRegistry(message => console.log(message));

function asyncOperation() {
    return new Promise((resolve, reject) => {
        /* never settles */
    });
}

function example() {
    const x = {};
    registry.register(x, 'x has been collected');
    asyncOperation().then(() => console.log(x));
}

example();

以前,我们看到垃圾收集器不会尝试执行任何复杂的分析,而只是跟随从对象到对象的指针来确定其“ livesice”。因此,在这种情况下,x将被收集!

可能令人惊讶

让我们看一下当某事仍在持有Promise resolve的引用时,这个示例的外观。在现实世界中,这可能是setTimeout()fetch()

const registry = new FinalizationRegistry(message => console.log(message));

function asyncOperation() {
    return new Promise((resolve) => {
        globalThis.temp = resolve;
    });
}

function example() {
    const x = {};
    registry.register(x, 'x has been collected');
    asyncOperation().then(() => console.log(x));
}

example();

在这里,globalThis保持temp活着,它使resolve活着,它使.then(...)回调保持活力,这使x保持活力。一旦我们执行globalThis.temp = undefined,就可以收集x。顺便说一句,保存对承诺本身的引用不会阻止x收集。

回到React示例:如果某事仍在对Promise resolve进行引用,那么即使在组件卸下后,词汇范围中的效果和词汇范围中的所有内容也将保持活力。当承诺定居时,或者当垃圾收集器无法再追踪到诺言的resolvereject时,它将被收集。

综上所述

在本文中,我们已经了解了FinalizationRegistry,以及如何使用它来检测何时收集对象。我们还看到,有时垃圾收集器即使是安全的,也无法收回记忆。这就是为什么意识到它可以做什么和不能做什么会有所帮助。

值得注意的是,不同的JavaScript引擎甚至同一引擎的不同版本都可以具有巨大的垃圾收集器实现,并且它们之间的外部可观察到的差异。

实际上,ECMAScript specification甚至不需要实现 垃圾收集器,更不用说规定某种行为了。

但是,上面的所有示例均已证实在V8(Chrome),JavascriptCore(Safari)和Gecko(Firefox)中使用相同的工作。