• home > webfront > ECMAS > emphasis >

    再谈JavaScript垃圾回收机制:分析与排查JS内存泄露情形

    Author:[email protected] Date:

    JavaScript不需要像C一样手动管理内存,JavaScript的内存管理

    首先推广下姊妹篇:《再谈js对象数据结构底层实现原理-object array map set 》、《Chrome开发工具Memory页面渲染内存占用分析

    JavaScript 内存分配

    JavaScript 在定义变量时就完成了内存分配,还可以通过函数调用分配内存:

    /**
     * 值的初始化
     */
    var s = "azerty" // 给字符串分配内存
    
    var o = {
      a: 1,
      b: null
    } // 给对象及其包含的值分配内存
    
    // 给数组及其包含的值分配内存(就像对象一样)
    var a = [1, null, "abra"]
    
    function f(a){
      return a + 2
    } // 给函数(可调用的对象)分配内存
    
    // 函数表达式也能分配一个对象
    someElement.addEventListener('click', function(){
      someElement.style.backgroundColor = 'blue'
    }, false)
    
    /**
     * 函数调用分配内存
     */
    var d = new Date() // 分配一个 Date 对象
    var e = document.createElement('div') // 分配一个 DOM 元素

    使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

    本篇不做过多解释,拓展阅读:

    JavaScript垃圾回收机制

    首先JavaScript是一个有Garbage Collection 的语言,也就是我们不需要手动的回收内存——具有自动垃圾回收机制。

    • 低级语言:像C语言这样的低级语言一般都有底层的内存管理接口,比如 malloc()和free()。

    • 高级语言:JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

    自动垃圾回收机制:找出那些不再继续使用的变量,然后释放其所占用的内存,垃圾回收器会按照固定的时间间隔周期性地执行这一操作。局部变量只有在函数执行的过程中存在,在这个过程中,会为局部变量在栈(或者堆)内存上分配空间,然后在函数中使用这些变量,直至函数执行结束。垃圾回收器必须追踪哪个变量有用哪个没用,对于不再有用的变量打上标记,以备将来回收其占用的内存,用于标识无用变量的策略主要有标记清除法和引用计数法。

    垃圾回收器要解决的最基本问题就是,辨别需要回收的内存。一旦辨别完毕,这些内存区域即可在未来的分配中重用,或者是返还给操作系统。

    js的垃圾回收机制

    垃圾回收机制通过定期的检查哪些先前分配的内存“仍然被需要”(×) 垃圾回收机制通过定期的检查哪些先前分配的内存“程序的其他部分仍然可以访问到的内存”(√)。

    这是理解垃圾回收的关键,只有开发者知道这一块内存是否在未来使用“被需要”,但是可以通过算法来确定无法访问的内存并将其标记返回操作系统。

    • 标记清除(mark and sweep):大部分浏览器以此方式进行垃圾回收,当变量进入执行环境(函数中声明变量)的时候,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”,在离开环境之后还有的变量则是需要被删除的变量。标记方式不定,可以是某个特殊位的反转或维护一个列表等。

      垃圾收集器给内存中的所有变量都加上标记,然后去掉环境中的变量以及被环境中的变量引用的变量的标记。在此之后再被加上的标记的变量即为需要回收的变量,因为环境中的变量已经无法访问到这些变量。

      JavaScript内存管理gc_mark_sweep

    • 引用计数(reference counting):机制就是跟踪一个值的引用次数,当声明一个变量并将一个引用类型赋值给该变量时该值引用次数加1,当这个变量指向其他一个时该值的引用次数便减一。当该值引用次数为0时就会被回收。

      该方式会引起内存泄漏的原因是它不能解决循环引用的问题: var a={};var b={};a.prop = b;b.prop = a;

      —a和b通过各自的属性相互引用,也就是说,这两个对象的引用次数都是 2,导致们的引用次数永远不会是 0,导致内存无法回收。

      JavaScript内存管理 堆栈值的内存表示方式.png

      推荐《V8 最佳实践:从 JavaScript 变量使用姿势说起》、《再谈javascriptjs原型与原型链及继承相关问题

    不同的JavaScript引擎有不同的垃圾回收机制,这里我们主要以V8这个被广泛使用的JavaScript引擎为主

    标记清除(Mark and Sweep)是最早开发出的 GC 算法(1960年)。

    Node.js 的 global 对象和 JavaScript 的 window 对象,被称作”根“,标记清除首先从根开始,将可能被引用的对象用递归的方式进行标记,标记阶段完成时,被标记的对象就被视为”存活“对象。然后将没有标记到的对象作为垃圾进行回收

    零引用的对象肯定是需要被回收的,反过来,需要被回收的对象却不一定是零引用(循环引用)。因此标记清除可以有效解决循环引用的问题。在上面的循环引用示例中,marry 函数调用返回之后,两个对象从全局对象出发无法获取。因此,它们将会被垃圾回收器回收。

    从 2012 年起,所有现代浏览器都使用了标记清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记清除算法的改进(如 V8 引擎的垃圾回收机制)。

    • JS的基础数据类型(Number Null Boolean Undefined String Symbol) 都存在内存栈中

    • 引用数据类型Object则存在内存堆中

    栈内存是由操作系统分配管理的是不会内存泄露的。所以关心堆的情况就好了。所以memory工具里只有Heap snapshot,而没有Stack snapshot。

    JavaScript内存分配和回收的关键词:GC根、作用域

    GC根:一般指全局且不会被垃圾回收的对象,比如:window、document或者是页面上存在的dom元素。JavaScript的垃圾回收算法会判断某块对象内存是否是GC根可达(存在一条由GC根对象到该对象的引用),如果不是那这块内存将会被标记回收

    作用域:在JavaScript的作用域里,我们能够新建对象来分配内存。比如说调用函数,函数执行的过程中就会创建一块作用域,如果是创建的是作用域内的局部对象,当作用域运行结束后,所有的局部对象(GC根无法触及)都会被标记回收,在JavaScript中能引起作用域分配的有函数调用、with和全局作用域。

    作用域的分类:全局作用域、局部作用域、闭包作用域

    全局作用域:每个JavaScript进程都会有一个全局作用域,全局作用域上的引用的对象都是常驻内存的,直到进程退出内存才会自动释放

    局部作用域:函数调用会创建局部作用域,在局部作用域中的新建的对象,如果函数运行结束后,该对象没有作用域外部的引用,那该对象将会标记回收

    闭包作用域闭包 是指有权访问另一个函数作用域中的变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,通过另一个函数访问这个函数的局部变量。闭包对象是当前作用域中的所有内部函数作用域共享的,并且这个当前作用域的闭包对象中除了包含一条指向上一层作用域闭包对象的引用外,其余的存储的变量引用一定是当前作用域中的所有内部函数作用域中使用到的变量。

    const closure = (function () {    // 这里是闭包的作用域
      let i = 0; // i就是自由变量
      return function () {
        console.log(i++);
      };
    }());

    闭包作用域会保持对自由变量的引用。上面代码的引用链就是:window -> closure -> i

    闭包会使变量始终保存在内存中,如果不当使用会增大内存消耗

    javascript的垃圾回收原理

    手动释放全局作用域上的引用的对象有两种方式:

    1. 在javascript中,如果一个对象不再被引用,那么这个对象就会被GC回收; 

    2. 如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。

    js内存泄露分析

    选择垃圾回收器也就意味着程序当中无法完全掌控内存,ECMAScript标准中没有暴露任何垃圾回收器的接口

    虽然垃圾回收机制很好很方便,但是得自己权衡.原因是我们无法确定何时会执行收集.只有开发人员才能明确是否可以将一块内存返回给操作系统。不需要的引用是指开发者明知内存引用不再需要,而我们在写程序的时候却由于某些原因,它仍被留在激活的 root 树中。

    垃圾收集语言泄漏的主要原因是不需要的引用。要了解不需要的引用是什么,首先我们需要了解垃圾收集器如何确定是否可以访问一块内存。

    因此,要了解哪些是JavaScript中最常见的泄漏,我们需要知道引用通常被遗忘的方式。

    全局变量造成内存不被回收

    将全局变量作为缓存数据的一种方式,将之后要用到的数据都挂载到全局变量上,用完之后也不手动释放内存(因为全局变量引用的对象,垃圾回收机制不会自动回收),全局变量逐渐就积累了一些不用的对象,导致内存泄漏。

    function foo(arg) { 
        bar = "this is a hidden global variable"; //等同于window.bar="this is a hidden global variable"
        this.bar2 = "potential accidental global";//这里的this 指向了全局对象(window),等同于window.bar2="potential accidental global"}

    解决方法:在JavaScript程序中添加,开启严格模式'use strict',可以有效地避免上述问题。

    export default {
        mounted() {
          window.demo = demo 
        },
        destroyed(){
        window.demo  = null
        }
    }
    // 页面卸载的时候解除引用——最好不要挂载到全局

    注意:那些用来临时存储大量数据的全局变量,确保在处理完这些数据后将其设置为null或重新赋值

    与全局变量相关的增加内存消耗的一个主因是缓存。缓存数据是为了重用,缓存必须有一个大小上限才有用。高内存消耗导致缓存突破上限,因为缓 存内容无法被回收。

    缓存爆炸

    通过 Object/Map 的内存缓存可以极大地提升程序性能,但是很有可能未控制好缓存的大小和过期时间,导致失效的数据仍缓存在内存中,导致内存泄漏:

    通过 Object/Map 的内存缓存可以极大地提升程序性能,但是很有可能未控制好缓存的大小和过期时间,导致失效的数据仍缓存在内存中,导致内存泄漏:

    const cache = {};
    function setCache() {
      cache[Date.now()] = new Array(1000);
    }
    setInterval(setCache, 100);

    会不断的设置缓存,但是没有释放缓存的代码,导致内存最终被撑爆。

    如果的确需要进行内存缓存的话,强烈建议使用 lru-cache 这个 npm 包,可以设置缓存有效期和最大的缓存空间,通过 LRU 淘汰算法来避免缓存爆炸。


    闭包循环引用

    闭包在IE6下会造成内存泄漏,但是现在已经无须考虑了。值得注意的是闭包本身不会造成内存泄漏,但闭包过多很容易导致内存泄漏(闭包内存不回收,占用过多内存)。闭包会造成对象引用的生命周期脱离当前函数的上下文,如果闭包如果使用不当,可以导致环形引用(circular reference),类似于死锁,只能避免,无法发生之后解决,即使有垃圾回收也还是会内存泄露。  

    (function(){
      var theThing = null
      var replaceThing = function () {
        var originalThing = theThing
        var unused = function () {
          if (originalThing)
            console.log("hi")
        }
        theThing = {
          longStr: new Array(1000000).join('*'),
          someMethod: function someMethod() {
            console.log('someMessage')
          }
        };
      };
      setInterval(replaceThing,100)
    })()

    在目前的 V8 实现当中,闭包对象是当前作用域中的所有内部函数作用域共享的,也就是说 theThing.someMethod 和 unUsed 共享同一个闭包的 context,导致 theThing.someMethod 隐式的持有了对之前的 newThing 的引用,所以会形成 

        theThing -> someMethod -> newThing -> 上一次 theThing ->... 的循环引用

    从而导致每一次执行 replaceThing 这个函数的时候,都会执行一次 longStr: new Array(1e8).join("*"),而且其不会被自动回收,导致占用的内存越来越大,最终内存泄漏。

    对于上面这个问题有一个很巧妙的解决方法:通过引入新的块级作用域,将 newThing 的声明、使用与外部隔离开,从而打破共享,阻止循环引用。

    et theThing = null;let replaceThing = function() {
      {
        const newThing = theThing;
        const unused = function() {
          if (newThing) console.log("hi");
        };
      }
      // 不断修改引用
      theThing = {
        longStr: new Array(1e8).join("*"),
        someMethod: function() {
          console.log("a");
        },
      };
    
      console.log(process.memoryUsage().heapUsed);};setInterval(replaceThing, 100);

    这里通过 { ... } 形成了单独的块级作用域,而且在外部没有引用,从而 newThing 在 GC 的时候会被自动回收


    没有及时清理的计时器或回调函数

    js中常用的定时器setInterval()、setTimeout().他们都是规定延迟一定的时间执行某个代码,而其中setInterval()和链式setTimeout()在使用完之后如果没有手动关闭,会一直存在执行占用内存,所以在不用的时候我们可以通过clearInterval()、clearTimeout() 来关闭其对应的定时器,释放内存

    setInterval用多了,会占用大量的内存。因此setInterval我们必须及时清理!可以用如下方式清理setInterval。

    function b() {
      var a = setInterval(function() {
        console.log("Hello");
        clearInterval(a);
        b();
      }, 50);
    }
    b();

    或者

    function init()
    {
      window.ref = window.setInterval(function() { draw(); }, 50);
    }
    function draw(){
      console.log('Hello');
      clearInterval(window.ref);
      init();
    }
    init();

    或者我们用setTimeout

    function time(f, time) {
      return function walk() {
        clearTimeout(aeta);
        var aeta =setTimeout(function () {
          f();
          walk();
        }, time);
      };
    }
    
    time(updateFormat, 1000)();

    如果外部已经触发组件的destroy(),timer不会随之删除,timer中的状态也会被保存;必须在组件销毁前手动移除timer

    定时器变量重新赋值,需要重置——每次新建一个 interval 并没有关闭前一个 interval, 赋值给 interval 只是外面变量的值变了 而interval的执行任务 没有变,还是在执行

    DOM元素相关的泄露

    游览器中DOM和js采用的是不一样的引擎,DOM采用的是渲染引擎,而js采用的是v8引擎,所以在用js操作DOM时会比较耗费性能,因为他们需要桥来链接他们。为了减少DOM的操作,我们一般将常用的DOM采用变量引用的方式会将其缓存在当前环境。如果在进行一些删除、更新操作之后,可能会忘记释放已经缓存的DOM,话不多说直接来个例子

    没有清理的DOM元素引用

    var refA = document.getElementById('refA');
    document.body.removeChild(refA);
    // #refA不能回收,因为存在变量refA对它的引用。将其对#refA引用释放,但还是无法回收#refA。

    解决办法:refA = null;

    给DOM对象添加的属性是一个对象的引用:

    var MyObject = {}; 
    document.getElementById('myDiv').myProp = MyObject;

    解决方法: 在window.onunload事件中写上: document.getElementById('myDiv').myProp = null;  

    DOM对象与JS对象相互引用:

    function Encapsulator(element) {
      this.elementReference = element;
      element.myProp = this;
    }
    new Encapsulator(document.getElementById('myDiv'));

    解决方法: 在onunload事件中写上: document.getElementById('myDiv').myProp = null;   

    给DOM对象用addEventListener||attachEvent绑定事件:

    function doClick() {} 
    element.attachEvent("onclick", doClick);

    解决方法: 在onunload事件中写上: element.detachEvent('onclick', doClick);   

    从外到内执行appendChild。这时即使调用removeChild也无法释放:

    var parentDiv = document.createElement("div"); 
    var childDiv = document.createElement("div"); 
    document.body.appendChild(parentDiv); 
    parentDiv.appendChild(childDiv);

    解决方法: 从内到外执行appendChild:   

    var parentDiv = document.createElement("div"); 
    var childDiv = document.createElement("div"); 
    parentDiv.appendChild(childDiv); 
    document.body.appendChild(parentDiv);


    事件绑定后没有解绑操作

    事件绑定导致的内存泄漏在浏览器中非常常见,一般是由于事件响应函数未及时移除,导致重复绑定或者 DOM 元素已移除后未处理事件响应函数造成的 

    React 代码:

    class Test extends React.Component {
      componentDidMount() {
        window.addEventListener('resize', this.handleResize);
      }
    }

    vue 代码

    export default class PanelManage extends Vue {
        mounted(){
          Bus.$on('refreshPanel', () => 
           this.$refs.panel.addEventListener('resize', this.handleResize;     
        );

     组件在挂载的时候监听了 resize 事件,但是在组件移除的时候没有处理相应函数,假如挂载和移除非常频繁,那么就会在 window 上绑定很多无用的事件监听函数,最终导致内存泄。而且执行多次,造成CPU报表。

    出现最高的就是 绑在 EventBus 的事件没有解绑


    减少try catch的使用

    关于try catch的性能损耗,监狱阅读《try catch引发的性能优化深度思考

    trycatch 需要遍历某种结构来查找 catch 处理代码,并且通常以某种方式分配异常(例如:需要检查堆栈,查看堆信息,执行分支和回收堆栈)。

    尽管现在大部分浏览器已经优化了,我们也尽量要避免去写出上面相似的代码,比如以下代码:

    try {
        container.innerHTML = "I'm alloyteam";
    }
    catch (error) {
        // todo
    }
    // 改为:
    if (container) container.innerHTML = "I'm alloyteam";
    else // todo

    在简单代码中应当减少甚至不用 try catch ,我们可以优先考虑 if else 代替,在某些复杂不可测的代码中也应该减少 try catch(比如异步代码),我们看过很多 async 和 await 的示例代码都是结合 try catch 的,在很多性能场景下我认为它并不合理,个人觉得下面的写法应该是更干净,整洁和高效的。

    因为 JavaScript 是事件驱动的,虽然一个错误不会停止整个脚本,但如果发生任何错误,它都会出错,捕获和处理该错误几乎没有任何好处,代码主要部分中的 try catch 代码块是无法捕获事件回调中发生的错误

    ……

    1. 如果我们通过完善一些测试,尽量确保不发生异常,则无需尝试使用 try catch 来捕获异常。

    2. 非异常路径不需要额外的 try catch,确保异常路径在需要考虑性能情况下优先考虑 if else,不考虑性能情况请君随意,而异步可以考虑回调函数返回 error 信息对其处理或者使用 Promse.reject()。

    3. 应当适当减少 try catch 使用,也不要用它来保护我们的代码,其可读性和可维护性都不高,当你期望代码是异常时候,不满足上述1,2的情景时候可考虑使用。


    echart maptalks等组件不停调用导致内存泄露

    不停的用setInterval ajax调用echart maptalks,更新echart表格及地图数据,即使清理了setInterval,也会导致内存泄露!

    解决办法,首先及时清理

    加一个 beforeDestroy()方法释放该页面的 chart 资源,我也试过使用 dispose()方法,但是 dispose 销毁这个图例,图例是不存在了,但图例的 resize()方法会启动,则会报没有 resize 这个方法,而 clear()方法则是清空图例数据,不影响图例的 resize,而且能够释放内存,切换的时候就很顺畅了。

    myChart.setOption(option);
    myChart.clear();
    myChart.dispose()

    我们在使用echarts的时候,尽量 增加了更新。组件刷新 重新设置 参数,不要重复创建echarts 实例对象。


    v-if 指令产生的内存泄露

    v-if 绑定到 false 的值,但是实际上 dom 元素在隐藏的时候没有被真实的释放掉。

    推荐阅读官方案例:https://cn.vuejs.org/v2/cookbook/avoiding-memory-leaks.html

    实际项目中,更多的就是自己  操作dom片段 append 到vue组件中,但是组件注销没有去检查相关内存泄露情况。


    如何高效实用内存

    作用域

    在 JavaScript 中能形成作用域的有函数调用

    var foo = function () {
      var local = {}  
    }

    foo() 在每次被调用的时候都会创建对于的作用域,执行完后作用域销毁,作用域内声明的局部变量也随之销毁。在这个示例中,local 对象会分配在新生代内存 From 中,作用域释放后,local 被垃圾回收。

    变量主动释放

    全局变量如果不主动删除,可能会导致对象常驻内存(老生代),可以通过 delete 操作符来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。

    global.foo = 'I am a global object'
    delete global.foo
    //或者重新赋值
    global.foo = undefined


    weakmap减少内存泄漏发生的可能性

    weakmap可以随着对象的取消引用,直接移除weakmap中引用对象的KV。

    const wm = new WeakMap();let key = new Array(5 * 1024 * 1024);
    wm.set(key, 1);
    key = null;

    weakmap建立的是弱引用,key=null后下一次回收weakmap中的相关KV即可被回收。

    Object.freeze()

    Object.freeze()接受一个对象作为参数,并返回一个相同的不可变的对象。这就意味着我们不能添加,删除或更改对象的任何属性——const和Object.freeze()并不同,const是防止变量重新分配,而Object.freeze()是使对象具有不可变性。

    要完全冻结具有嵌套属性的对象,您可以编写自己的库或使用已有的库来冻结对象,如Deepfreezeimmutable-js

    vue中给data中新建变量对象时,vue都会新建一个observer对象以实现其监听、双向绑定的功能,但这对于个别场景这个监听显然是冗余的,可通过Object.freeze()来优化

    Object.freeze() 会把传入的对象 “冻结” 住,以防止对象属性被修改。Vue 对应被冻结的对象的处理是:不会绑定 getter 和 setter,即不维护其状态变化。对于大数组,使用 Object.freeze() 可以让性能大幅提升,前提是在符合场景中使用。

    其实除了 Object.freeze(),还有一种方法也可以达到数据不被绑定 getter 和 setter 的效果。就是在 Vue 对象 created 的生命周期中才把数据挂在到 this 上。在 data,props,computed 以外设置的属性,是不会被维持状态变化的。不过,这部分属性也是可以被修改的,只是它们的数据变化并不会触发dom differ。

    这方面推荐阅读:《Vue性能提升之Object.freeze() 

    比如数据可视化项目,父级组件请求的海量数据 通过echarts 子组件渲染,props 绑定的海量数据 加watch 直接导致内存占用与 cpu使用率 爆炸

    初步通过Object.freeze() 减少监听,第二通过优化架构,echarts 直接渲染请求的数据,第三个,前后台 采用 增量更新形势,递补数据。


    参考文章:

    javascript内存泄露及谷歌浏览器查看内存使用 https://www.haorooms.com/post/javascript_neicun_use

    javascript典型内存泄漏及chrome的排查方法 https://segmentfault.com/a/1190000008901861

    js常见的内存泄漏及解决方法总汇 www.fly63.com/article/detial/225?type=2

    JavaScript 垃圾回收 https://lz5z.com/JavaScript垃圾回收/

    V8 之旅: 垃圾回收器 http://newhtml.net/v8-garbage-collection/

    有意思的 Node.js 内存泄漏问题 https://cloud.tencent.com/developer/article/1683960

    彻底掌握js内存泄漏以及如何避免 https://juejin.cn/post/6844903917986267143





    转载本站文章《再谈JavaScript垃圾回收机制:分析与排查JS内存泄露情形》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/js6/2016_0219_7612.html