内存泄漏:关于定时器的引用

367 阅读5分钟

背景

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)的回调中使用了大对象,而且引用又被其它持有的时候,请确保解除回调函数中对大对象的引用。