垃圾回收机制

介绍

浏览器的 JavaScript 具有自动垃圾回收机制(GC:Garbage Collecation),也就是说,执行环境会负责管理代码执行过程中使用的内存。

其原理是:垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存。但是这个过程不是实时的,因为其开销比较大并且垃圾回收时停止响应其他操作,所以垃圾回收器会按照固定的时间间隔周期性的执行。

不再使用的变量也就是生命周期结束的变量,当然只可能是局部变量,全局变量的生命周期直至浏览器卸载页面才会结束。局部变量只在函数的执行过程中存在,而在这个过程中会为局部变量在栈或堆上分配相应的空间,以存储它们的值,然后在函数中使用这些变量,直至函数结束,而闭包中由于内部函数的原因,外部函数并不能算是结束。

function fn1 {
  var obj = {name: 'zhangshan', age: 10};
}
function fn2 {
  var obj = {name:'zhangshan', age: 10};
  return obj;
}
var a = fn1;
var b = fn2;

我们来看代码是如何执行的。

  • 首先声明了两个函数,分别叫做 fn1 和 fn2,
  • 当 fn1 被调用时,进入 fn1 的环境,会开辟一块内存存放对象 {name:'zhangshan',age:10},而当调用结束后,出了 fn1 的环境,那么该块内存会被 JS 引擎中的垃圾回收器自动释放;
  • 在 fn2 被调用的过程中,返回的对象被全局变量 b 所指向,所以该块内存并不会被释放。

这里问题就出现了:到底哪个变量是没有用的?

所以垃圾收集器必须跟踪到底哪个变量没用,对于不再有用的变量打上标记,以备将来收回其内存。

用于标记的无用变量的策略可能因实现而有所区别,通常情况下有两种实现方式:标记清除引用计数。引用计数不太常用,标记清除较为常用。

标记清除

js 中最常用的垃圾回收方式就是标记清除。

当变量进入环境时,例如,在函数中声明一个变量,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

function test{
  var a = 10 ; // 被标记 ,进入环境
  var b = 20 ; // 被标记 ,进入环境
}
test(); // 执行完毕 之后 a、b又被标离开环境,被回收。
  • 垃圾回收器在运行的时候会给存储在内存中的所有变量都加上标记。
  • 然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记(闭包)。
  • 而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。
  • 最后,垃圾回收器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。
  • 到目前为止,IE9+、Firefox、Opera、Chrome、Safari 的 JS 实现使用的都是标记清除的垃圾回收策略或类似的策略,只不过垃圾收集的时间间隔互不相同。

引用计数

  • 引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是 1。
  • 如果同一个值又被赋给另一个变量,则该值的引用次数加 1。
  • 相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减 1。
  • 当这个值的引用次数变成 0 时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。
  • 这样,当垃圾回收器下次再运行时,它就会释放那些引用次数为 0 的值所占用的内存。
function test {
  var a = {}; // a 指向对象的引用次数为 1
  var b = a; // a 指向对象的引用次数加 1,为2
  var c = a; // a 指向对象的引用次数再加1,为3
  var b = {}; // a 指向对象的引用次数减1,为2
}

Netscape Navigator3 是最早使用引用计数策略的浏览器,但很快它就遇到一个严重的问题:循环引用。循环引用指的是对象 A 中包含一个指向对象 B 的指针,而对象 B 中也包含一个指向对象 A 的引用。

function fn {
  var a = {};
  var b = {};
  a.pro = b;
  b.pro = a;
}
fn();
  • 以上代码 a 和 b 的引用次数都是 2, fn 执行完毕后,两个对象都已经离开环境,在标记清除方式下是没有问题的,

  • 但是在引用计数策略下,因为 a 和 b 的引用次数不为 0,所以不会被垃圾回收器回收内存,如果 fn 函数被大量调用,就会造成内存泄露。

  • 在 IE7 与 IE8 上,内存直线上升。

  • 我们知道,IE 中有一部分对象并不是原生 JS 对象。

  • 例如,其内存泄露 DOM 和 BOM 中的对象就是使用 C++ 以 COM 对象的形式实现的,而 COM 对象的垃圾回收机制采用的就是引用计数策略。

  • 因此,即使 IE 的 js 引擎采用标记清除策略来实现,但 JS 访问的 COM 对象依然是基于引用计数策略的。

  • 换句话说,只要在 IE 中涉及 COM 对象,就会存在循环引用的问题。

var element = document.getElementById("some_element");
var myObject = new Object;

myObject.e = element;
element.o = myObject;
  • 这个例子在一个 DOM 元素 element 与一个原生 js 对象 myObject 之间创建了循环引用。
  • 其中,变量 myObject有一个属性 e 指向 element 对象;
  • 而变量 element 也有一个属性 o 回指 myObject。
  • 由于存在这个循环引用,即使例子中的 DOM 从页面中移除,它也永远不会被回收。

内存管理

什么时候触发垃圾回收?

垃圾回收器周期性运行。IE6 的垃圾回收是根据内存分配量运行的,当环境中存在 256 个变量、4096 个对象、64k 的字符串任意一种情况的时候就会触发垃圾回收器工作,看起来很科学,不用按一段时间就调用一次,有时候会没必要,这样按需调用不是很好吗?但是如果环境中就是有这么多变量等一直存在,现在脚本如此复杂,很正常,那么结果就是垃圾回收器一直在工作,这样浏览器就没法儿玩儿了。

微软在 IE7 中做了调整,触发条件不再是固定的,而是动态修改的,初始值和 IE6 相同,如果垃圾回收器回收的内存分配量低于程序占用内存的 15%,说明大部分内存不可被回收,设的垃圾回收触发条件过于敏感,这时候把临界条件翻倍,如果回收的内存高于 85%,说明大部分内存早就该清理了,这时候把触发条件置回。这样就使垃圾回收工作智能了很多。

合理的垃圾回收方案

基础方案:

JavaScript 引擎基础 GC 方案是(simple GC):mark and sweep(标记清除),即:

  • 遍历所有可访问的对象。
  • 回收已不可访问的对象。

GC 的缺陷:

  • 和其他语言一样,JS 的 GC 策略也无法避免一个问题:GC 时,停止响应其他操作,这是为了安全考虑。
  • 而 Java 的 GC 在 100ms 甚至以上,对一般的应用还好,但对于 JS 游戏,动画对连贯性要求比较高的应用,就麻烦了。
  • 这就是新引擎需要优化的点:避免 GC 造成的长时间停止响应。

GC 优化策略:

主要有 2 个优化方案:

  • 分代回收(Generation GC) 这个和 Java 回收策略思想是一致的,也是 V8 所主要采用的。目的是通过区分“临时”与“持久”对象;多回收“临时对象”区,少回收“持久对象”区,减少每次需遍历的对象,从而减少每次 GC 的耗时。
  • 增量 GC 这个方案的思想很简单,就是“每次处理一点,下次再处理一点,如此类推”。 这种方案,虽然耗时短,但中断较多,带来了上下文切换频繁的问题。

因为每种方案都其适用场景和缺点,因此在实际应用中,会根据实际情况选择方案。比如:低 (对象/s) 比率时,中断执行 GC 的频率,simple GC 更低些;如果大量对象都是长期“存活”,则分代处理优势也不大。

Vue 中的内存泄漏问题

JS 程序的内存溢出后,会使某一段函数体永远失效(取决于当时的 JS 代码运行到哪一个函数),通常表现为程序突然卡死或程序出现异常。

这时我们就要对该 JS 程序进行内存泄漏的排查,找出哪些对象所占用的内存没有释放。

这些对象通常都是开发者以为释放掉了,但事实上仍被某个闭包引用着,或者放在某个数组里面。

泄漏点:

  1. DOM/BOM 对象泄漏;
  2. 存在对 DOM/BOM 对象的引用导致;
  3. JS 对象泄漏;
  4. 通常由闭包导致,比如事件处理回调,导致 DOM 对象和脚本中对象双向引用,这个是常见的泄漏原因;

代码关注点:

主要关注的就是各种事件绑定场景,比如:

  1. DOM 中的 addEventLister 函数及派生的事件监听,比如 Jquery 中的 on 函数,Vue 组件实例的 $on 函数;
  2. 其它 BOM 对象的事件监听, 比如 websocket 实例的 on 函数;
  3. 避免不必要的函数引用;
  4. 如果使用 render 函数,避免在 HTML 标签中绑定 DOM/BOM 事件;

如何处理:

  1. 如果在 mounted/created 钩子中使用 JS 绑定了 DOM/BOM 对象中的事件,需要在 beforeDestroy 中做对应解绑处理;
  2. 如果在 mounted/created 钩子中使用了第三方库初始化,需要在 beforeDestroy 中做对应销毁处理(一般用不到,因为很多时候都是直接全局 Vue.use);
  3. 如果组件中使用了 setInterval,需要在 beforeDestroy 中做对应销毁处理;

在 vue 组件中处理 addEventListener:

调用 addEventListener 添加事件监听后在 beforeDestroy 中调用 removeEventListener 移除对应的事件监听。

为了准确移除监听,尽量不要使用匿名函数或者已有的函数的绑定来直接作为事件监听函数。

mounted {
  const box = document.getElementById('time-line')
  this.width = box.offsetWidth
  this.resizefun = => {
    this.width = box.offsetWidth
  }
  window.addEventListener('resize', this.resizefun)
},
beforeDestroy {
  window.removeEventListener('resize', this.resizefun)
  this.resizefun = null
}

观察者模式引起的内存泄漏:

在 spa 应用中使用观察者模式的时候如果给观察者注册了被观察的方法,而没有在离开组件的时候及时移除,可能造成重复注册而内存泄漏; 举个栗子:进入组件的时候 ob.addListener("enter",_func),如果离开组件 beforeDestroy 的时候没有 ob.removeListener("enter",_func),就会导致内存泄漏。

参考资料

1、https://www.sohu.com/a/340397574_463987 2、https://segmentfault.com/a/1190000018605776?utm_source=tag-newest 3、https://www.jianshu.com/p/2c10e2537fda 4、https://blog.csdn.net/qq_39643614/article/details/97679133

Last Updated:
Contributors: Rain9