js 垃圾回收机制

274 阅读8分钟

栈内存的释放

  • 全局作用域
          在全局作用域下,只有当页面关闭的时候,全局作用域才会被销毁。
  • 私有作用域
          一般情况下,函数执行会形成一个新的私有作用域(在ES6之前只有函数执行才会产生私有作用域),当私有作用域中的代码执行完成后,当前作用域都会主动的进行释放和销毁。
          不过依然有特殊的情况存在:当前私有作用域中的部分内容被作用域以外的东西占用了,那么当前作用域就不能销毁了。 这就是所谓的闭包 ,有以下几种情况:

一:函数返回了一个引用数据类型的值(数组、函数...),并且该引用类型的值在函数的外面被一个其他变量接收了,这种情况下形成的私有作用域都不会销毁。

注意两个条件:
(1)函数返回引用数据类型的值;

(2)该引用类型的值在函数外面被一个其他变量接收了;

function fn() {
    var num = 100;
    return function () {
        num ++;
        console.log(num);
    }
}
var f = fn(); // fn执行形成的作用域就不能再销毁了
f() //101
f()//102
f()//103

注意:即使fn返回的函数中什么代码都没有,没有使用到fn私有作用域中的任何变量和函数,在以上情况下,fn的私有作用域也不会被销毁,即:

function fn() {
    var num = 100;
    return function () {
    }
}
var f = fn();

二:在一个私有作用域中,给DOM元素绑定方法,私有作用域不能被销毁

var btn = document.getElementById('btn1');
~function () {
    btn.onclick = function () {
    }
}();

      在自执行函数中形成了一个私有的作用域,在这个私有作用域中为页面上的一个button元素绑定了点击事件,所以这个私有作用域也不能被销毁。

闭包是个好东西,可以完成很多工作,其中就包括一道网上常考的经典题目

    <ul>
        <li>1</li>
        <li>2</li>
        <li>3</li>
        <li>4</li>
    </ul>
    <script>
        var aLi = document.getElementsByTagName('li');
        for (var i = 0; i < aLi.length; i++) {
            aLi[i].onclick = function() {
                console.log(i);     // ?
            };
        }
    </script>

上面的答案无论怎么点结果都是4。

      这是因为li节点的onclick事件属于异步的,在click被触发的时候,for循环以迅雷不及掩耳盗铃的速度就执行完毕了,此时变量i的值已经是4了
      解决方法就需要通过闭包,把每次循环的i值都存下来。然后当click事件继续顺着作用域链查找的时候,会先找到被存下来的i,这样每一个li点击都可以找到对应的i值了

  <script>
        var aLi = document.getElementsByTagName('li');
        for (var i = 0; i < aLi.length; i++) {
            (function(n) {    // n为对应的索引值
                aLi[i].onclick = function() {  
                    console.log(n);     // 0, 1, 2, 3
                };
            })(i);  // 这里i每循环一次都存一下,然后把0,1,2,3传给上面的形参n
        }
    </script>

三:“不立即销毁”

function fn() {
    var num = 100;
    return function () {
    }
}
fn()(); // 首先执行fn,返回一个小函数对应的内存地址,然后紧接着让返回的小函数再执行

      以上代码就是“不立即销毁”的情况,fn返回的函数没有被其他的任何变量占用,但是还需要执行一次,所以暂时不能销毁,但返回的值执行完成后,浏览器会在空闲的时候把它销毁了。

以上便是总结:只要某作用域还有被引用,那么该作用域就不能被销毁,一旦没有任何变量引用了,该私有作用域就会被销毁了。

很多人都听过一个版本,就是闭包会造成内存泄漏,所以要尽量减少闭包的使用,不是你想象那样的

  • 局部变量本来应该随着函数的执行完毕被销毁,但如果局部变量被封装在闭包形成的环境中,那这个局部变量就一直能存在
  • 之所以使用闭包是因为我们想要把一些变量存起来方便以后使用,这和放到全局下,对内存的影响是一致的,并不算是内存泄漏。如果在将来想回收这些变量,直接把变量设为null即可了
  • 还有就是在使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些DOM节点,此时就有可能造成内存泄漏。但这本身并非闭包的问题,也并非js的问题
  • 要怪就怪老版本的IE同志吧,它内部实现的垃圾回收机制采用的是引用计数策略。在老同志IE中,如果两个对象之间形成了循环引用,那么这两个对象都不能被回收,但循环引用造成的内存泄漏其本质也不是闭包的错
  • 同样要解决循环引用代理的内存泄漏问题,只需把循环引用中的变量设为null就好

堆内存的释放

      对象数据类型或者函数类型在定义的时候,首先都会开辟一个堆内存,堆内存有一个引用地址,如果外面有引用这个地址,我们就说这个内存被占用了,就不能销毁了。有以下两种方式实现:

引用计数法

      IE9的JavaScript引擎使用的垃圾回收算法是引用计数法,对于循环引用将会导致GC无法回收“应该被回收”的内存。造成了无意义的内存占用,也就是内存泄漏。

      顾名思义,让所有对象实现记录下有多少“程序”在引用自己,让各对象都知道自己的“人气指数”。举一个简单的例子:

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

优势

  • 可即刻回收垃圾,当被引用数值为0时,对象马上会把自己作为空闲空间连到空闲链表上,也就是说。在变成垃圾的时候就立刻被回收。
  • 因为是即时回收,那么‘程序’不会暂停去单独使用很长一段时间的GC,那么最大暂停时间很短。
  • 不用去遍历堆里面的所有活动对象和非活动对象

劣势

  • 计数器需要占很大的位置,因为不能预估被引用的上限,打个比方,可能出现32位即2的32次方个对象同时引用一个对象,那么计数器就需要32位。
  • 最大的劣势是无法解决循环引用无法回收的问题 这就是前文中IE9之前出现的问题
    一个简单的例子:
function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2,o2的引用次数是1
  o2.a = o; // o2 引用 o,o的引用此时是1
  return "azerty";
}
f();

      fn在执行完成之后理应回收fn作用域里面的内存空间,但是因为o里面有一个属性引用o2,导致o2的引用次数始终为1,o2也是如此,而又非专门当做闭包来使用,所以这里就应该使o和o2被销毁。

      因为算法是将引用次数为0的对象销毁,此处都不为0,导致GC不会回收他们,那么这就是内存泄漏问题。

      该算法已经逐渐被 ‘标记-清除’ 算法替代,在V8引擎里面,使用最多的就是 标记-清除算法

标记清除算法

       主要将GC的垃圾回收过程分为两个阶段

  • 标记阶段:把所有活动对象做上标记。
  • 清除阶段:把没有标记(也就是非活动对象)销毁。
var a = {name: 'bar'} // '这个对象'被a引用,是活动对象。
a=null; // ‘这个对象’没有被a引用了,这个对象是非活动对象。

标记阶段

      根可以理解成我们的全局作用域,GC从全局作用域的变量,沿作用域逐层往里遍历(对,是深度遍历),当遍历到堆中对象时,说明该对象被引用着,则打上一个标记,继续递归遍历(因为肯定存在堆中对象引用另一个堆中对象),直到遍历到最后一个(最深的一层作用域)节点。

清除阶段

      又要遍历,这次是遍历整个堆,回收没有打上标记的对象。
优势:

  • 实现简单,打标记也就是打或者不打两种可能,所以就一位二进制位就可以表示
  • 解决了循环引用问题

缺点

  • 造成碎片化(有点类似磁盘的碎片化)
  • 再分配时遍次数多,如果一直没有找到合适的内存块大小,那么会遍历空闲链表(保存堆中所有空闲地址空间的地址形成的链表)一直遍历到尾端