JavaScript 垃圾回收机制和内存泄漏 - 第一部分(1/3)

105 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

一、概述

我们知道JavaScript的自动垃圾回收,不用程序员手动回收内存,为了更加了解内存的回收机制,本文浅浅探讨 JavaScript 和 Google V8引擎的垃圾回收机制以及那些操作会造成内存泄漏

  • 了解JavaScript和V8引擎垃圾回收机制
  • 了解常见的内存泄漏操作以及排查方法

该文章会分为以下三个方面来讲解:

  1. 垃圾回收机制
  2. V8引擎对垃圾回收机制的优化
  3. 常见的内存泄漏

二、JavaScript垃圾回收机制

2.1 什么是GC

GC 就是 Garbage Collection 也就是我们常说的垃圾回收机制。程序在工作中会产生很多不用的内存或者使用过了,以后也不会再用的内存空间,而GC就是负责回收这些不用的内存空间,因为它工作在引擎的内部,对于前端来说是相对无感的,那么这一套相对无感的操作也就是GC(垃圾回收机制)

当然也不是所有语言都有 GC,一般的高级语言里面会自带 GC,比如 Java、Python、JavaScript 等,也有无 GC 的语言,比如 C、C++ 等,那这种就需要我们程序员手动管理内存了,相对比较麻烦。

2.2 垃圾的产生 & 为何需要回收

举个例子

let x = {
  name: "winter",
};

x = [1, 2, 3];

首先声明了一个变量x,它引用了对象{name: "winter"},接着把这个变量重新赋值给了一个数组对象,那么之前的对象应用关系就没有了,如下图:

image.png

没有了引用关系后,这部分内存就不会被使用了,一个两个还好,多了的话内存也会受不了,所以就需要被清理(回收)。

程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。

2.3 垃圾回收策略

在 JavaScript 内存管理中有一个概念叫做 可达性 ,就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收。

至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)它并给予清理的问题, JavaScript 垃圾回收机制的原理就是定期找出那些不再用到的内存(变量),然后释放其内存。(不是实时的找出无用内存并释放的原因:实时开销太大了)。有两种垃圾回收的方法:

  • 标记清除法
  • 引用计数算法

2.3.1 标记清除法

原理

标记清除( Mark-Sweep ),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的avaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。

此算法分为 标记清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。也就是说每个对象都有一个标记其状态。

怎么给变量标记的呢?

  1. 当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记);

  2. 维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表;

其实,怎样标记对我们来说并不重要,重要的是策略。

引擎在执行 GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象、文档DOM树 等。

整个标记清除算法大致过程就像下面这样:

  1. 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0

  2. 然后从各个根对象开始遍历,把不是垃圾的节点改成1

  3. 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间;

  4. 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收;

优点

比较简单,无非就是被标记和没有被标记的情况,这使得一位二进制位(0和1)就可以为其标记,非常简单

缺点

会产生内存碎片, 在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的, 并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题,举个例子:

假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配:

常见包括三种分配策略找到合适的块内存:

  1. First-fit ,找到大于等于 size 的块立即返回;
  2. Best-fit ,遍历整个空闲列表,返回大于等于 size 的最小分块;
  3. Worst-fit ,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fitBest-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择

综上所述,标记清除算法或者说策略就有两个很明显的缺点:

  1. 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块;

  2. 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢;

归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了

标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存,举个例子:

2.3.2 引用计数法

原理

引用计数( Reference Counting ),这其实是早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,但因为它的问题很多,目前很少使用这种算法了。

它的原理是跟踪记录每个变量值被使用的次数

  1. 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1;
  2. 如果同一个值又被赋给另一个变量,那么引用数加 1;
  3. 如果该变量的值被其他的值覆盖了,则引用次数减 1;
  4. 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存;

举个例子:

const base = { name: "winter" };
let a = base;   // base: 计数1
let b = a;      // base: 计数2

a = null;       // base: 计数1
b = null;       // base: 计数0
...             // GC

虽然这种方式很简单,但是在引用计数这种算法出现没多久,就遇到了一个很严重的问题——循环引用,即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A

function func() {
  let A = { b: null };
  let B = { a: null };

  // 通过各自的属性互相引用
  A.b = B;
  B.a = A;
}

对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,但是,在函数 func 执行完成之后,对象 A 和 B 是要被清理的,但使用引用计数则不会被清理,因为它们的引用数量不会变成 0,假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放。

优点

  1. 引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾;
  2. 标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了;

缺点

  1. 需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限;
  2. 无法解决循环引用无法回收的问题;

其他部分