JavaScript隐藏机制之垃圾回收知识总结

来自:互联网
时间:2022-06-08
阅读:

一、前言

垃圾回收是JavaScript的隐藏机制,我们通常无需为垃圾回收劳心费力,只需要专注功能的开发就好了。但是这并不意味着我们在编写JavaScript的时候就可以高枕无忧了,伴随着我们实现的功能越来越复杂,代码量越积越大,性能问题就变的越来越突出。如何写出执行速度更快,而且占用内存更小的代码是程序员永无止歇的追求。一个优秀的程序员总是能在极其有限的资源下,实现惊人的效果,这也正式芸芸众生和高高在上的神祗之间的区别。

二、何为垃圾

代码执行在计算机的内存中,我们在代码中定义的所有变量、对象、函数都会在内存中占用一定的内存空间。在计算机中,内存空间是非常紧张的资源,我们必须时时刻刻注意内存的占用量,毕竟内存条非常贵!如果一个变量、函数或者对象在创建之后不再被后继的代码执行所需要,那么它就可以被称作垃圾。

虽然从直观上理解垃圾的定义非常容易,但是对于一个计算机程序来说,我们很难在某一时刻断定当前存在的变量、函数或者对象在未来不再使用。为了降低计算机内存的开销,同时又保证计算机程序正常执行,我们通常规定满足以下任一条件的对象或者变量为垃圾:

  1. 没有被引用的对象或者变量;
  2. 无法访问到的对象(多个对象之间循环引用);

没有被引用的变量或者对象相当于一座没有门的房子,我们永远都无法进入其中,因此不可能在用到它们。无法访问到的对象之间虽然具备连通性,但是仍然无法从外部进入其中,因此也无法再次被利用。满足以上条件的对象或者变量,在程序未来执行过程中绝对不会再次被采用,因此可以放心的当作垃圾回收。

当我们通过以上定义明确了需要丢弃的对象,是否就意味着剩余的变量、对象中就没有垃圾了呢?

不是的!我们当前分辨出的垃圾只是所有垃圾的一部分,仍然会有其他垃圾不满足以上条件,但是也不会再次被使用了。

这是否可以说满足以上定义的垃圾是“绝对垃圾”,其他隐藏在程序中的为“相对垃圾”呢?

三、垃圾回收

垃圾回收机制(GC,Garbage Collection)负责在程序执行过程中回收无用的变量和内存占用的空间。一个对象虽然没有再次使用的可能,但是仍然存在于内存中的现象被称为内存泄漏。内存泄漏是非常危险的现象,尤其在长时间运行的程序中。如果一个程序出现了内存泄漏,它占用的内存空间就会越来越多,直至耗尽内存。

字符串、对象和数组没有固定的大小,所以只有当它们大小已知时才能对它们进行动态的存储分配。JavaScript程序每次创建字符串、数组或对象时,解释器都要分配内存才存储这个实体。只要像这样动态地分配了内存,最终都要释放这些内存以便它们能够被再次利用;否则,JavaScript的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

JavaScript的垃圾回收机制会间歇性的检查没有用途的变量、对象(垃圾),并释放条它们占用的空间。

四、可达性(Reachability)

不同的编程语言采用不同的垃圾回收策略,例如C++就没有垃圾回收机制,所有的内存管理靠程序员本身的技能,这也就造成了C++比较难以掌握的现状。JavaScript采用可达性管理内存,从字面意思上看,可达的意思是可以到达,也就是指程序可以通过某种方式访问、使用的变量和对象,这些变量所占用的内存是不可以被释放的。

JavaScript规定了一个固有的可达值集合,集合中的值天生就是可达的:

  1. 当前正在执行的函数上下文(包括函数内的局部变量、函数的参数等);
  2. 当前嵌套调用链上的其他函数、它们的局部变量和参数;
  3. 全局变量;
  4. 其他内部的变量;

以上变量称为,是可达性树的顶层节点。

如果一个变量或则对象,直接或者间接的被根变量应用,则认为这个变量是可达的。

换一个说法,如果一个值能够通过根访问到(例如,A.b.c.d.e),那么这个值就是可达的。

五、可达性举例

层次关联:

let people = {
    boys:{
        boys1:{name:'xiaoming'},
        boys2:{name:'xiaojun'},
    },
    girls:{
        girls1:{name:'xiaohong'},
        girls2:{name:'huahua'},
    }};

以上代码创建了一个对象,并赋值给了变量people,变量people中包含了两个对象boysgirlsboysgirls中又分别包含了两个子对象。这也就创建了一个包含了3层引用关系的数据结构(不考虑基础类型数据的情况下),如下图:

JavaScript隐藏机制之垃圾回收知识总结

其中,people节点由于是全局变量,所以天然可达。boysgirls节点由于被全局变量直接引用,构成间接可达。boys1boys2girls1girls2由于被全局变量间接应用,可以通过people.boys.boys访问,因此也属于可达变量。

如果我们在以上代码的后面加上以下代码:

people.girls.girls2 = null;people.girls.girls1 = people.boys.boys2;

那么,以上引用层次图将会变成如下形式:

JavaScript隐藏机制之垃圾回收知识总结

其中,girls1girls2由于和grils节点断开连接,从而变成了不可达节点,意味着将被垃圾回收机制回收。

而如果此时,我们再执行以下代码:

people.boys.boys2 = null;

那么引用层次图将变成如下形式:
JavaScript隐藏机制之垃圾回收知识总结

此时,虽然boys节点和boys2节点断开了连接,但是由于boys2节点和girls节点之间存在引用关系,所以boys2仍然属于可达的,不会被垃圾回收机制回收。

以上关联关系图证明了为何称全局变量等值为,因为在关联关系图中,这一类值通常作为关系树的根节点出现。

相互关联:

let people = {
    boys:{
        boys1:{name:'xiaoming'},
        boys2:{name:'xiaojun'},
    },
    girls:{
        girls1:{name:'xiaohong'},
        girls2:{name:'huahua'},
    }};people.boys.boys2.girlfriend = people.girls.girls1;	
    //boys2引用girls1people.girls.girls1.boyfriend = people.boys.boys2;	//girls1引用boys2

以上代码在boys2girls1之间创建了一个相互关联的关系,关系结构图如下:

JavaScript隐藏机制之垃圾回收知识总结

此时,如果我们切断boysboys2之间的关联:

delete people.boys.boys2;

对象之间的关联关系图如下:

JavaScript隐藏机制之垃圾回收知识总结

显然,并没有不可达的节点出现。

此时,如果我们切断boyfriend关系连接:

delete people.girls.girls1;

关系图变为:

JavaScript隐藏机制之垃圾回收知识总结

此时,虽然boys2girls1之间还存在girlfriend关系,但是,boys2以及变为不可达节点,将被垃圾回收机制收回。

可达孤岛:

let people = {
    boys:{
        boys1:{name:'xiaoming'},
        boys2:{name:'xiaojun'},
    },
    girls:{
        girls1:{name:'xiaohong'},
        girls2:{name:'huahua'},
    }};delete people.boys;delete people.girls;

以上代码形成的引用层次图如下:

JavaScript隐藏机制之垃圾回收知识总结

此时,虽然虚线框内部的对象之间仍然存在相互引用的关系,但是这些对象同样是不可达的,并会被垃圾回收机制删除。这些节点已经和脱离了关系,变的不可达。

六、垃圾回收算法

引用计数

所谓引用计-数,顾名思义,就是每次对象被引用时都进行计数,增加引用就加一,删除引用就减一,如果引用数变为0,那么就被认定为垃圾,从而删除对象回收内存。

举个例子:

let user = {username:'xiaoming'};
//对象被user变量引用,计数+1
let user2 = user;
//对象被新的变量引用,计数+1
user = null;
//变量不再引用对象,计数-1
user2 = null;
//变量不再引用对象,奇数-1
//此时,对象引用数为0,会被删除

虽然看起来引用计数方法非常合理,实际上,采用引用计数方法的内存回收机制存在明显的漏洞。

例如:

let boy = {};	
let girl = {};	
boy.girlfriend = girl;
girl.boyfriend = boy;
boy = null;
girl = null;

以上代码在boygirl之间存在相互引用,计数删掉boygirl内的引用,二者对象并不会被回收。由于循环引用的存在,两个匿名对象的引用计数永远不会归零,也就产生了内存泄漏。

C++中存在一个智能指针shared_ptr)的概念,程序员可以通过智能指针,利用对象析构函数释放引用计数。但是对于循环引用的状况就会产生内存泄漏。

好在JavaScript已经采用了另外一种更为安全的策略,更大程度上避免了内存泄漏的风险。

标记清除

标记清除mark and sweep)是JavaScript引擎采取的垃圾回收算法,其基本原理是从出发,广度优先遍历变量之间的引用关系,对于遍历过的变量打上一个标记(优秀员工徽章),最后删除没有标记的对象。

算法基本过程如下:

  1. 垃圾收集器找到所有的,并颁发优秀员工徽章(标记);
  2. 然后它遍历优秀员工,并将优秀员工引用的对象同样打上优秀员工标记;
  3. 反复执行第2步,直至无新的优秀员工加入;
  4. 没有被标记的对象都会被删除。

举个栗子:

如果我们程序中存在如下图所示的对象引用关系:

JavaScript隐藏机制之垃圾回收知识总结

我们可以清晰的看到,在整个图片的右侧存在一个“可达孤岛”,从出发,永远无法到达孤岛。但是垃圾回收器并没有我们这种上帝视角,它们只会根据算法会首先把根节点打上优秀员工标记。

JavaScript隐藏机制之垃圾回收知识总结

然后从优秀员工出发,找到所有被优秀员工引用的节点,如上图中虚线框中的三个节点。然后把新找到的节点同样打上优秀员工标记。

JavaScript隐藏机制之垃圾回收知识总结

反复执行查找和标记的过程,直至所有能找到的节点都被成功标记。

JavaScript隐藏机制之垃圾回收知识总结

最终达到下图所示的效果:

JavaScript隐藏机制之垃圾回收知识总结

由于在算法执行周期结束之后,右侧的孤岛仍然没有标记,因此会被垃圾回收器任务无法到达这些节点,最终被清除。

如果学过数据结构和算法的童鞋可能会惊奇的发现,这不就是图的遍历吗,类似于连通图算法。

七、性能优化

垃圾回收是一个规模庞大的工作,尤其在代码量非常大的时候,频繁执行垃圾回收算法会明显拖累程序的执行。JavaScript算法在垃圾回收上做了很多优化,从而在保证回收工作正常执行的前提下,保证程序能够高效的执行。

性能优化采取的策略通常包括以下几点:

分代回收

JavaScript程序在执行过程中会维持相当量级的变量数目,频繁扫描这些变量会造成明显的开销。但是这些变量在生命周期上各有特点,例如局部变量会频繁的创建,迅速的使用,然后丢弃,而全局变量则会长久的占据内存。JavaScript把两类对象分开管理,对于快速创建、使用并丢弃的局部变量,垃圾回收器会频繁的扫描,保证这些变量在失去作用后迅速被清理。而对于哪些长久把持内存的变量,降低检查它们的频率,从而节约一定的开销。

增量收集

增量式的思想在性能优化上非常常见,同样可以用于垃圾回收。在变量数目非常大时,一次性遍历所有变量并颁发优秀员工标记显然非常耗时,导致程序在执行过程中存在卡顿。所以,引擎会把垃圾回收工作分成多个子任务,并在程序执行的过程中逐步执行每个小任务,这样就会造成一定的回收延迟,但通常不会造成明显的程序卡顿。

空闲收集

CPU即使是在复杂的程序中也不是一直都有工作的,这主要是因为CPU工作的速度非常快,外围IO往往慢上几个数量级,所以在CPU空闲的时候安排垃圾回收策略是一种非常有效的性能优化手段,而且基本不会对程序本身造成不良影响。这种策略就类似于系统的空闲时间升级一样,用户根本察觉不到后台的执行。

八、总结

本文的主要任务是简单的结束垃圾回收的机制、常用的策略和优化的手段,并不是为了让大家深入了解引擎的后台执行原理。

通过本文,你应该了解:

  1. 垃圾回收是JavaScript的特性之一,执行在后台,无需我们操心;
  2. 垃圾回收的策略是标记清除,按照可达性理论筛选并清除垃圾;
  3. 标记清楚策略可以避免可达孤岛带来的内存泄漏
返回顶部
顶部