pfan123 / Articles

经验文章

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

JavaScript的垃圾回收机制

pfan123 opened this issue · comments

前言

无论高级语言,还是低级语言。内存的管理都是:

  1. 内存分配:申明变量、函数、对象,系统会自动分配内存
  2. 内存使用:读写内存,使用变量、函数等
  3. 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

释放内存处理方式,各种语言都有自己的垃圾回收(garbage collection, 简称GC)机制。做GC的第一步是判断堆中存的是数据还是指针,是指针的话,说明它被指向活跃的对象。有3种判断方法:

  1. Conservative:存储格式是地址,C/C++有用到这种算法。
  2. Compiler hints:对于静态语言,比如Java,编译器是知道它是不是指针的,所以可以用这种。
  3. Tagged pointersJavaScript用的是这种,在字末位进行标识,1为指针。

JavaScript 内存问题

内存泄漏

什么情况下会内存泄漏 memory leak ?可以这么理解,就是有些代码本来应该要被回收的,但是没有被回收,所以一直占用着操作系统的内存,从而越积越多。一般的内存泄漏其实无关紧要,可怕的是内存泄漏引起的堆积,导致GC一直没办法使用所占用的内存给其他程序使用。

内存溢出

内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现 out of memory;比如申请了一个 integer, 但给它存了 long 才能存下的数,那就是内存溢出。

注意: memory leak 会最终会导致 out of memory

JavaScript 垃圾管理

JavaScript 有自动垃圾收集机制,找出不再继续使用的值,然后释放其占用的内存。垃圾收集器会每隔固定的时间段就执行一次释放操作。 在 JavaScript 中,最常用的是通过标记清除的算法来找到哪些对象是不再继续使用的,因此 a = null 其实仅仅只是做了一个释放引用的操作,让 a 原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。

  • 在局部作用域中,当函数执行完毕,局部变量也就没有存在的必要了,因此垃圾收集器很容易做出判断并回收。但是全局变量什么时候需要自动释放内存空间则很难判断,因此在我们的开发中,需要尽量避免使用全局变量,以确保性能问题。
  • 以 Google 的 V8 引擎为例,在 V8 引擎中所有的 JAVASCRIPT 对象都是通过堆来进行内存分配的。当我们在代码中声明变量并赋值时,V8 引擎就会在堆内存中分配一部分给这个变量。如果已申请的内存不足以存储这个变量时,V8 引擎就会继续申请内存,直到堆的大小达到了 V8 引擎的内存上限为止(默认情况下,V8引擎的堆内存的大小上限在 64 位系统中为 1464MB,在32位系统中则为 732MB)。
  • 另外,V8 引擎对堆内存中的 JAVASCRIPT 对象进行分代管理。新生代:新生代即存活周期较短的 JAVASCRIPT 对象,如临时变量、字符串等; 老生代:老生代则为经过多次垃圾回收仍然存活,存活周期较长的对象,如主控制器、服务器对象等。

javascript回收方法

V8 引擎中使用两种优化方法:
  1. 分代回收;
  2. 增量 GC
  3. 目的是通过对象的使用频率、存在时长区分新生代与老生代对象。多回收新生代区(young generation),少回收老生代区(tenured generation),减少每次需遍历的对象,从而减少每次 GC 的耗时。
  4. 把需要长耗时的遍历、回收操作拆分运行,减少中断时间,但是会增大上下文切换开销.
回收算法:

大部分垃圾回收语言用的算法称之为 Mark-and-sweep

(1) 引用计数 (reference counting)

在内存管理环境中,对象 A 如果有访问对象 B 的权限,叫做对象 A 引用对象 B。引用计数的策略是将“对象是否不再需要 ”简化成“ 对象有没有其他对象引用到它,如果没有对象引用这个对象,那么这个对象将会被回收。

let obj1 = { a: 1 }; // 一个对象(称之为 A)被创建,赋值给 obj1,A 的引用个数为 1 ,很显然引用次数不为0, 无法垃圾收集
let obj2 = obj1; // A 的引用个数变为 2

obj1 = 0; // A 的引用个数变为 1
obj2 = 0; // A 的引用个数变为 0,此时对象 A 就可以被垃圾回收了

mozilla 文档 中很形象的一个例子:

var o = { 
  a: {
    b:2
  }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;      // 现在,“这个对象”只有一个o2变量的引用了,“这个对象”的原始引用o已经没有

var oa = o2.a; // 引用“这个对象”的a属性
               // 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了
           // 但是它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
           // 它可以被垃圾回收了

当对象被引用次数为 0 时,就被回收。潜在的一个问题是:循环引用时,两个对象都至少被引用了一次,将不能自动被回收,导致内存泄露。

function func() {
    let obj1 = {};
    let obj2 = {};

    obj1.a = obj2; // obj1 引用 obj2
    obj2.a = obj1; // obj2 引用 obj1
}

当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。

要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。上面的例子可以这么做:

obj1 = null;
obj2 = null;

(2)标记清除 (mark and sweep)
这是当前主流的 GC 算法,从2012年起,所有现代浏览器都使用了** 标记-清除(Mark-Sweep)**垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于 标记-清除算法 的改进,并没有改进 标记-清除算法 本身和它对“对象是否不再需要”的简化定义。

Mark-Sweep(标记清除)分为标记清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。

当对象,无法从根对象沿着引用遍历到,即不可达(unreachable),进行清除。JAVASCRIPT 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象...对这些活着的对象进行标记,这是标记阶段清除阶段就是清除那些没有被标记的对象。

有了标记清除法,循环引用不再是问题了。在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。

常见 JavaScript 内存泄漏

1.意外的全局变量(全局变量不会被标记清除法清除)

JavaScript 处理未定义变量的方式比较宽松:未定义的变量会在全局对象创建一个新变量。在浏览器中,全局对象是 window

function foo(arg) {
    bar = "this is a hidden global variable";  // 意外挂在在 window 全局变量,导致内存泄漏
}

解决方法:

delete window.bar

2.被遗忘的计时器或回调函数

  • 计数器函数,一直占用内存
// 计数器一直存在会一直占用内存,计数器结束需要做释放处理
var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 处理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);
  • 事件回调函数(老式浏览器如 IE 6 无法做回收,新版浏览器已经可以处理)
var element = document.getElementById('button');
function onClick(event) {
    element.innerHTML = 'text';
}
element.addEventListener('click', onClick);  
//针对老式浏览器,回收需要移除事件回调
element.removeEventListener('click', onClick);  

3.闭包递归

闭包的特性:1.函数嵌套函数,2.函数内部可以引用外部的参数和变量,3.参数和变量不会被垃圾回收机制回收

闭包由于存在变量引用其返回的匿名函数,导致作用域无法得到释放。 最新版本的浏览器中,可以通过标记清除的方式处理掉闭包的作用域导致的内存泄漏,但闭包的变量(匿名函数、参数变量)会常驻内存,会造成一定的性能问题

let index = 0
function readData() {
  let buf = new Buffer(1024 * 1024 * 100)
  buf.fill('g')  

  return function fn() { // 此处会把 return 出来的函数挂在在 window 下,作用域无法清除
    index++   // 引入局外变量,内存无法清除
    if (index < buf.length) { 
      return buf[index-1]   // buf 不会被清除,需要手动清除
    } else {
      return ''
    } 
  }
}

const data = readData()
const next = data()

内存剖析工具方法

Chrome浏览器方法

Chrome 提供了一套很棒的检测 JavaScript 内存占用的工具 Memory , 提供 Heap snapshot (堆内存截图)、allocation instrumentation on timeline ( 内存timeline上的分配检测)、allocation sampling (内存分配抽样)

Node 命令行查看内存状态方法

console.log(process.memoryUsage());
// { rss: 27709440,
//  heapTotal: 5685248,
//  heapUsed: 3449392,
//  external: 8772 }

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,单位是字节,含义如下。

  • rss(resident set size)`:所有内存占用,包括指令区和堆栈。
  • heapTotal:"堆"占用的内存,包括用到的和没用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎内部的 C++ 对象占用的内存。

判断内存泄漏,以heapUsed字段为准。

WeakSet 和 WeakMap

通过前几个内存泄漏示例我们会发现如果我们一旦疏忽,就会容易地引发内存泄漏的问题。及时清除引用,回收内存非常重要。但是实际生产过程中,有可能不清楚上下文,导致内存泄漏。最好能有一种方法,在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。这样就能大大减轻程序员的负担,只要清除主要引用就可以了。

ES6 考虑到了这一点,推出了两种新的数据结构:WeakSetWeakMap。它们对于值的引用都是不计入垃圾回收机制的,弱引用。

const wm = new WeakMap();

const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"

上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element的引用就是弱引用,不会被计入垃圾回收机制。

也就是说,DOM 节点对象的引用计数是1,而不是2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。

基本上,如果要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap

参考阅读:

JavaScript深入理解之垃圾收集

阮一峰-JavaScript 内存泄漏教程

阮一峰-JavaScript 运行机制详解:再谈Event Loop

4类 JavaScript 内存泄漏及如何避免

10分钟了解JS堆、栈以及事件循环的概念

(js队列,堆栈) (FIFO,LIFO)

从Promise到Event Loop

Node.js 内存管理和 V8 垃圾回收机制