javascript-垃圾回收和内存泄露

loading 2022年11月19日 61次浏览

1.可达性

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

“可达”是指那些能访问到的值,它们一定存在于内存中。

可达分为两种:

(1)根
根是可达值的基本集合,比如

  • 当前执行的函数,它的局部变量和参数
  • 当前嵌套调用链上的其他函数、它们的局部变量和参数
  • 全局变量

(2)引用链
如果一个值可以通过引用链从根访问任何其他值,则认为该值是可达的。
比方说,如果全局变量中有一个对象A,并且对象A有一个属性引用了另一个对象B,则对象A和对象B都被认为是可达的。

一个最基本的例子

        let user = {
            name : 'yoimiya'
        }

user是一个全局变量,也就是所谓的“根”,引用了对象{name:'yoimiya'}。

如果user的值被重写,那么这个引用也就随之消失。

	user = null;

现在{name:'yoimiya'}变成不可达的对象了,因为没有了根节点的引用,无法通过引用练访问到它了,垃圾回收器就会将其判断为垃圾数据回收,然后释放内存。

如果我们再引入一个全局变量admin,也让它引用那个对象:

        let user = {
            name : 'yoimiya'
        }

        let admin = user;
        user = null;

那么此时的对象仍然可以通过admin访问到,因此不会被垃圾回收。

2.相互关联的对象

看一个比较复杂的例子

        const marry = (man , woman) =>{
            man.wife = woman;
            woman.husband = man;

            return{
                father : man,
                mother : woman
            }
        }
        let family = marry({name:'John'} , {name:'Ann'})

family全局变量引用了一个对象,对象里又引用了man和woman两个对象,而man和woman两个对象间还有wife和husband的相互引用。如图

目前为止所有对象都是可达的,然后移除两个引用试试:

	delete family.father;
	delete family.mother.husband;

会发现此时已经没有对John的引用了,因此John将被垃圾回收。

3.垃圾回收算法

3.1 标记清除法

js中垃圾回收的最常用最基本算法称为'mark-and-sweep'(标记清除法)

步骤如下:
(1)垃圾回收器找到所有的根,并标记它们。

(2)遍历并标记这些根的所有引用

(3)然后继续遍历被标记的对象并标记它们的引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。

(4)直到从根部开始所有可达的引用都被访问到。

(5)最后,没有被标记的对象将被回收删除。

在此基础上,js引擎做了一些优化,使得垃圾回收的运行速度更快

  • 分代收集:新出现的对象被检查的频率更高,而已经存存活了很久的对象被检查的频率会降低

  • 增量收集:当对象集比较大时,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的,使得延迟从一个大型延迟变成许多微小的延迟。

  • 闲时收集:垃圾回收器只在CPU空闲时运行,以减少对代码执行的影响。

3.2 引用计数法

这种不咋常用:
所谓"引用计数"是指语言引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。

引用计数最大的问题是解决不了循环引用产生的次数,除非手动设为null。

4.垃圾回收总结

(1)垃圾回收是自动完成的,我们不能强制执行或是阻止执行。

(2)当对象是可达状态时,它一定是存在于内存中的。

(3)被引用与可访问(从一个根)不同:一组相互连接的对象可能整体都不可达。

5.引起内存泄露的情况

5.1 意外的全局变量

一种情况是函数中不带标识符声明:

        function foo(arg) {
            bar = "this is a hidden global variable";
        }

另一种是函数中滥用this,this指向全局对象

        function foo() {
            this.variable = "potential accidental global";
        }
        // foo 调用自己,this 指向了全局对象(window)
        foo();

5.2 被遗忘的计时器或回调函数

如果id为Node的元素从DOM中移除,该定时器仍会存在,同时,因为回调函数中包含对someResource的引用,定时器外面的someResource也不会被释放。

        var someResource = getData();
        setInterval(function () {
            var node = document.getElementById('Node');
            if (node) {
                // 处理 node 和 someResource
                node.innerHTML = JSON.stringify(someResource);
            }
        }, 1000);

5.3 闭包

闭包可以维持函数内局部变量,使其得不到释放。上例定义事件回调时,由于是函数内定义函数,并且内部函数--事件回调引用外部函数,形成了闭包。

        function bindEvent() {
            var obj = document.createElement('xxx')
            obj.onclick = function () {
                // Even if it is a empty function
            }
        }

解决方法很简单,加一句obj = null即可。

        function bindEvent() {
            var obj = document.createElement('xxx')
            obj.onclick = function () {
                // Even if it is a empty function
            }
            obj = null
        }

或者是把事件处理函数写在外面

        // 将事件处理函数定义在外面
        function bindEvent() {
            var obj = document.createElement('xxx')
            obj.onclick = onclickHandler
        }

5.4 没有清理的DOM元素引用

虽然我们用removeChild移除了button,但是还在elements对象里保存着#button的引用,换言之,DOM元素还在内存里面。

        var elements = {
            button: document.getElementById('button'),
        };
        function doStuff() {
            button.click();
        }
        function removeButton() {
            document.body.removeChild(document.getElementById('button'));
            // 此时,仍旧存在一个全局的 #button 的引用
            // elements 字典。button 元素仍旧在内存中,不能被 GC 回收。
	}        

6. Chrome V8 垃圾回收算法

Chrome 浏览器所使用的 V8 引擎就是采用的分代回收策略。这个和 Java 回收策略思想是一致的。目的是通过区分「临时」与「持久」对象;多回收「临时对象区」(新生代young generation),少回收「持久对象区」(老生代 tenured generation),减少每次需遍历的对象,从而减少每次GC的耗时。

6.1 内存限制

新生代内存限制大小远小于老生代内存限制大小。

  • 32位系统新生代内存大小为16MB,老生代内存大小为700MB。
  • 64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。

至于V8为何要限制堆的大小:

  • 表层原因: V8最初为浏览器而设计,不太可能遇到用大量内存的场景。

  • 深层原因: V8的垃圾回收机制的限制。官方说法,以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JS线程暂停执行的时间,在这样时间花销下,应用的性能和响应能力都会直线下降。

6.2 分代回收(Generation GC)

V8垃圾回收策略主要基于分代式垃圾回收机制。现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

6.2.1 新生代回收算法

新生代中的对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法。

Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。

当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中;而非存活对象占用的空间将会被释放。

完成复制后,From空间和To空间的角色发生对换(即以前的From空间释放后变为To;To空间在复制存活的对象后,变为From空间)。简而言之,在垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制

Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。 Scavenge是典型的牺牲空间换取时间的算法

6.2.2 对象晋升

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。 这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升

在单纯的Scavenge过程中,From空间中的存活对象会被复制到To空间中去,然后对From空间和To空间进行角色对换(又称翻转)。

但在分代式垃圾回收前提下,From空间中的存活对象在复制到To空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中,也就是完成对象晋升。

对象晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过25%限制。

6.2.3 老生代回收算法

对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。为此,V8在老生代中主要采用Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收

Mark-Sweep:即标记清除法,与Scavenge复制活着的对象不同,Mark-Sweep在标记阶段遍历堆中所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。

可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。

但是标记清除法存在这么一个问题: 在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

此时就要结合Mark-Compact来使用:即标记整理法,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。

6.3 增量回收

为了避免出现js应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。

为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小步,每做完一小部分就让js应用逻辑执行一小会,垃圾回收与应用逻辑交替执行直到标记阶段完成。