垃圾回收(GC)那些事儿

3,955 阅读13分钟

前言

垃圾回收是什么,你们是不是想到垃圾车来收垃圾了?耳边响起东方红?

是不是还有人想到想到的是废品回收:收电饭煲、高压窝、煤气灶~~~

其实今天我们要讲的是内存中·垃圾回收啦~

第一次了解垃圾回收是在一个公众号看到的,当时讲了一下标记清除法和引用计数法,但是当时存在很多疑惑,比如可达不可达到底是什么?当时也没太在意。今天重新了解,用自己的话总结分享出来。希望能给您一些启发和思考。

这篇文章包含以下知识点:

  • 什么是垃圾回收
  • 如何判断是否为垃圾
    • 可达性
  • 垃圾回收算法有哪些,各自的特点
  • 内存泄漏

在文章开始前要知道一个很重要的知识,JS的内存生命周期。今天要讲的就是那些关于内存释放时的故事。

  • 1.为变量分配内存
  • 2.使用分配的内存
  • 3.不需要的时候将内存释放

一、什么是垃圾回收

首先我认为垃圾回收比较难理解的一个原因是:它比较抽象,毕竟是浏览器内部的操作,是我们肉眼不可见的。

可以想象我们日常生活中那些废弃的没用的东西就是“垃圾”,为了占位置就要将生活垃圾清理掉。js内存管理也是如此,只不过他的内存管理是自动执行的,我们不可见的(但是在没有垃圾回收机制的语言中就需要人为管理内存,比如c语言)。

知识点1

JavaScript 引擎中有一个后台进程称为垃圾回收器,它监视所有对象,并删除那些不可访问的对象(垃圾)。

知识点2

我们在创建一个字符串、数组等都看作对象(不管是基本类型还是引用类型)都会为这个对象开辟一个内存空间来保存这个变量。如果访问不到这个对象的时候(没用了)就是垃圾

那么你可能会问, 不可访问的对象是什么呢?怎么知道对象是否可以访问呢?下面就由我一一为大家道来

二、如何判断垃圾

如何判断垃圾前面说过就是看这个对象能否被访问,那如何知道对象能否被访问?有一个专业的词叫可达性。根据对象是否可达来判断。

可达性

JavaScript 中内存管理的主要概念是可达性。

简单地说,“可达性” 值就是那些以某种方式可访问或可用的值,它们被保证存储在内存中。先看一个例子吧

//定义一个user对象,引用name属性
const user={
    name:"john"
}

这里箭头表示一个对象引用。全局变量user引用对象 {name:“John”} ,user 的 “name” 属性存储一个基本类型,因此它被绘制在对象中。

如果 user 的值被覆盖,则引用丢失:

例1

//让user的引用为空
user=null

这个时候通过user就没有办法访问到name这个属性,更没办法得到属性值。

图中的箭头表示引用,第一个图user引用name属性,第二图让user指向空,箭头消失,user无法引用name属性,js引擎将 {name:“John”}回收到垃圾桶处理掉,释放了内存空间。

圈个重点👇

user可以访问到name属性,那name是可达的;无法访问那么name就是不可达的。

看了上面这个例子不知道对可达性是否有基础的认识了呢?接着我们继续深入可达性。

  • 有一组基本的固有可达值,由于显而易见的原因无法删除, 例如:

    • 本地函数的局部变量和参数

    • 当前嵌套调用链上的其他函数的变量和参数

    • 全局变量

    • 还有一些其他的,内部的

上面这些值称为根。

  • 如果引用或引用链可以从根访问任何其他值,则认为该值是可访问的。

现在我们又多了一个概念那就是。接着来看一个例子。

例2

// user具有对象的引用
let user = {
  name: "John"
};

let admin = user;

我们创建一个全局新对象admin,它和user一样引用了同一个变量。此时name是可达的,如果我们进行下面的操作,它还是可达的吗?

admin=null;

结果是name属性还是可达的,为什么呢?不是已经删除了admin对name的引用吗?

原因是:虽然admin没有办法引用name,但是user还是可以引用name属性的,因此可以从根访问到name属性,因此他还是可达的。

如果再让user=null;那name才会变成不可达。这个时候无法从引用name属性了。

上面的图片都来自 这里。我们继续来看看垃圾回收算法有哪些。

三、垃圾回收算法

这里主要介绍两种主要回收算法,如果想了解更多,比如标记压缩,GC复制等可以点击这里

引用计数法

引用计数法也很好理解,就是引用对引用的次数进行计数。如果引用了增加就加1,引用减少就减去1.当引用等于0将它清除。看一个例子

  • 例3
//假如有一个计数器count=0
let a ={
    name:'linglong',//count==1
}
let b=a;           //count==2
b=null;            //count==1
a=null;            //count==0,被清除

假如有一个引用计数的计数器count,依次进行上面四步操作,对于name的引用从0->1->2->1->0。最后被回收。

引用计数的问题

引用计数有一个致命的问题就是循环引用,如果两个对象互相引用,尽管不再使用但是会进入一个无限循环,垃圾回收器不会对他进行回收。看下面代码

  • 例4
function cycle(){
    var o1={};
    var o2={};
    o1.a=o2;
    o2.a=o1;
}
cycle();

这个代码中cycle函数执行完后不需要了,所以o1和o2的内存应该被释放,但是他们互相引用导致内存不会被回收,现在一般不会使用这个方法,但是ie9之前仍然还在用。现在用的较多的是后面介绍的标记清除法。

标记清除算法

标记清除法分为两大步,先标记然后清除没有被标记的。

  • 垃圾回收器获取根并标记(记住)它们。
    • 然后它访问并“标记”所有来自它们的引用。
    • 然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
    • 以此类推,直到有未访问的引用(可以从根访问)为止。
  • 除标记的对象外,所有对象都被清除

例如,对象结构如下:

我们可以清楚地看到右边有一个“不可到达的块”。现在让我们看看**“标记并清除”**垃圾回收器如何处理它。

  • 第一步标记根

- 然后标记他们的引用

-以及子孙代的引用:

  • 现在进程中不能访问的对象被认为是不可访问的,将被删除:

标记清除算法数据结构

标记清除法利用到了堆、链表结构 标记阶段:从根集合出发,将所有活动对象及其子对象打上标记 清除阶段:遍历堆,将非活动对象(未打上标记)的连接到空闲链表上

这就是垃圾收集的工作原理。JavaScript引擎应用了许多优化,使其运行得更快,并且不影响执行。

V8引擎一些优化:

分代回收

v8堆中对象对象分为两组:新生代和老生代

  • 新生代:大多数对象的创建被分配在这里,这个区域很小,但垃圾回收非常频繁,独立于其它区存活期短,如临时变量字符串等
  • 老生代
    • 老生代指针区:包含大部分可能含有指向其它对象指针的对象。大多数从新生代晋升(存活一段时间)的对象会被移动到这里。
    • 老生代数据:区包含原始数据对象(没有指针指向其它对象)。Strings、boxed numbers以及双精度unboxed数组从新生代中晋升后被移到这里。
增量回收

如果有很多对象,并且我们试图一次遍历并标记整个对象集,那么可能会花费一些时间,并在执行中会有一定的延迟。因此,引擎试图将垃圾回收分解为多个部分(18年提出)。然后,各个部分分别执行。这需要额外的标记来跟踪变化,这样有很多微小的延迟,而不是很大的延迟。

空闲时间收集

垃圾回收器只在 CPU 空闲时运行,以减少对执行的可能影响。

内存泄露

由于内存泄露和内存没有被释放有关,所以这里简单介绍下什么时候会产生内存泄露吧!

知识点1

什么是内存泄露,对于不再用到的内存如果没有及时释放就叫内存泄露。

这和泄露有半毛钱关系???

我是这样理解的,我们把内存比作手心里的沙子,当沙子从手里漏了出去,那么可用的内存就越来越少了,这个过程就是内存泄露。

手里握不住的沙,不如扬了它

但是内存泄露了还是要管一下的啦,如何对它负责,请接着往下看

四种内存泄露

理解了内存泄露的概念后,我们要知道以下几种情况会导致内存泄露

  • 意外的全局变量(严格模式解决)
  • 被遗忘的定时器和回调函数
  • 脱离dom的引用
  • 闭包(变量引用指向null解决)

第一个和第四个很好理解,如果有问题可以评论区找linglong,十分欢迎。主要讲讲2、3两个。

  • 2、被遗忘的定时器和回调函数

当不需要setInterval或者setTimeout时,定时器没有被clear,定时器的回调函数以及内部依赖的变量都不能被回收,造成内存泄漏。

 timeout = setTimeout(() => {
 var node = document.getElementById('node');
    if(node){
          fn();
    }
    }, 1000);

解决方法: 在定时器完成工作的时候,手动清除定时器。timeout=null

  • 3、脱离dom的引用
<body>
    <div id="fa">
        <button id="button"></button>
    </div>
    <script>
        let div=document.getElementById('fa');
        document.body.removeChild(div); // dom删除了
        //div=null          //切断div对div的引用
        console.log(div);
    </script>
</body>

结果:

<div id="fa">
        <button id="button"></button>
 </div>

我们可以看到结果中div并没有被删除,这是因为代码中删除的div是dom树中div,let div=document.getElementById('fa');这句代码中存在着div对div的引用。

solution:我们要通过div=null将两次引用都切掉。

其他内存泄露情况

除了上面四种,如果有其他的内存泄露情况欢迎指出,一起学习,嘻嘻嘻

内存泄露的识别方法

内存泄露识别方法的内容来自LinDaiDai_霖呆呆小哥哥的建议,超级nice的作者😃😉

1、浏览器中识别(Chrome浏览器的控制台Performance或Memory)

这里展示performance里面查看的方法。主要步骤如下:

  1. 在网页上右键, 点击“检查”打开控制台(Mac快捷键option+command+i);
  2. 选择Performance面板(下图的步骤1)
  3. 勾选Memory, 然后点击左上角的小黑点Record开始录制(下图的步骤2、3)
  4. 点击弹窗中的Stop结束录制, 面板上就会显示这段时间的内存占用情况。
    完成前面三步开始录制就是下面这个样子。
    第4部结束录制后的样子如下图。如果内存使用情况一直在做增量,就是内存泄露了。
    ps:打开控制台中Memory面板也是可以检查的,在LinDaiDai_霖呆呆记录一次定时器及闭包问题造成的内存泄漏一文中有详细讲解

2、命令行方法

命令行可以使用 Node 提供的process.memoryUsage方法。玲珑对node.JS不熟悉,在这之前不知道这个方法的。经查阅官方文档后得知:

process 是一个全局变量,即 global全局对象下的一个的属性它用于描述当前Node.js 进程状态的对象。

memoryUsage是process下的一个方法,返回一个对象,描述了 Node 进程所用的内存状况,单位为字节。

接下来一起实践一下吧

  1. 新建一个main.js文件,输入以下代码
const fun = () => {
    console.log(__filename);//文件所在位置的绝对路径
    console.log('下面是内存使用信息:');
    console.log(process.memoryUsage());
}
fun();
  1. 在终端输入node main.js回车可以看到执行后结果,如下图所示:

3. 结果说明

我们看到process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,单位是字节,含义如下。

rss(resident set size):所有内存占用,包括指令区和堆栈。
heapTotal:"堆"占用的内存,包括用到的和没用到的。
heapUsed:用到的堆的部分。
external: V8 引擎内部的 C++ 对象占用的内存。

注意:判断内存泄漏,以heapUsed字段为准

最后给大家一个思考题:如何减少内存泄露?可以评论区留言哦

总结

看完了给自己一个大大的赞吧,可以问问自己:

  • 什么是垃圾回收,什么是可达性
  • 垃圾回收算法,哪两种
  • 内存泄露常见的有哪些,如何解决

认真看完一定会有收获滴,比心~

玲珑觉得如果垃圾回收算法的话可以聊很多. 从内存机制开始讲起,什么是垃圾回收,垃圾回收算法,v8引擎如何回收等,内存泄露,以及ES6中国的Weakset和WeakMap这两个不计入垃圾回收机制的弱引用....

如果我有哪些理解不对的地方还请掘友们指正,如果误导大家就尴尬啦

另外文章封面“除了money都是垃圾”是一句玩笑话啦,毕竟还是有很多东东高于毛爷爷的,比如你们的star~和留言!

再次感谢LinDaiDai_霖呆呆小哥哥的鼓励和帮助!

参考文章

[译] 前端面试:谈谈 JS 垃圾回收机制

推荐阅读

我们下篇文章见...