Web应用程序中的内存泄漏是广泛的,众所周知难以调试。如果我们想避开它们,它有助于了解垃圾收集器如何决定可以和不能收集哪些物体。在本文中,我们将探讨一些情况,其行为可能会让您感到惊讶。
如果您不熟悉垃圾收藏的基础知识,那么一个好的起点将是Lin Clark或MDN上的Memory Management的A 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()
函数。
使用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()
函数返回后不再存在变量x
,x
引用的对象仍由globalThis.temp
变量持有。另一方面,z
和y
无法从全局对象或执行堆栈中达到,并且将被收集。如果我们现在运行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
。我们不能再达到z
或y
。但是这是什么,尽管不再可以到达,但z
和y
没有收集。
可能的理论是,由于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.parentElement
和y
通过temp.nextSibling
到达z
。因此,这三个要素都将保持生命。
现在,如果我们执行temp.remove()
,y
和z
将被收集,因为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()
从未解决,那么效果功能会发生什么?即使在组件卸下后,它也会继续等待承诺吗?它会保持isMounted
和setStatus
吗?
让我们将此示例简化为不需要反应的更基本的形式:
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
进行引用,那么即使在组件卸下后,词汇范围中的效果和词汇范围中的所有内容也将保持活力。当承诺定居时,或者当垃圾收集器无法再追踪到诺言的resolve
和reject
时,它将被收集。
综上所述
在本文中,我们已经了解了FinalizationRegistry
,以及如何使用它来检测何时收集对象。我们还看到,有时垃圾收集器即使是安全的,也无法收回记忆。这就是为什么意识到它可以做什么和不能做什么会有所帮助。
值得注意的是,不同的JavaScript引擎甚至同一引擎的不同版本都可以具有巨大的垃圾收集器实现,并且它们之间的外部可观察到的差异。
实际上,ECMAScript specification甚至不需要实现 垃圾收集器,更不用说规定某种行为了。
但是,上面的所有示例均已证实在V8(Chrome),JavascriptCore(Safari)和Gecko(Firefox)中使用相同的工作。