万字总结:Javascript垃圾回收机制

一、为什么要关注内存

1、防止页面占用内存过大,引起客户端卡顿,甚至无响应

2、Node使用的也是V8,内存对于后端服务的性能至关重要。因为服务的持久性,后端更同意造成内存溢出

Chrome V8 简称 V8,是由谷歌开源的一个高性能 JavaScript 引擎。该引擎采用 C++ 编写,Google Chrome 浏览器用的就是这个引擎。V8 可以单独运行,也可以嵌入 C++ 应用当中。和其他的 JavaScript 引擎一样,V8 会编译、执行 JavaScript 代码,并一样会管理内存、垃圾回收等。就是因为 V8 的高性能以及跨平台等特性,所以它也是 Node.js 的 JavaScript 引擎。

二、js数据类型与js内存机制

值类型(基本类型):字符串(String)、数字(Number)、布尔(Boolean)、对空(Null)、未定义(Undefined)、Symbol。

引用数据类型:对象(Object)、数组(Array)、函数(Function)。
在这里插入图片描述
在定义变量时候就完成了分配内存,使用时候是对内存的读写操作,内存的释放依赖于浏览器的垃圾回收机制

三、V8引擎的内存分配

和操作系统有关,64位系统下大约为1.4GB,在32位系统下大约为0.7G。

64位下新生代的空间为64M,老生代为1400M

32位下新生代的空间为16M,老生代为700M

为什么只分配很小的内存做垃圾回收?
1、V8最初是为了浏览器设计的,不太可能遇到大内存的场景
2、js垃圾回收的时候程序会暂停线程执行,会占用一定时间。

拓展:
在NodeJS环境中,我们可以通过**process.memoryUsage()**来查看内存分配
在这里插入图片描述
process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,含义如下:
在这里插入图片描述

rss(resident set size):所有内存占用,包括指令区和堆栈

heapTotal:V8引擎可以分配的最大堆内存,包含下面的 heapUsed

heapUsed:V8引擎已经分配使用的堆内存

external: V8管理C++对象绑定到JavaScript对象上的内存
以上所有内存单位均为字节(Byte)。

如果说想要扩大Node可用的内存空间,可以使用Buffer等堆外内存内存

下面是Node的整体架构图
node环境v8内存

Node Standard Library: 是我们每天都在用的标准库,如Http, Buffer 模块

Node Bindings: 是沟通JS 和 C++的桥梁,封装V8和Libuv的细节,向上层提供基础API服务

第三层是支撑 Node.js 运行的关键,由 C/C++ 实现:

  1. V8 是Google开发的JavaScript引擎,提供JavaScript运行环境,可以说它就是 Node.js 的发动机
  2. Libuv 是专门为Node.js开发的一个封装库,提供跨平台的异步I/O能力
  3. C-ares:提供了异步处理 DNS 相关的能力
  4. http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其他的能力

四、什么是垃圾回收

找出那些不再继续使用的变量,然后释放其所占用的内存,垃圾回收器会按照固定的时间间隔周期性的执行这一操作

1、定时回收
2、内存不够了回收

javaScript使用垃圾回收机制来自动管理内存,垃圾回收是一把双刃剑

优势:可以大幅度简化程序的内存管理代码,降低程序的负担,减少因时常运转而带来的内存泄露问题。

劣势:意味着程序员将无法掌控内存。js没有暴露任何关于内存的API。我们无法强迫其进行垃圾回收,也无法干预内存管理。

五、垃圾回收机制

5.1、如何判断是否可以回收

5.1.1 引用计数

跟踪记录每个值被引用的次数,如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。

例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。 在这里,“对象”的概念不仅特指
JavaScript 对象,还包括函数作用域(或者全局词法作用域)

致命缺陷:循环引用。
如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o  这里

  return "azerty";
}

f();

上面我们申明了一个函数 f ,其中包含两个相互引用的对象。 在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。 但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

var div = document.createElement("div");
div.onclick = function(event) {
    console.log(event);
};

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。
此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。
一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。

为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。

但也不是完全不用引用计数

console.log(process.memoryUsage())
var a = new Array(20*1024*1024);
var b= new Array(20*1024*1024);
var c = new Array(20*1024*1024);
console.log(process.memoryUsage())
a = null
b=null
c=null
console.log(process.memoryUsage())
setTimeout(()=>{
	console.log(process.memoryUsage())
},2000

result
{
  rss: 17723392,
  heapTotal: 4210688,
  heapUsed: 2063704,
  external: 675600
}
{
  rss: 523169792,
  heapTotal: 511475712,
  heapUsed: 505903592,
  external: 847389
}
{
  rss: 523173888,
  heapTotal: 511475712,
  heapUsed: 505912344,
  external: 847389
}
{
  rss: 523517952,
  heapTotal: 515670016,
  heapUsed: 505424936,
  external: 840160
}

5.1.2、标记清除
  1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
  2. 从根部出发将能触及到的对象的标记使用。
  3. 那些还存在标记的变量被视为准备删除的变量。
  4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。
    在这里插入图片描述 V8 GC 的关键是 root 级对象,因此内存泄露基本上都是由于 root 级引用没有被释放导致的

5.2、内存泄漏

5.2.1、什么是内存泄漏?

内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费。

5.2.2、内存泄漏的识别方法

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。
这就要求实时查看内存的占用情况。

在 Chrome 浏览器中,我们可以这样查看内存占用情况

  1. 打开开发者工具,选择 Performance 面板
  2. 在顶部勾选 Memory
  3. 点击左上角的 record 按钮
  4. 在页面上进行各种操作,模拟用户的使用情况
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况

来看一张效果图:
在这里插入图片描述

我们有两种方式来判定当前是否有内存泄漏:

多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏
某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏
在这里插入图片描述![在这里插入图片描述](https://img-blog.csdnimg.cn/20200426100432244.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMwODY4Mjg5,size_16,color_FFFFFF,t_70

在服务器环境中使用 Node 提供的 process.memoryUsage 方法查看内存情况

5.2.3、常见的内存泄露案例

意外的全局变量

function foo() {
    bar1 = 'some text'; // 没有声明变量 实际上是全局变量 => window.bar1
    this.bar2 = 'some text' // 全局变量 => window.bar2
}
foo();

被遗忘的定时器和回调函数
在很多库中, 如果使用了观察者模式
, 都会提供回调方法, 来调用一些回调函数。 要记得回收这些回调函数。举一个 setInterval的例子

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); // 每 5 秒调用一次

如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。 但如果你没有回收定时器,整个定时器依然有效, 不但定时器无法被内存回收, 定时器函数中的依赖也无法回收。在这个案例中的 serverData 也无法被回收。

闭包

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  // Define a closure that references originalThing but doesn't ever actually get called.
//定义一个引用originalThing但实际上没有调用它的实例闭包
  // But because this closure exists, originalThing will be in the
  // lexical environment for all closures defined in replaceThing, instead of
  // being optimized out of it. If you remove this function, there is no leak.
//但是由于这个闭包的存在,originalThing将存于定义在replaceThing中的所有闭包的词法环境中,而不被优化,如果你移除 这个方法,就不会有泄漏
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    // While originalThing is theoretically accessible by this function, it
    // obviously doesn't use it. But because originalThing is part of the
    // lexical environment, someMethod will hold a reference to originalThing,
    // and so even though we are replacing theThing with something that has no
    // effective way to reference the old value of theThing, the old value
// will never get cleaned up!
//虽然originalThing理论上可以通过这函数(someMethod)访问,但显然没有使用它,但是由于originalThing是词法环境的一部分,someMethod将会保留一个引用指向originalThing,所以即使我们用非有效的方式替换旧的theThing值,但是旧值不会被清理。
    someMethod: function () {}
  };
  // If you add `originalThing = null` here, there is no leak.
//此处加originalThing = null不会有泄漏
};
setInterval(replaceThing, 1000);

DOM 引用
很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中。

var elements = {
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    document.body.removeChild(document.getElementById('image'));
    // 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收.
}

上述案例中,即使我们对于 image 元素进行了移除,但是仍然有对 image 元素的引用,依然无法对齐进行内存回收。

5.2.4、优化内存技巧

WeakMap
前面说过,及时清除引用非常重要。但是,你不可能记得那么多,有时候一疏忽就忘了,所以才有那么多内存泄漏。

最好能有一种方法,在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。这样就能大大减轻程序员的负担,你只要清除主要引用就可以了。

ES6 考虑到了这一点,推出了两种新的数据结构:WeakSet 和 WeakMap。它们对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个"Weak",表示这是弱引用。
下面以 WeakMap 为例,看看它是怎么解决内存泄漏的。


    // 手动执行一次垃圾回收,保证获取的内存使用状态准确
    > global.gc(); 
    undefined

    // 查看内存占用的初始状态,heapUsed 为 4M 左右
    > process.memoryUsage(); 
    { rss: 21106688,
      heapTotal: 7376896,
      heapUsed: 4153936,
      external: 9059 }

    > let wm = new WeakMap();
    undefined

    > let b = new Object();
    undefined

    > global.gc();
    undefined

    // 此时,heapUsed 仍然为 4M 左右
    > process.memoryUsage(); 
    { rss: 20537344,
      heapTotal: 9474048,
      heapUsed: 3967272,
      external: 8993 }

    // 在 WeakMap 中添加一个键值对,
    // 键名为对象 b,键值为一个 5*1024*1024 的数组  
    > wm.set(b, new Array(5*1024*1024));
    WeakMap {}

    // 手动执行一次垃圾回收
    > global.gc();
    undefined

    // 此时,heapUsed 为 45M 左右
    > process.memoryUsage(); 
    { rss: 62652416,
      heapTotal: 51437568,
      heapUsed: 45911664,
      external: 8951 }

    // 解除对象 b 的引用  
    > b = null;
    null

    // 再次执行垃圾回收
    > global.gc();
    undefined

    // 解除 b 的引用以后,heapUsed 变回 4M 左右
    // 说明 WeakMap 中的那个长度为 5*1024*1024 的数组被销毁了
    > process.memoryUsage(); 
    { rss: 20639744,
      heapTotal: 8425472,
      heapUsed: 3979792,
      external: 8956 }

在这里插入图片描述

5.3、V8引擎的垃圾回收

5.3.1、v8的回收策略

自动垃圾回收有很多算法,由于不同对象的生存周期不同,所以无法只用一种回收策略来解决问题,这样效率会很低。

  • V8采用了一种分代回收的策略,将内存分为两个生代:新生代(new generation)和老生代
  • V8分别对新生代和老生代使用不同的垃圾回收算法来提升回收效率
    新生代简单来说就是复制
    老生代就是标记删除整理
  • 新生代:新生代中的对象为存活时间较短的对象
  • 老生代:老生代中的对象为存活时间较长或常驻内存的对象。

对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代)
新生代中的对象会在满足某些条件后,被移动到老生代,这个过程也叫晋升

5.3.2、分代内存

默认情况下,32位系统新生代内存大小为16MB,老生代内存大小为700MB
64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。

新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。

5.3.3、新生代对象的晋升

在这里插入图片描述
在这里插入图片描述

5.3.4、新生代算法

新生代主要使用Scavenge进行管理,主要实现是Cheney算法,将内存平均分为两块,使用空间叫From,闲置空间叫To,新对象都先分配到From空间中,在空间快要占满时将存活对象复制到To空间中,然后清空From的内存空间,此时,调换From空间和To空间,继续进行内存分配,当满足那两个条件时对象会从新生代晋升到老生代。
在这里插入图片描述

5.3.5、老生代算法

老生代主要采用Mark-Sweep和Mark-Compact算法,一个是标记清除,一个是标记整理。两者不同的地方是,Mark-Sweep在垃圾回收后会产生碎片内存,而Mark-Compact在清除前会进行一步整理,将存活对象向一侧移动,随后清空边界的另一侧内存,这样空闲的内存都是连续的,但是带来的问题就是速度会慢一些。在V8中,老生代是Mark-Sweep和Mark-Compact两者共同进行管理的
在这里插入图片描述在这里插入图片描述

参考:
「前端进阶」JS中的内存管理
聊聊V8引擎的垃圾回收

  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值