阅读 373
V8引擎[垃圾回收]与[内存泄露]

V8引擎[垃圾回收]与[内存泄露]

如果有用还请多多点赞👍,更多精彩前端文章还请关注我的微信公众号【南橘前端】,我们一起学习!😘

前言:

我们知道在JS中数据有两种存储形式,简单数据类型存放在栈内存中,引用数据类型存放在堆内存中。在栈内存中数据回收比较简单,ESP指针向下移动,也就是切换上下文之后,栈顶空出来的空间会被自动回收,而对于堆内存来说就比较复杂了,接下来我们就来细细说说垃圾回收。

JS中数据是如何存储的:

JS中数据主要存放在四种数据结构中:栈,堆,池,队列

  • 栈:简单数据类型,变量,引用数据类型的地址

  • 堆:引用数据类型,比较复杂,大小不确定

  • 池:存储常量,如null
  • 队列:Event Loop 事件环中的任务队列,其特点是先进先出

内存堆分为两部分:


首先我们要知道内存堆是由两部分组成的,一部分称之为新生代,一部分称之为老生代,新生代所占的空间很小,64位操作系统只有32M。其因为比较小,所以回收数据效率比较高,回收也比较频繁,主要用于存放存活时间较短,创建后很快会被回收的对象,大部分的对象需要都存放在这里。

  

对于新生代与老生代有不同的垃圾回收方法,我们分开介绍。

新生代内存回收算法:


在新生代内存的内部,内存又被分为了两部分,一部分是用来保存数据的称之为From部分,另一部分空闲出来称之为To部分。



回收的过程非常简单,首先遍历From部分找到存活的对象,那么什么是存活的对象?具有可达性的对象。简单来说就是可以沿着根找到的对象,在JS语言内这些变量被称之为

  • 当前函数的局部变量和形参
  • 全局变量
  • 函数嵌套调用时当前作用域链上可以访问到的变量与参数
  • (还有一些内部的)

根是很重要的概念,垃圾回收机制主要就是依靠一个对象是否可达从而判断其是否可以被回收。

在找到存活的对象之后,会将那些已经不再存活的对象进行回收,然后将存活的对象复制到To部分,之后将To部分与From部分进行对调,如此往复执行。这个算法便是新生的代垃圾回收的算法,也称之为Scavenge算法。就这么简单,但是大家此刻是不是有很多疑问,为什么我们非要将新生代内存分为两部分呢?然后又要经过复制对调这样复杂的操作呢?为了防止内存碎片

首先在From部分我们回收完非存活对象之后,内存应该是这样的,白色表示回收后空闲出来的内存:



从视觉上来看此时我们的内存空间非常的乱,就像我们很久没有收拾的房间一样,如果此时有一个比较大的对象需要存放,由于当前对象所占的空间断断续续的很可能其无法放下。

所以下一步我们按照顺序将其复制到To部分。

  


看起来整齐多了,之后将其与From部分进行调换,来继续存放其他对象,重复之前的操作。

那是不是这些对象会永远存放在新生代呢?当然不是,当出现以下两种情况这些对象就可以晋升啦(这个过程真的称之为晋升🤣),转移到老生代那里。

  • 当前对象已经经历过一次Scavenge回收
  • To空间内存占比超过25%


老生代内存回收算法:


老生代的垃圾回收算法有两种:计数引用算法,标记删除算法。前者在IE9以后已经弃用了,因为无法解决对象之间循环引用的问题,从而造成内存泄露,这里解释一下。

内存泄露是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

计数引用:

顾名思义,就是将对象被引用的次数进行记录,被引用一次就标记为1,当一个引用被删除时就减少一个标记,当标记为0时就自动回收这个对象。

var a = new Object(); // 此时'这个对象'的引用计数为1(a在引用)
var b = a; // ‘这个对象’的引用计数是2(a,b)
a = null; // reference_count = 1
b = null; // reference_count = 0 
// 下一步 GC来回收‘这个对象’了
复制代码


虽然这是一个“古老”的方法但是还是有许多优点的:当一个对象的标记为0时可以被立刻回收,不需要像新生代那样递归遍历寻找存活的对象,这样对主线程执行代码的影响最小。

缺点也很明显:无法解决循环引用的问题,如果每个被引用的对象都进行标记的话可能会占用大量的内存。

标记删除算法:


后来大多数浏览器都采用标记删除算法了,这个方法也非常简单:

  1. 首先找到所有的“根”并标记(记住)它们
  2. 然后遍历(采用广度优先)并标记来自它们的所有引用
  3. 然后沿着这些引用再遍历,再标记。所有被标记过的对象都会被记住,以免重复标记。
  4. 最后直到所有的可达性对象都被标记,删除那些没有被标记的对象


   image.png


通过这个图片我们可以清楚的看到被引用不意味着就是可达的,这样就很好的解决了循环引用的问题。

那么在删除完这些不被标记的对象后同样的问题又来了,此时我们的内存还是会存在很多的内存碎片,为了解决这个问题我们需要将所有的对象移动到一端整齐的“靠拢”。

  

由于我们需要对象,可能会经过对象的复制等,这个过程也是垃圾回收中最费时的一个部分。

垃圾回收对性能影响:


我们知道JS是一种单线程的语言,当我们进行垃圾回收时无论是新生代空间还是老生代空间的,都要将主线程的执行逻辑暂停下来,等到垃圾回收执行完毕再继续执行。这个过程称之为全停顿

以 1.5G 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式垃圾回收甚至需要 1s 以上。这是垃圾回收中引起的 JavaScript 线程暂停执行时间,在这样的时间花销下,应用性能和响应能力都会直线下降。


所以我们发现垃圾回收还是非常影响JS语言的性能的,对于新生代空间其所占内存比较小,所用时也比较少,但对于老生代空间,所占内存较大,其垃圾回收部分要经历标记,删除,整理造成的全停顿用时也比较多。所以为了提高JS语言的执行性能,避免造成太久的全停顿,我们有以下优化:

限制可操作内存大小:


对于Java与Go语言来说其可操作系统的内存大小基本上是没有限制的,但是我们也知道JS语言是单线程的,如果内存太大,那么垃圾回收所用时也就越多,这样很明显是不太好的,所以在V8引擎中我们对可操作的内存大小进行了限制。

  • 64位操作系统:总共分配1.4G,新生代32M
  • 32位操作系统:总共分配0.7G,新生代16M

增量回收:


进行一次完整的垃圾回收比较耗时那么我们就将垃圾回收分为多次,每次执行一点点,穿插在JS主线程执行代码的过程中,这样全停顿的时长也得到了有效的降低。

这个过程主要用在老生代空间的标记阶段,每标记一部分就暂停下来,执行一段时间主线程其他应用的逻辑,之后再标记一部分,直到所有的可达性对象标记完。

V8 后续还引入 Lazy Sweep(延迟清除)、Incremental Compaction (增量式整理),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行整理,进一步利用多核性能来降低每次停顿的时间。


说了这么多垃圾回收的原理,我们知道Javascript不像C语言那样需要通过malloc()和 free()来手动来释放内存,其垃圾回收往往是自动触发的具体会在:

  • 如while/for循环结束,其内部创建的局部变量或对象将会被回收
  • 函数作用域内,创建的局部变量,如果没有被引用的情况下,函数执行完将会被回收


这里我们真正需要注意的是那些无法主动触发垃圾回收的情况,这些情况下数据所占的内存无法进行垃圾回收,会一直占用在内存中,影响页面的性能造成卡顿,直到关闭页面,也就是我们常说的内存泄露

所有的一切理论都是为了实践而着想,我们研究垃圾回收的本质就是为了提高我们页面的性能,减少内存泄露的情况。

内存泄露的例子:

1.忘记声明的局部变量


这个情况应该在新手上很常见,我们在函数内部赋值的变量如果没有声明将会变成一个全局变量,直到页面关闭才可以回收。

function a(){
    b=2
    console.log('b没有被声明!')
}
复制代码

2.闭包

老生常谈的例子了,匿名函数在执行完内部创建的变量本应该被回收的但是由于被内部的函数调用,无法回收。如果想了解更多关于闭包的内容可以看我的这篇文章

var leaks = (function(){
    var leak = 'xxxxxx';// 闭包中引用,不会被回收
    return function(){
        console.log(leak);
    }
})()
复制代码

3.移除DOM节点时忘记移除暂存的值


很多时候出于性能优化的考虑我们会把多个节点一次获取然后保存在一个对象里,如果之后我们移除了页面中的该节点,但是对象中的引用依然存在,这种情况也会造成内存泄露。

var element = {
  image: document.getElementById('image'),
  button: document.getElementById('button')
};

document.body.removeChild(document.getElementById('image'));
// 如果element没有被回收,这里移除了 image 节点也是没用的,image 节点依然留存在内存中.
复制代码


与这种情况相似的是,如果我们给一个节点绑定了事件,之后删除了该节点,如果节点没有解除事件绑定那么也会造成内存泄露。

let oDiv = document.querySelector('div');
        oDiv.onclick = function(){
            alert(111111111)
        }
document.body.removeChild(oDiv);
oDiv.onclick = null; // 解除事件绑定,触发垃圾回收
复制代码

4.定时器中的内存泄露

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);
someResource = null; // 定时器依然在引用变量无法回收
复制代码


如果我们不删除定时器,那么定时器中通过someResource引用的数据将会一直存在,无法进行回收,但是当定时器结束之后,其回调函数内部引用的对象还是会被回收的,所以对于定时器持续时间特别长的情况还是要特别考虑的。

这四个例子,函数内部不声明的变量,闭包,对象对节点的引用未在删除节点后移除,还有定时器对数据的引用,大家要好好记忆,接下来介绍一点如何在Chrome中查看是否发生了内存泄露。

如何在Chrome中查看:


最后说一些如何在Chrome中查看当前网页是否存在内存泄露的情况:

image.png

  1. 首先按F12打开浏览器的控制台
  2. 点击Performance
  3. 勾选Screenshots 和 memory
  4. 点击左上角的圆点⚪开始录制
  5. 点击stop停止录制


我们可以看见图中Heap部分呈周期性的递增,上涨到一个点之后开始下降这就是在进行垃圾回收了,两个极点的间隔就是垃圾回收的周期,如果极小值不断的呈增高的形式那么说明存在明显的内存泄露,如图中所示。

最后:

更多精彩前端文章还请大家多多关注我的微信公众号【南橘前端】我们一起学习,一起加油!!! 如果觉得内容不错也请多多点赞👍👍👍

参考资料:

javascript.info《垃圾回收》
神三元《V8 引擎垃圾内存回收原理解析》
薄荷前端《JavaScript中的垃圾回收和内存泄漏》
EdmundChen 《V8 内存分配与垃圾回收》
楠小忎《V8引擎的垃圾回收机制》

文章分类
前端
文章标签