内存泄漏(Memory Leak):程序中不会再被使用到的内存且无法被浏览器 GC (Garbage Collection)回收。

内存泄漏可能导致诸如应用程序速度降低,崩溃,高延迟等问题。

⚠️本文内容都是基于 Chrome浏览器 V8 JS 解析引擎。

如何导致 内存泄漏

一句话简单总结内存泄漏:应该存在时间很短的Object,但是被无意中引用导致无法被销毁。

全局引用

1
2
3
4
5
6
7
8
9
10
// This will be hoisted as a global variable
function hello() {
foo = "Message";
}

// This will also become a global variable as global functions have
// global `this` as the contextual `this` in non strict mode
function hello() {
this.foo = "Message";
}

上面👆代码中,foo变量被挂载到全局对象global中,导致即使它在程序中不会再被使用到,但是无法被GC回收。

建议在编码中尽量使用letconst.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JavaScript memory leaks</title>
</head>
<body>
<div id="app">
hello,world!
</div>
</body>
<script>

var a = {};
console.log(a in window ) // true

let b = {};
console.log(b in window) // false

</script>
</html>

DOM内存泄露

为了操作方便我们会将DOM节点赋值给JS变量,但有时我们会操作删除掉DOM节点,可是引用的了该DOM节点的变量没有清空,从而导致在Heap内存中会一直保持该DOM节点的数据。

1
2
3
4
5
6
const buttonOne = document.querySelector('#button-a');
const buttonTwo = document.querySelector('#button-b');

buttonOne.addEventListener('click', () => {
document.body.removeChild(buttonTwo);
});

在上面的代码中,通过单击buttonOne 中删除#btton-bDOM节点,但我们从不删除存储在变量buttonTwo 中 的引用。这种内存泄漏可能非常危险。因为在buttonTwo作用域中不会被销毁,则变量buttonTwo不会被GC回收。

可以通过一下方式避免这样的情况:

1
2
3
4
5
6
const buttonOne = document.querySelector('#button-a');

buttonOne.addEventListener('click', () => {
const buttonTwo = document.querySelector('#button-b');
document.body.removeChild(buttonTwo);
});

在这里,我们通过单击buttonOne从DOM中删除buttonTwo并收集垃圾。

还有一种情况:清除了父DOM节点,但子节点有被变量引用则父节点还是不会被销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var select = document.querySelector;
var treeRef = select("#tree");
var leafRef = select("#leaf");
var body = select("body");

body.removeChild(treeRef);

//#tree can't be GC yet due to treeRef
treeRef = null;

//#tree can't be GC yet due to indirect
//reference from leafRef

leafRef = null;
//#NOW can be #tree GC

Memory 结构

内存分为栈内存(Stack)和堆内存(Heap):

  • Stack: ** 这是静态数据的存储位置。包括方法/函数框架、基本数据类型值和指向对象的指针。Stack**的内存管理有操作系统(OS)处理。
  • Heap: 这里是V8引擎存储JS对象和动态数据的地方。这是内存中最大的区域,有Garbage Collection管理。

V8 通过GC管理Heap内存。简单地说,它释放孤立对象使用的内存,即不再从 Stack 中直接或间接引用的对象(通过另一个对象的引用),以便为新对象创建创造空间。
GC负责回收未被使用的内容供V8进程使用。GC会讲Heap中的对象根据存活时间分为两种类型,并使用三种算法进行内存回收。以保证内存回收的高效和准确。

Heap memory

Heap是 V8存储对象和动态数据的地方。它是有一大块内存空间组成,它也是Garbage Collection工作的地方。Heap有很多部分组成,只有New spaceOld space是有Garbage Collection进行管理:

  • New Space (Young generation) : 它是生命周期较短的对象存储的地方。它是通过Scavenger(Minor GC)算法进行管理。

  • Old Space (Old generation): 当在 New Space保存的数据经过两次Scavenger(Minor GC)循环后还在未被清除,则会被移到此处。这里的管理内存算法是 Major GC(Mark-Sweep & Mark-Compact).

    • Old pointer space - 包含具有指向其他对象的指针的幸存对象。
    • Old data space - 包含仅包含数据的对象(没有指向其他对象的指针)。
  • Large object space - 这里存储的对象的大小超过其它内存模块大小限制。

  • Code-space: 存储着 Just In Time(JIT)编译后的代码块。这是唯一具有可执行内存的空间。

  • Cell space, property cell space, and map space: 这些区域存放Cell、属性Cell和Map,每个区域因为都是存放相同大小的元素,因此内存结构很简单。

每个区域都由一组内存页构成。内存页是一块连续的内存,经mmap(或者Windows的什么等价物)由操作系统分配而来。除大对象区的内存页较大之外,每个区的内存页都是1MB大小,且按1MB内存对齐。

Stack memory

每一个V8进程拥有一块 Stack space.它存储着 ‘’方法 的框架“, 静态数据项,指向Heap space中对象的指针。它有操作系统直接管理。

V8 内存管理:Garbage Collection (GC)

V8内存管理简单概括是:它会将Heap中孤立的对象(在程序中未被引用的对象)清除掉,以便为创建新对象留出空间。

根据对象存活的时间长短,V8将保存对象的Heap分为两类:new space(young generation)old space(old generation).

  • New Space (Young generation) : 它是生命周期较短的对象存储的地方。它是通过Scavenger(Minor GC)算法进行管理。
  • Old Space (Old generation): 当在 New Space保存的数据经过两次Scavenger(Minor GC)循环后还在未被清除,则会被移到此处。这里的管理内存算法是 Major GC(Mark-Sweep & Mark-Compact).
    • Old pointer space - 包含具有指向其他对象的指针的幸存对象。
    • Old data space - 包含仅包含数据的对象(没有指向其他对象的指针)。

Minor GC (Scavenger)

new space的空间很小一般是1-8M,它用来保存存活时间较短的对象。

new space分配内存很简单:当我们想为新对象保留空间时,我们只需要创建一个分配指针。当该分配指针指到new space尾部时,则会触发一次 Minor GC.这个过程被称为 Scavenger,它使用的是 Cheney’s algorithm (一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用)。它会被频繁触发,使用并发辅助器它执行效率很高。

Cheney’s algorithm 将新生代堆分为两部分,分别叫from-spaceto-space,工作方式也很简单,就是将from-space中存活的活动对象复制到to-space中,并将这些对象的内存有序的排列起来,然后将from-space中的非活动对象的内存进行释放,完成之后,将from spaceto space进行互换,这样可以使得新生代中的这两块区域可以重复利用。

new space分为两个大小相等的半空间:to-space and from-space。大多数分配都是from space进行(某些类型的对象除外,例如始终在旧空间中分配的可执行代码)。from space填充时,将触发次要 GC。

  • 标记活动对象和非活动对象
  • 复制 from space 的活动对象到 to space 并对其进行排序
  • 释放 from space 中的非活动对象的内存
  • 将 from space 和 to space 角色互换

那么,垃圾回收器是怎么知道哪些对象是活动对象和非活动对象的呢?

有一个概念叫对象的可达性,表示从初始的根对象(window,global)的指针开始,这个根指针对象被称为根集(root set),从这个根集向下搜索其子节点,被搜索到的子节点说明该节点的引用对象可达,并为其留下标记,然后递归这个搜索的过程,直到所有子节点都被遍历结束,那么没有被标记的对象节点,说明该对象没有被任何地方引用,可以证明这是一个需要被释放内存的对象,可以被垃圾回收器回收。

当在 New Space 保存的数据经过两次 Minor GC 循环后还在未被清除,则会被移到 old space

Major GC (Mark-Sweep-Compact)

当V8觉得没有足够的 old space时就会触发 Major GCold space 对象来至于 new space,V8 动态的计算极限值。

Scavenger 算法非常适合小数据大小,但对于大型堆来说不切实际,因为它具有内存开销,因此主要 GC 是使用 Mark-Sweep-Compact 算法完成的。

Mark-Sweep-Compact: 算法分为三步

  • Mark - 第一步,其中GC标识哪些对象正在使用,哪些对象未在使用。
  • Sweeping - GC遍历Heap,并记下未标记为活动的任何对象的内存地址。此空间现在在free-list中标记为free,可用于存储其他对象.
  • Compacting - Sweeping之后,如果需要,所有幸存的对象将被移动到一起。这将减少碎片,并提高将内存分配给较新的对象的性能。

Stop-The-World

由于垃圾回收是在JS引擎中进行的,而Mark-Compact算法在执行过程中需要移动对象,而当活动对象较多的时候,它的执行速度不可能很快,为了避免JavaScript应用逻辑和垃圾回收器的内存资源竞争导致的不一致性问题,垃圾回收器会将JavaScript应用暂停,这个过程,被称为全停顿(stop-the-world)。

在新生代中,由于空间小、存活对象较少、Scavenge算法执行效率较快,所以全停顿的影响并不大。而老生代中就不一样,如果老生代中的活动对象较多,垃圾回收器就会暂停主线程较长的时间,使得页面变得卡顿。

优化 Orinoco

orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题,它利用了增量标记、懒性清理、并发、并行来降低主线程挂起的时间。

增量GC - Incremental GC

增量式垃圾回收是主线程间歇性的去做少量的垃圾回收的方式。我们不会在增量式垃圾回收的时候执行整个垃圾回收的过程,只是整个垃圾回收过程中的一小部分工作。做这样的工作是极其困难的,因为 JavaScript 也在做增量式垃圾回收的时候同时执行,这意味着堆的状态已经发生了变化,这有可能会导致之前的增量回收工作完全无效。从图中可以看出并没有减少主线程暂停的时间(事实上,通常会略微增加),只会随着时间的推移而增长。但这仍然是解决问题的的好方法,通过 JavaScript 间歇性的执行,同时也间歇性的去做垃圾回收工作,JavaScript 的执行仍然可以在用户输入或者执行动画的时候得到及时的响应。

增量标记 Concurrent marking

标记是使用多个辅助线程同时完成的,而不会影响主要的JavaScript线程。写屏障用于跟踪在辅助线程进行标记时JavaScript创建的对象之间的新引用。

增量清除和压缩 Concurrent sweeping/compacting

在辅助线程同时进行清理和压缩,而不会影响主要的JavaScript线程。

懒清理 Lazy sweeping

增量标记只是对活动对象和非活动对象进行标记,惰性清理用来真正的清理释放内存。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理的过程延迟一下,让JavaScript逻辑代码先执行,也无需一次性清理完所有非活动对象内存,垃圾回收器会按需逐一进行清理,直到所有的页都清理完毕。

Minor GC 优化

V8在新生代垃圾回收中,使用并行(parallel)机制,在整理排序阶段,也就是将活动对象从from-to复制到space-to的时候,启用多个辅助线程,并行的进行整理。由于多个线程竞争一个新生代的堆的内存资源,可能出现有某个活动对象被多个线程进行复制操作的问题,为了解决这个问题,V8在第一个线程对活动对象进行复制并且复制完成后,都必须去维护复制这个活动对象后的指针转发地址,以便于其他协助线程可以找到该活动对象后可以判断该活动对象是否已被复制。

Major GC 优化

V8 中的主垃圾回收器主要使用并发标记,一旦堆的动态分配接近极限的时候,将启动并发标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用。在 JavaScript 执行的时候,并发标记在后台进行。写入屏障(write barriers)技术在辅助线程在进行并发标记的时候会一直追踪每一个 JavaScript 对象的新引用。

当并发标记完成或者动态分配到达极限的时候,主线程会执行最终的快速标记步骤;在这个阶段主线程会被暂停,这段时间也就是主垃圾回收器执行的所有时间。在这个阶段主线程会再一次的扫描根集以确保所有的对象都完成了标记;然后辅助线程就会去做更新指针和整理内存的工作。并非所有的内存页都会被整理,之前提到的加入到空闲列表的内存页就不会被整理。在暂停的时候主线程会启动并发清理的任务,这些任务都是并发执行的,并不会影响并行内存页的整理工作和 JavaScript 的执行。

Refer To

https://dev.to/deepu105/visualizing-memory-management-in-v8-engine-javascript-nodejs-deno-webassembly-105p

https://github.com/yacan8/blog/issues/33

http://newhtml.net/v8-garbage-collection/