背景
function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();
使用上述代码,bigArrayBuffer 将永远泄漏。我没想到会这样,因为:
- 一秒钟后,引用
bigArrayBuffer的函数不再可调用。 - 返回的取消函数不引用
bigArrayBuffer。
关于垃圾回收
垃圾回收的基本原理
下面我们一起看看为什么。先来了解一下JavaScript中的垃圾回收(Garbage Collection, GC)是自动进行的,开发者无需手动管理内存。JavaScript引擎(如V8引擎)会自动检测不再使用的对象,并释放其占用的内存。为了了解和优化JavaScript应用的内存使用,理解垃圾回收的基本原理和机制是很重要的。
JavaScript的垃圾回收主要基于两个基本算法:标记-清除(Mark-and-Sweep)和引用计数(Reference Counting)。
1. 标记-清除算法(Mark-and-Sweep)
这是现代JavaScript引擎中最常用的垃圾回收算法。
- 标记阶段:从根对象(通常是全局对象,如
window)开始,遍历所有可达对象,并标记它们为“活动的”。 - 清除阶段:遍历内存中的所有对象,清除那些未被标记的对象,释放其内存。
标记-清除算法解决了循环引用的问题,因为它仅关注对象是否可达,而不考虑对象的引用计数。
2. 引用计数算法(Reference Counting)
这种算法较简单,但在现代JavaScript引擎中不常使用。
- 每个对象都有一个引用计数,当一个对象被引用时计数加1,当引用被移除时计数减1。
- 引用计数为0的对象会立即被回收。
引用计数算法无法处理循环引用的问题,因为循环引用的对象即使彼此引用,引用计数不为0,但实际上是不可达的。
V8引擎中的垃圾回收
V8引擎(Chrome和Node.js使用的JavaScript引擎)使用了一种分代垃圾回收(Generational Garbage Collection)机制,将内存分为新生代(New Space)和老生代(Old Space)。
新生代(New Space)
新分配的对象存储在新生代,内存较小且回收频繁。V8使用一种称为Scavenge的算法进行回收:
- 新生代分为两个半空间:使用空间(From Space)和空闲空间(To Space)。
- 当使用空间满时,将活动对象复制到空闲空间,并清除使用空间。
- 角色互换,空闲空间变为使用空间,原使用空间变为空闲空间。
老生代(Old Space)
存活时间较长的对象存储在老生代,内存较大且回收不频繁。V8使用标记-清除和标记-压缩(Mark-and-Compact)算法:
- 标记-清除:标记活动对象,清除未标记对象。
- 标记-压缩:标记活动对象后,将其压缩到内存的一端,减少内存碎片。
垃圾回收的触发
JavaScript引擎会自动触发垃圾回收,开发者无需手动干预。以下情况可能触发垃圾回收:
- 新生代内存达到阈值。
- 老生代内存达到阈值。
- 显式调用内存密集操作。
示例代码
以下是一些避免内存泄漏的示例代码:
// 示例1:避免全局变量
function createScope() {
let localVariable = 'I am local'; // 使用局部变量
console.log(localVariable);
}
createScope();
// 示例2:合理使用闭包
function createClosure() {
let closureVariable = 'I am a closure variable';
return function() {
console.log(closureVariable);
};
}
let closure = createClosure();
closure(); // 调用闭包
closure = null; // 解除闭包引用
// 示例3:清理定时器
let timerId = setTimeout(() => {
console.log('This is a timer');
}, 1000);
clearTimeout(timerId); // 清除定时器
// 示例4:清理事件监听器
let element = document.getElementById('myElement');
function handleClick() {
console.log('Element clicked');
}
element.addEventListener('click', handleClick);
element.removeEventListener('click', handleClick); // 移除事件监听器
原因分析
我们再来看看刚才的情况,如果是以下情况,不会出现泄漏:
function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
console.log(bigArrayBuffer.byteLength);
}
demo();
函数执行后,bigArrayBuffer 不再需要,可以被垃圾回收。
还有这样也不会泄漏:
function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
}
demo();
在这种情况下:
- 引擎看到
bigArrayBuffer被内部函数引用,所以会保留它。它与调用demo()时创建的作用域关联。 - 一秒钟后,引用
bigArrayBuffer的函数不再可调用。 - 由于作用域内没有任何可调用的内容,作用域以及
bigArrayBuffer可以被垃圾回收。
还有这样也不会泄漏:
function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log('hello');
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();
在这种情况下,引擎知道它不需要保留 bigArrayBuffer,因为内部可调用的函数没有访问它。
如果改成如下,这里情况就变得混乱了:
function demo() {
const bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();
这样子会泄漏,因为:
- 引擎看到
bigArrayBuffer被内部函数引用,所以会保留它。它与调用demo()时创建的作用域关联。 - 一秒钟后,引用
bigArrayBuffer的函数不再可调用。 - 但是,作用域仍然存在,因为 'cancel' 函数仍然可调用。
bigArrayBuffer与作用域关联,所以它仍然保留在内存中。
我以为引擎会识别这种情况,在 bigArrayBuffer 不再可访问时进行垃圾回收,但事实并非如此。
globalThis.cancelDemo = null;
现在 bigArrayBuffer 可以被垃圾回收了,因为作用域内没有任何可调用的内容。
又或者我们在清除定时器时同时清除bigArrayBuffer
```javascript
function demo() {
let bigArrayBuffer = new ArrayBuffer(999_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => {
clearTimeout(id);
bigArrayBuffer = null
}
}
globalThis.cancelDemo = demo();
那么在我们执行cancelDemo的时候内存将被回收。所以这里最重要的原因还是因为globalThis.cancelDemo持有了引用,如果没有这个引用,都会按照我们预期那样回收。
结论
所以,如果你在定时器(setTimeout)的回调中使用了大对象,而且引用又被其它持有的时候,请确保解除回调函数中对大对象的引用。