# 内存泄漏与垃圾回收

垃圾回收(garbage collection)简称为GC。

# 为何要垃圾回收

JavaScript 程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript 的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

# 内存泄漏

JavaScript 引擎使用垃圾收集器来释放不再使用的内存。垃圾收集器的工作是识别并删除应用程序不再使用的对象。它通过持续监控代码中的对象和变量,并跟踪哪些对象和变量仍在被引用来实现这一点。一旦一个对象不再被使用,垃圾收集器将其标记为删除并释放它正在使用的内存。

垃圾收集器使用一种称为“标记和清除”的技术来管理内存。它首先标记所有仍在使用的对象,然后“扫过”堆并删除所有未标记的对象。这个过程会定期进行,并且在堆内存不足时进行,以确保应用程序的内存使用始终尽可能高效。

内存泄漏的常见原因如下:

# 循环引用

两个或多个对象相互引用时,就会发生这种情况,从而形成垃圾收集器无法破坏的循环。这可能会导致对象在不再需要后很长时间内仍保留在内存中。

let object1 = {};
let object2 = {};

object1.next = object2;
object2.prev = object1;

object1 = null;
object2 = null;

delete object1.next;
delete object2.prev;
1
2
3
4
5
6
7
8
9
10
11

创建了两个对象,object1 和 object2,并通过向它们添加 next 和 prev 属性在它们之间创建循环引用。然后,本想通过设置 object1 和 object2 为 null 以打破循环引用,但由于垃圾收集器无法打破循环引用,因此对象将在不再需要后很长时间内保留在内存中,从而导致内存泄漏。

为了避免这种类型的内存泄漏,我们可以使用手动内存管理,通过delete 关键字来删除创建循环引用的属性。避免此类内存泄漏的另一种方法是使用 WeakMap 和 WeakSet,它们允许您创建对对象和变量的弱引用。

# 事件监听未移除

将事件侦听器附加到元素时,它会创建对侦听器函数的引用,该函数可以防止垃圾收集器释放元素使用的内存。如果在不再需要该元素时未删除侦听器函数,这可能会导致内存泄漏。

let button = document.getElementById("my-button");

button.addEventListener("click", function() {
  console.log("Button was clicked!");
});

button.parentNode.removeChild(button);
1
2
3
4
5
6
7

将事件侦听器附加到按钮元素,然后从 DOM 中删除该按钮。即使按钮元素不再存在于文档中,事件侦听器仍附加到它,这会创建对侦听器函数的引用,以防止垃圾收集器释放该元素使用的内存。如果在不再需要该元素时未删除侦听器函数,这可能会导致内存泄漏。

为避免此类内存泄漏,在不再需要该元素时删除事件侦听器很重要:

button.removeEventListener("click", function() {
  console.log("Button was clicked!");
});
1
2
3

另一种方法是使用 EventTarget.removeAllListeners() 方法删除所有已添加到特定事件目标的事件侦听器。

button.removeAllListeners();
1

# 引用全局变量

创建全局变量时,可以从代码中的任何位置访问它,这使得很难确定何时不再需要它。这可能会导致变量在不再需要后很长时间仍保留在内存中。

let myData = {
  largeArray: new Array(1000000).fill("some data"),
  id: 1
};

myData = null;
1
2
3
4
5
6

创建了一个全局变量 myData 并在其中存储了大量数据,然后将 myData 设置为 null 以中断引用,但是由于该变量是全局变量,它仍然可以从您的代码中的任何位置访问,并且很难确定何时不再需要它,这会导致该变量在内存中保留很长时间在不再需要它之后,导致内存泄漏。

为避免这种类型的内存泄漏,您可以使用函数作用域技术。创建一个函数并在该函数内声明变量,以便只能在函数范围内访问。这样,当不再需要该函数时,变量会自动被垃圾回收。


function myFunction() {
  let myData = {
    largeArray: new Array(1000000).fill("some data"),
    id: 1
  };

  // do something with myData
  // ...
}
myFunction();
1
2
3
4
5
6
7
8
9
10
11

另一种方法是使用 JavaScript 的 let 和 const 代替 var,这允许您创建块范围的变量。用 let 和 const 声明的变量只能在定义它们的块内访问,并且当它们超出范围时将被自动垃圾收集。

{
  let myData = {
    largeArray: new Array(1000000).fill("some data"),
    id: 1
  };

  // do something with myData
  // ...
}
1
2
3
4
5
6
7
8
9

# 定时器未清除

如果在定时器中使用闭包而不清除定时器,可能会导致内存泄漏。

function startTimer() {
    let largeData = new Array(1000000).fill('*');
    setInterval(function() {
        console.log(largeData);
    }, 1000);
}
startTimer(); // largeData 会一直保持引用


// 解决 清除定时器
let timerId = setInterval(...);
clearInterval(timerId);
1
2
3
4
5
6
7
8
9
10
11
12

# 组件的销毁和创建频繁

在单页应用中,组件的销毁和创建频繁,若不清理闭包可能会导致内存泄漏。

function createComponent() {
    let largeData = new Array(1000000).fill('*');
    return function() {
        console.log(largeData);
    };
}
let component = createComponent();
// 若组件被销毁,确保清理 largeData 的引用
component = null; // 允许垃圾回收
1
2
3
4
5
6
7
8
9

# 回调地狱

在复杂的回调中,若不小心引用了外部变量,可能导致内存泄漏。

解决方案:确保在回调中不持有外部变量的引用,或在不需要时清理引用。

function fetchData() {
    let largeData = new Array(1000000).fill('*');
    someAsyncOperation(function() {
        console.log(largeData); // 可能导致内存泄漏
    });
}
1
2
3
4
5
6

# 闭包中大对象的引用未释放

在闭包内使用了较大的对象,若引用未被清除,会造成内存泄漏。

解决方案:在合适的时候设置 largeObject 为 null。

function outerFunction() {
    let largeObject = { data: new Array(1000000).fill('*') };
    return function innerFunction() {
        console.log(largeObject);
    };
}
const myFunc = outerFunction(); // largeObject 无法被回收
1
2
3
4
5
6
7

# 内存泄漏排查

# 使用浏览器开发者工具

  1. Memory 面板
    • 快照:可以拍摄内存快照,观察对象的保留情况。比较不同快照之间的变化,以识别哪些对象未被释放。
    • 步骤:
      • 打开开发者工具,切换到 "Memory" 面板。
      • 选择 "Take snapshot" 拍摄快照。
      • 执行一些操作,然后再次拍摄快照。
      • 比较两个快照,查看哪些对象仍然存在且未被释放。
  2. Allocation Timeline
    • 监测内存分配:通过录制性能,监测内存分配情况,观察内存使用的变化。
    • 步骤:
      • 在 "Performance" 面板中,点击 "Record" 按钮。
      • 执行一系列操作。
      • 停止录制,查看内存使用图表,分析内存高峰。
  3. 使用 Profiler
    • CPU 和内存分析:使用 Profiler 观察函数的调用频率和内存使用情况,找出性能瓶颈。
    • 步骤:
      • 进入 "Profiler" 面板,选择 "Record"。
      • 执行相关操作,停止记录。
      • 查看函数调用栈,分析哪些函数占用了过多的内存。

# 手动检测和审查代码

  1. 代码审查
    • 检查引起内存泄露的诱因的代码,例如:检查闭包、检查事件监听器等。
  2. 单元测试
    • 内存检测测试:在单元测试中添加内存使用情况监测,确保在组件卸载后不会发生内存泄漏。
  3. 强制垃圾回收
    • Chrome DevTools:在 Memory 面板中手动触发垃圾回收,观察内存变化。
    • 使用 window.performance.memory:获取当前内存使用情况,以便监测。

# 使用性能监测等工具

  1. 性能监测和分析
    • 持续监测, 在开发过程中定期检查内存使用情况,使用工具如 Lighthouse 分析应用性能。
    • 长时间测试,在应用运行较长时间后,监测内存使用,找出增长原因。
  2. 工具分析
    • memwatch-next:用于 Node.js 应用,监测内存使用情况并报告潜在的泄漏。
    • why-did-you-render:用于 React 应用,帮助分析组件渲染和内存使用情况。

# 垃圾回收方式

  • 手动回收

    何时分配内存、何时销毁内存都是由代码控制的,如 C/C++。

  • 自动回收

    垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放,如 JavaScript、Java、Python 等语言。

# 垃圾回收策略

# 标记清除

标记清除(mark and sweep)。

大部分浏览器以此方式进行垃圾回收,当变量进入执行环境(函数中声明变量,执行时)的时候,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”,在离开环境之后还有的变量则是需要被删除的变量。标记方式不定,可以是某个特殊位的反转或维护一个列表等。

垃圾收集器给内存中的所有变量都加上标记,然后去掉环境中的变量以及被环境中的变量引用的变量的标记。在此之后再被加上的标记的变量即为需要回收的变量,因为环境中的变量已经无法访问到这些变量。

# 引用计数

另一种不太常见的垃圾回收策略是引用计数。引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是 1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数就减1 。当这个引用次数变成 0 时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。这样,垃圾收集器下次再运行时,它就会释放那些引用次数为 0 的值所占的内存。

注意

该方式会引起内存泄漏的原因是它不能解决循环引用的问题。

低版本 IE 中有一部分对象并不是原生 JS 对象。例如,其 BOM 和 DOM 中的对象就是使用 C++ 以 COM(Component Object Model) 对象的形式实现的,而 COM 对象的垃圾收集机制采用的就是 引用计数策略。 因此虽然 IE 的 js 引擎是用的标记清除来实现的,但是 js 访问 COM 对象如 BOM, DOM 还是基于引用计数的策略的,也就是说只要在 IE 中涉及到 COM 对象,也就会存在循环引用的问题

# JS 里的垃圾回收机制

首先我们要明确 JS 里的数据存储分为栈存储和堆存储,这两者采用的垃圾回收机制是不同的。

V8 中主垃圾回收器就采用标记清除法进行垃圾回收。主要流程如下:

  • 标记:遍历调用栈,看老生代区域堆中的对象是否被引用,被引用的对象标记为活动对象,没有被引用的对象(待清理)标记为垃圾数据。
  • 垃圾清理:将所有垃圾数据清理掉

# 栈的垃圾回收机制

通过移动 ESP 指针(记录当前执行状态的指针)实现垃圾回收。执行栈中当一个函数执行完毕,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

function foo(){ 
  var a = 1;
  var b = {name:"极客邦"}
  function showName() { 
    var c = "极客时间";
    var d = {name:"极客时间"};
  } 
  showName()
  }
foo()
1
2
3
4
5
6
7
8
9
10

当代码执行到第六行时堆栈状态

垃圾回收1

当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程。

移动 ESP 前后的对比图:

垃圾回收2

从图中可以看出,当 showName 函数执行结束之后,ESP 向下移动到 foo 函数的执行上下文中,上面 showName 的执行上下文虽然保存在栈内存中,但是已经是无效内存了。比如当 foo 函数再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文。

# 堆的垃圾回收机制

# 代际假说(The Generational Hypothesis)

后续垃圾回收的策略都是建立在该假说的基础之上的。

特征:

  • 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问
  • 不死的对象,会活得更久

# 新老生代区别

在 V8 中会把堆分为新生代和老生代两个区域,两者的区别主要在一下几点:

新生代 老生代
存储对象 生存时间短的对象 生存时间久的对象
内存大小 32位下16MB64位下64MB 32位下700MB64位下1400MB
所用垃圾回收器 副垃圾回收器 主垃圾回收器

# 垃圾回收器共有的的工作流程

  • 标记空间中活动对象和非活动对象

    所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。

  • 回收非活动对象所占据的内存

    其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。

  • 内存整理

    一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如副垃圾回收器。

# 主垃圾回收器

  • 职责:负责老生区的垃圾回收。
  • 回收方式:标记 - 清除(Mark-Sweep),标记 - 整理(Mark-Compact)
  • 对象特征:
    1. 是对象占用空间大
    2. 对象存活时间长

注意

老生区除了新生区中晋升的对象,一些大的对象会直接被分配到老生区

  • 具体步骤:

处理老生代对象时,采用深度优先扫描,用三色标记算法

  1. 标记阶段

    从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。V8使用每个对象的两个mark-bits和一个标记工作栈来实现标记,两个mark-bits 编码三种颜色:白色(00),灰色(10)和黑色(11),白色表示对象可以回收,黑色表示对象不能回收,并且他的所有引用都被遍历完毕了,灰色表示不可回收,他的引用对象没有扫描完毕。

    具体扫描步骤如下:

    • 从已知对象开始,即roots(全局对象和激活函数), 将所有非 root 对象标记置为白色
    • 将 root 对象的所有直接引用对象入栈(marking worklist)
    • 依次 pop 出对象,出栈的对象标记为黑,同时将他的直接引用对象标记为灰色并 push 入栈
    • 栈空的时候,仍然为白色的对象可以回收
  2. 清除阶段

    清除白色标记的对象。但是进行清除后,内存会出现不连续的状态,对后续的大对象分配地址造成无意义的回收(因为可用内存的不足),这时就需要 Mark-Compact 来处理内存碎片了。

  3. 标记 - 整理

    由于对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,所以会执行标记整理,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

# 副垃圾回收器

  • 职责:负责新生区的垃圾回收。
  • 回收方式:Scavenge 算法。即把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域

垃圾回收3

  • 具体步骤:

    1. 对对象区域中的垃圾做标记;
    2. 标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
    3. 完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

为什么新生代空间比较小?怎么优化空间小的问题

由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。 也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。

# 优化垃圾回收器

由于 JavaScript 是运行在主线程之上的,在垃圾回收时会阻塞 JavaScript 脚本的执行,会造成页面卡顿等问题,使得用户体验不佳。为了解决上述问题,V8 团队推出了并行、并发和增量等垃圾回收技术,这些技术主要是从两方面来解决垃圾回收效率问题的:

  1. 将一个完整的垃圾回收的任务拆分成多个小的任务,解决单个垃圾回收时间长的问题
  2. 将标记对象、移动对象等任务转移到后台线程进行,减少主阻塞线程的时间。
  • 并行回收

    如果只有一个主线程进行垃圾回收,会造成停顿时间过长。所以 V8 团队推出主线程在执行垃圾回收的任务时,引入多个辅助线程来并行处理,这样就会加速垃圾回收的执行速度。副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。

  • 增量回收

    并行回收虽然能增加垃圾回收效率,但是还是一种阻塞的方式进行垃圾回收。增量回收采用将标记工作把垃圾回收工作分解为更小的块,每次只进行小部分垃圾回收,减少主线程阻塞时间。

  • 并发回收

    虽然增量回收已经能大大降低我们主线程阻塞的时间,但是所有的标记和清除还是在主线程上。那有没有办法可以在不阻塞主线程情况下执行呢?也由此 V8 推出了并发回收。并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作

在实际的应用中,这三种回收机制通常是融合在一起用的。