为了账号安全,请及时绑定邮箱和手机立即绑定

JavaScript内存管理和优化

作者:聚划算前端开发专家 韩璟(花名:业勤)


在JavaScript中,每当我们创建一个对象,都会占用内存,不再使用时,浏览器会自动释放。这种自动化的内存的管理的方式,大大降低了开发对于js内存管理的成本,但也造成了开发人员的JavaScript的内存管理忽视。然而现在,各种单页应用的诞生,各种不同无线终端少的可怜内存分配,交互的复杂性以及流畅性,以及nodejs应用的崛起,又使得JavaScript的内存管理变得重要起来。

基本概念篇

在ECMAScript标准中,没有规定任何的内存管理的接口,这使得开发者没有任何的能力来操作内存。但并不代表我们可以不关心内存。

怎样才是一个好的内存管理?简单来说就是做到以下两点
  1. 使得js的内存的使用保持在较低水平。如果浏览的内存使用过高,往往容易导致浏览器的crash。内存泄漏,导致内存无法被浏览器回收,也是引起内存使用过高的重要原因。

  2. 不频繁的触发浏览器的GC(内存垃圾回收)操作。因为浏览器的单线程的,GC操作执行时,会阻碍浏览器的正常程序的执行。所以,频繁触发GC时,往往会影响页面的流畅度,尤其在动画的过程中,卡顿感还是会比较明显的。
内存生命周期

内存的生命周期大致分为三个步骤

  1. 分配需要的内存
    JavaScript 在定义变量或者创建一个变量的时候,就完成了内存分配。
    例如:

    
    // 给数值变量分配内存
    var n = 123; 
    // 给字符串分配内存
    var s = "azerty"; 
    
    // 给对象及其包含的值分配内存
    var o = {
        a: 1,
         b: null
    }; 
    
    // 给数组及其包含的值分配内存(就像对象一样)
    var a = [1, null, "abra"]; 
    
    // 给函数(可调用的对象)分配内存
    function f(a){
        return a + 2;
    } 
    
    var d = new Date(); // 分配一个 Date 对象
    
    var e = document.createElement('div'); // 分配一个 DOM 元素
    
  2. 使用分配到的内存(读、写)

    其实就是对值的读写操作,不多说了

  3. 不需要时将其释放\归还

    如果一个内存不在被需要了,就需要把内存释放掉,以便内存的再次使用。这里比较难的问题就是“如何判断内存是否不再被需要”。垃圾回收算法主要依赖于引用(reference)的概念,来判断内存是否被需要。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。

    垃圾收集算法中,IE 6, 7采用的是引用计数垃圾收集算法。该算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。该算法有一个弊端就是“循环引用”,是导致内存泄漏的重要原因。

    而从2012年起,所有的现代浏览器都换成了标记-清除算法,这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。是否可获得的判断标准就是这个对象是否被root引用(包含直接引用和间接引用),如果不被引用到,就被收回。这样就很好的避免了循环引用的问题。


    var a = new A();  //创建A的实例
    var b = new B();  //创建B的实例

    a.link = b;
    b.link = a;

    a = null;
    b = null;

    /*
    上面的例子中  A ,B的实例形成循环引用,  最后把a ,b设为null。

    在引用计数垃圾收集算法中,A ,B的实例相互引用,各自的引用数不为0,所以不会被收回。

    而在标记-清除算法中,由于a, b设为null,A ,B的实例都不会被root也就是window对象引用到,会被收回。

    */
调试篇

查看浏览器的JavaScript的内存使用情况,一般有三种方法:

  1. 通过浏览器的任务管理器。这个可以了解各个页面的内存的使用总量,发现内存是否占用过高。

  2. chrome的dev-tools里面的timeline。timeline的好处是可以看到随着时间的变化,看到内存的使用的情况。通过timeline,我们很容易了解到GC操作和内存的分配,从而发现内存是否泄漏和GC是否频繁的问题。

  3. dev-tools里面的profiles。内存快照的优点是详细的展示了某一时刻的内存的使用情况,包括:什么类型的数据占用了多大的内存,以及变量之间的引用关系。通过这些,我们就可以找到内存使用的问题所在,找到解决内存问题的方法。

注意在使用dev-tools调试的时候,最好使用隐身模式,或者禁用浏览器插件扩展程序等,因为这些插件的运行也会被dev-tools统计到,从而影响你的分析结果,增加你分析的难度。

通过浏览器的任务管理器参看内存使用情况,比较简单,我们就不多说了。从timeline开始讲起。

timeline
  • 先看个简单的例子,了解下timeline的基本使用

    var x = []

    var grow = function(){

        x.push(new Array(1000000))
    }

    setInterval(function(){
        grow()
    },1000)

以上代码的timeline的情况如下:

由上图我们可以看到,在第一个红框里,很明显随着时间的增加内存在不断的增高,而第二个红框里,表示的是在这段时间里调用了哪些函数,以及每个函数的耗时。通过这个我们就可以定位到在哪些函数里面我们创建了对象,并且占用了内存,找到内存不断增高的原因。

  • 再来看个GC的例子。

    var run = function(){

        var d = [];

        for(var i = 0; i<100000; i++){

            d.push(Math.random())
        }

    }

    setInterval(function(){
        run()
    },100);

从图上可以看到,每隔一段时间,内存的使用就会大幅的下降,那就说明浏览器进行了GC操作。上面的代码中,每100ms执行一次run函数,每次执行我们创建一个d数组,浏览器并不是每次执行完,立刻回收d数组,而是等5次后执行一次GC操作。并且也可以注意到,GC操作也是花费时间的,minor GC和 major GC一共花费了8.9ms(5.8+3.1ms),所以这也是我们需要避免频繁的GC操作的原因。避免的方式,就是在频繁执行的方法里(如动画),尽可能的减少创建对象,尽可能的使用缓存对象来代替创建新对象。

仔细的人可能会问,啥是minor GCmajor GC?有什么区别?这个涉及到GC的实现和工作原理,详细的解释请看第三章,在上图中我们看到箭头标记的其实是major GC。而minor GC一般发生在给对象分配空间的时候,也就是说,在上图中内存的增长的时候,可能就触发了minor GC,请注意是可能。

有图有真相

  • minor GC (这个图显示minor GC是0.0ms, 感觉是精度问题舍掉了,并不是没发生)

  • major GC

通过上面的两个例子,我们大致了解了timeline调试内存的使用方式和一些常见内存的问题,下面来了解另一个调试工具profiles(快照)。

profiles

profiles里我们主要用下面两个方式来分析内存的问题。

这两种分析方式的界面,操作和分析的方式基本是一致的。

  • Take Heap Snapshot可以截取某一个时刻的,内存快照,用于分析这个时刻的内存使用情况,找出那些东西占用了比较大的内存。
  • Record Allocation Timeline可以记录每个时刻分配了哪些内存,和内存被回收的情况,主要用来分析内存泄漏的问题。

先了解下 Take Heap Snapshot


    function Class1(name){
        this.name = name
    }

    var x1 = new Class1("i am  x1")

    x1.x = new Array(1000000).join('x')

生成的快照如下

上图中我们选择的视图方式是summary,所以在第一个红框内,是按照构造函数名分类显示对象。另外,还有几个选项分别是:

  • Summary - 通过构造函数名分类显示对象;

  • Comparison - 显示两个快照间对象的差异;

  • Containment - 从根节点开始,按照包含关系来显示对象;

  • statistics - 统计各个不同类型的对象占用内存的比例;

在这块区域我们可以看到每个对象的情况:

  • Distance - 表示该root引用该对象的最短距离
  • Object Count - 表示数量
  • shallow Size - 表示该对象对应构造函数生成的对象直接占用的内存数,不包含引用的对象占用的内存。
  • Retained Size - 表示该对象和该对象的间接引用对象占用的内存的总数。

shallow SizeRetained Size这两个概念很重要,通常来说,只有Arrays和Stings会有比较大的shallow size。正如上面的图看到的 x1这个对象shallow size比较小,而因为包含一个比较大的string,所以Retained Size比较大。

上图中第二个红框,表示的选中的对象被root引用的关系链。例如,我们选中的是x这个字符串。第二个红框显示的内容就是,x 被一个Class1类型的对象引用,这个Class1的对象是x1, 然后x1又被window所引用。

通过上面的例子我们对快照的使用有了大致的了解,接下来我们看几个有趣的例子来进一步了解快照的使用。

  • example1

     var div1 = document.createElement('div')
     var div2 = document.createElement('div')

     document.body.appendChild(div1);

这回我们需要将summary切换成contaiment视图方式,在这个视图下,都是从根节开始,树型结构来组织对象,这里我们看到一个有趣的根节点Detached Dom tree,顾名思义,就是没有插入到dom树中,但仍然被js引用的dom。就如我们创建的div2(由于div1已经插入到body中,所以不在这里),并且选中时高亮成了红色(这里应该是有bug,前几个版本的chrome,在未选中的时候,高亮也是红色)。通过这个,我们很容易查找出这些特殊dom,往往这些dom都是无用,也经常是造成内存泄漏的原因。

  • example2

    var text1 = 'im a test'
    var text2 = 'im a test'
    var text3 = 'im a test'
    var text4 = 'im a test'

生成的快照如下

这个例子有趣的地方是,我们在代码中声明了四个String都是“im a test”,但是在实际的内存中,只有一个“im a test”,四个变量都指向了同一片内存。说明,在v8引擎中,创建sting的时候,会先判断有没有相同的字符串,如果有就复用,没有的话才会新建一个字符串。

总结下,在Take Heap Snapshot中,我们可以了解到详细的、全局的内存使用情况,也有各种视图,来帮助我们更好的分析内存中的问题。但现实还是很残酷,真实的页面往往上万甚至几十万个对象,要发现问题真的很难。还是需要更多的仔细,耐心,经验。

接着来了解下 Record Allocation Timeline,这个真的是分析内存泄漏的利器了。

还是来一段代码


    var arr = [];

    var add = function(){
        arr.push(new Array(1000))
    }

    var del = function(){
        arr[arr.length-1] = null;
    }

    setInterval(function(){
        add();
    },1000);

    setInterval(function(){
        del();
    },2000);

profiles中选择Record Allocation Timeline, 然后就可以看到下面的样子

这个和Take Heap Snapshot不同的是多了一个时间轴,还有蓝色和灰色的柱子。这个柱子表示的在那个时刻,浏览器分配的内存,高度表示分配的大小。蓝色是指未被回收的内存,灰色是指分配了,但被回收了的内存。这样,看到蓝色的柱子,就说明你可能存在内存泄漏了。

这时,你可以选中一个时间段,像下图这样

这时就会显示,这段时间内,哪些对象创建了但是没有回收了的,如上图看到的是arr[113]没有被回收。

最后来一个实际开发中比较容易内存泄漏的例子,大家可以用上面的方法试试,哪里内存泄漏了,如何解决,这里就不多说了。


    var wrap = document.querySelector('#wrap')

    var createDOM = function(){

        var a = new Array(100000);

        var div = document.createElement(div);

        div.a = a

        div.resize = function(){
            console.info(div.a)
        }

        window.addEventListener('resize', div.resize)

        wrap.innerHTML = "";
        wrap.appendChild(div);

    }

    setInterval(function(){
        createDOM();
    },1000);
V8’s GC engine

这一节,我们来了解下V8的GC引擎是到底是如何工作的。

在V8中,会把内存分为两个部分:新生区( young generation )和年老区(old generation),新生区的内存空间较小,用于创建和分配新的内存,年老区的空间较大,用于存放存活时间较长的对象。

大部分的对象,会在新生区里面被回收,这个时候发生的GC就是上面讲到的minor GC,而不被回收的对象就会被移动到年老区。在年老区进行的GC操作,就是major GC.

在新生区内,内存还会分为两个区,其中一个是active区,所有的内存分配都发生在active区,当active区满了以后,这个时候就会触发minor GC,把活的对象移动到另一个半区,并且这些对象最终会移动到年老区。而其余的对象就会被清除,内存进行回收。minor GC相对于major GC,触发的更加频繁,并且处理速度也比较快。因为一是内存空间小,相对涉及的对象数也比较少。二是分成了两个半区,避免了内存整理压缩等操作。

下面了解下年老区的处理。在年老区里,当内存使用超出限制时,就会触发major GC,回收算法就是使用的我们说的标记-清除(mark-sweep)算法,通常来说,标记整个年老区需要很长的时间,这里V8做了优化,支持增量标记,这样就可以把整个标记过程,分成多个小步。在清除完垃圾以后,以避免清除后年老区产生的内存碎片,还要进行内存压缩的操作,这个操作也比较耗时。所以可以看出,major GC确实要比minor GC多花很多的时间。

来张图 回顾下整个的GC流程

我们知道浏览器的最快的渲染频率是60fps,也就是是说每帧的时间间隔是16.6ms,如果浏览器在16.6ms内完成了所有的操作(包括js的运行和渲染),剩下的时间就是空闲时间,在这些空闲时间里面,就会执行GC操作,以便可以把GC操作的影响降到最低。

这里面chrome很多的优化,以保证GC时间不超过空闲时间。例如,
通过内存分配的速度,预测新生区什么时候会满,从而决定什么时候进行minor GC;
年老区进行增量标记,可以使标记操作分散在多段空闲时间里;
年老区里并不是每次GC操作都进行内存整理的工作,同时也会考虑空闲的大小是否足够

最后

以上我们可以看出,浏览器对于GC再不断的优化,并且对于内存的管理,也提供给了我们很多的工具。这也说明了内存管理是越来的越重要了。总结下内存相关的东西,以便可以解决项目里内存的问题。然而,每当页面crash的时候,内心还是捉急和懵逼的。

点击查看更多内容
68人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消