JS内存管理那些事

1、内存管理

1.1 为什么关注内存管理

像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()。相反,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,
并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript开发者错误的感觉他们可以不关心内存管理。

因为JS有比较完善的垃圾回收机制,同时之前的web页面大多是比较简单的多页面应用,页面停留时间短,页面卡顿了,刷新或者重启一下就可以了,前端开发者不用特别的关注内存管理,
但随着移动互联网和前端SPA应用的流行,页面停留的时间变长,移动APP里内置web页面以及PWA对体验提出了更高的要求,JS内存可能成为新的内存瓶颈。

mm01.png

内存问题表现

  • 页面出现延迟加载或经常性暂停
  • 页面持续性出现糟糕的性能
  • 页面的性能随时间延长越来越差

思考:JS内存问题为什么会导致页面卡顿?下面章节中有说明

1.2 内存声明周期

不管什么编程语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放/归还
mm02.png
编程语言 分配 使用 释放
C之类底层语言 手动 malloc() 读写内存 手动 free()
JS等高级语言 声明变量,运行时系统自动分配内存 读写内存 垃圾回收机制自动回收不再使用的内存

所有语言内存使用部分都是明确的。分配和释放部分在底层语言中是明确的,一般都有底层的内存管理接口,比如 malloc()和free()用于手动分配内存和释放内存。
但在像JavaScript这些高级语言中,大部分都是隐含的,会在创建变量时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收,绝大部分内存管理问题都是处于这个阶段。

2、垃圾回收及常见GC算法

2.1 JS运行机制

在讲垃圾回收之前先看下JS运行机制如下图

mm03.jpg

重点关注JS引擎,有两个重要的组成部分:

  • 调用栈:这是JS代码执行时的地方。当引擎遇到像函数调用之类的可执行单元,就会把它们推入调用栈。
  • 内存堆:这是内存分配发生的地方。当JS引擎遇到复杂变量声明和函数声明的时候,就把它们存储在堆里面。

下面分别讲下调用栈和堆中的垃圾回收

2.2 调用栈垃圾回收

函数是一段连续的内存空间,主要用于存放函数调用信息和变量等数据。有一个记录当前执行状态的栈指针ESP(Extended Stack Pointer)指向调用栈的栈顶。
栈内存中变量一般在它的当前执行环境结束就会被销毁。
以下面代码为例,innerFn执行结束之后,ESP下移到outerFn执行上下文,innerFn1执行上下文被销毁,往下执行到innerFn2时,innerFn2执行上下文入栈,ESP上移。

function outerFn() { 
    const a = { name: 'a' };
    function innerFn1() { 
         const b = { name: 'b' }; 
    } 
    function innerFn2() { 
         const c = { name: 'c' }; 
    }
    innerFn1(); 
    innerFn2(); 

} 
outerFn();
mm04.jpg

2.3 堆中的垃圾回收

与栈中的垃圾回收不同,堆中的垃圾回收需要使用 JavaScript 中的垃圾回收器。
垃圾回收器查找垃圾和回收空间所遵循的规则为GC算法,在讲常见GC算法之前先了解下相关的概念。

堆内存中的垃圾

  • 不再被引用的内存
  • 从GC root上不可达

GC root包括但不限于以下几种(浏览器中)

  • 全局对象window
  • 存放在栈上的变量
  • 文档DOM树,由可以通过遍历文档到达所有原生 DOM 节点组成

2.3.1 常见GC算法

  • 引用计数算法
  • 标记清除算法
  • 标记整理算法

引用计数算法

核心思想:判断对象有没有其他对象引用到它,引用为0时立即回收。

优点:发现垃圾时立即回收,最大限度减少程序暂停。

存在的问题:无法回收循环引用的对象,常常造成对象被循环引用时内存发生泄漏。

比如下面例子中,两个对象互相引用,就造成了循环引用,f调用之后它们已经没有用了,可以被回收了,但是如果采用引用计数算法他们的引用都不为0,所以它们不会被回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用 o2
  o2.p = o1; // o2 引用 o1. 这里会形成一个循环引用
}
f();
mm05.png

使用现状:IE 6, 7 使用,现代浏览器中已不再使用

下面例子中,div这个DOM元素里的circularReference属性引用了div,造成了循环引用。 IE 6, 7中使用引用计数方式进行垃圾回收,会造成内存发生泄漏。现代浏览器通过使用标记清除算法,来解决这一问题。

var div;
window.onload = function(){
  div = document.getElementById("myDivElement");
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join("*");
};

标记清除算法

核心思想:分为标记和清除两个阶段,如下图所示

  • 第一步标记,从GC root开始遍历可达的对象标记为活动对象,到达不了的元素可以判断为非活动对象,也就是垃圾数据
  • 第二步清除,清除未标记的非活动对象


    mm06.gif

优点:可以回收循环引用的对象

存在问题:内存碎片化

mm07.jpg

从上图中可以看出,如果对一块内存进行多次的标记清除算法,就会产生大量的内存碎片,这样会导致如果有一个对象需要一块大的连续的内存出现内存不足的情况。
为了解决这个问题,于是又引入了另一种算法:标记整理算法

标记整理算法

核心思想:可以看作是算法的增强,标记阶段操作和标记清除算法一致,清除阶段会先执行整理,先将所有存活的对象向一端移动,然后直接清理掉这一端以外的内存。
如下图所示

mm08.jpg

优点:解决了碎片化问题

存在的问题:需移动对象所以比较慢。

垃圾回收在主线程执行,而JS引擎采用单主线程运行机制,因此,一旦执行垃圾回收算法,需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕之后再恢复脚本执行,
如果垃圾回收执行时间比较长,如果此时浏览器正在执行频繁渲染和交互的操作(如动画),势必会造成页面的卡顿,下面会讲V8中如何优化该问题。

3、V8垃圾回收机制

V8内存设限:64位操作系统默认使用1.4G,32位操作系统默认使用0.7G

  • 表层原因是V8最初为浏览器设计,不太可能会遇到使用大量内存的场景
  • 深层原因是V8垃圾回收机制的限制(根据官方说法,对于1.5G的垃圾回收堆内存,V8做一次增量垃圾回收需要50ms,做一次非增量式的垃圾回收在1s以上)
mm10.jpg

V8采用分代回收,把堆内存空间分为新生代和老生代,如下图所示,新老生生代因为使用场景不同采用不同的垃圾回收算法,下面分别展开叙述。

mm09.jpg

3.1 新生代存储区

新生代存储区用来存放存活时间较短且较小的对象

采用Scavenge算法,如下图

  • 内存分为两个等大的空间(from对象空间和To空闲空间)
  • 活动对象存储于From空间
  • 当From空间快被写满时,就需要执行一次垃圾清理操作
  • 先标记,然后将活动对象拷贝至To中连续空间
  • From与To交换空间完成释放
mm11.jpg

新生代空间大小:64位系统:32MB;32位系统:16MB。
每次执行清理操作时,都需要将存活的对象从From对象区域复制到To空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。

对象晋升

  • 移动那些经过两次垃圾回收依然还存活的对象到老生代中。
  • 当对象从from复制到to空间时,如果to空间占用已超过25%,则将直接晋升这个对象。

垃圾回收算法对比

特性 标记清除 标记整理 Scavenge
速度 最慢 最快(无需移动对象,非活动对象无需分别释放)
空间开销 少(有碎片) 少(无碎片) 双倍空间(无碎片)
是否需移动对象

Scavenge只使用内存的一半,是一种典型的空间换时间的算法。对于新生代,对象的周期都比较短,因此非常适合Scavenge
而对于老生代,存活对象占较大比重,继续采用Scavenge不但浪费空间,而且复制的效率也很低。

3.2 老生代存储区

老生代存储区的对象一般有两个特点

  • 对象占用空间大(一些大的对象会直接被分配到老生代里)
  • 对象存活时间长

老生代使用标记整理和标记清除相结合代算法,标记整理算法由于需要移动对象,执行速度不会太快,所以在取舍上,V8主要使用标记清除算法,当碎片化较多内存不足以分配时才会采用标记整理算法。

存在的问题

上面有讲到垃圾回收在主线程执行,会暂停主线程上的其他任务,称为全停顿,垃圾回收时间不宜太长10ms以内,因为16ms就会出现丢帧,频繁和长时间的GC会造成页面卡顿,用户体验不佳。
打个比方,200ms,那么在这200ms内,主线程是没有办法进行其他工作的,动画也就无法执行,这样就会造成页面卡顿的现象出现。

mm12.png

提高垃圾回收效率方法

为了解决全停顿造成的用户体验问题,V8 团队向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,并且也已经取得了一些成效,下面分别介绍。

增量回收

所谓增量是指将一个较长的任务拆分成多个小任务,增量回收将之前一次性标记工作拆分为更小的块增量标记,穿插在主线程不同的任务之间执行。如下图所示

mm13.png

并行回收

并行回收开启多个协助线程,将标记、移动对象等任务转移到到后台辅助线程进行,标记完成后再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。
另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行。大大减少了主线程暂停时间,如下图所示

mm14.png

上面用到的三幅图引自References中的[8],如有侵权可联系删除

4、常见内存泄露

4.1 GC不能解决的问题

  • GC自动回收不再使用的内存是一个近似的过程,要知道是否仍然需要某块内存是无法准确判定的。
  • GC对于我们像一个黑盒,我们不知道GC 什么时候会进行, 这意味着如果我们在使用过程中使用了大量的内存, 而 GC 没有运行的情况下, 或者 GC 无法回收这些内存的情况下, 程序就有可能假死, 这个就需要我们在程序中手动做一些操作来触发内存回收.

内存泄露,由于错误的编码,不再被需要的内存未能使得GC正确的将这些内存回收的情况

4.2 内存泄露之没有完全切断与GC root之间的路径

下面几种常见的内存泄露主要是因为没有完全切断与GC root之间的路径,即从GC root可达造成的

意外的全局变量

全局变量生命周期最长,直到页面关闭才能被回收,全局变量使用不当,没有及时回收(手动赋值null),就发生了内存泄露。

//写法1:函数内变量未声明
function foo(arg) {
    bar = "some ";
}

//写法2:this使用不当
function foo() {
    this.var1 = "potential accidental global";
}

//可通过开启严格模式避免

遗忘的定时器

setTimeout 和 setInterval 是由浏览器专门线程来维护它的生命周期,所以当在某个页面使用了定时器,当该页面销毁时,没有手动去释放清理这些定时器的话,那么这些定时器还是存活着的
定时器的生命周期并不挂靠在页面上,即使页面销毁了,由于定时器回调持有该页面部分引用而造成页面无法正常被回收,从而导致内存泄漏了。

游离的DOM引用

DOM 元素的生命周期正常是取决于是否挂载在 DOM 树上,当从 DOM 树上移除时,也就可以被销毁回收了。
如果某个 DOM 元素,在 js 中也持有它的引用时,那么它的生命周期就由 js 和是否在 DOM 树上两者决定了,记得移除时,两个地方都需要去清理才能正常回收它。

如下示例中,虽然tree节点已经从 DOM 树上移除且变量引用 treeRef 置为null,但其下各个结节仍不能被移除,这是因为leafRef还持有引用,通过节点之间但引用关系依然是可达的。

var select = document.querySelector;
var treeRef = select("#tree"); 
var leafRef = select("#leaf"); 
var body = select("body");
body.removeChild(treeRef);
treeRef = null;
mm15.jpg

使用不当的闭包

函数本身会持有它定义时所在的词法环境的引用,但通常情况下,使用完函数后,该函数所申请的内存都会被回收了,
当函数内再返回一个函数时,由于返回的函数持有外部函数的词法环境,而返回的函数又被其他生命周期东西所持有,导致外部函数虽然执行完了,但内存却无法被回收,
返回的函数,它的生命周期应尽量不宜过长,方便该闭包能够及时被回收。

正常来说,闭包并不是内存泄漏,因为这种持有外部函数词法环境本就是闭包的特性,就是为了让这块内存不被回收,因为可能在未来还需要用到,但这无疑会造成内存的消耗,
所以,不宜烂用就是了

4.3 内存泄露之过度占用了内存空间

无节制的循环

while(true) {
    // do sth
}

过大的数组

var arr = []; 
for (var i=0; i< 100000000000; i++) {
    var a = { 'desc': 'an object’ } 
   arr.push(a); 
}

5、内存分析工具

上面我们介绍了内存泄露,如果发生了内存泄露,如何发现呢?下面简单介绍浏览器端Chrome浏览器常用的内存分析工具。

  • Chrome Task Manager工具
  • Chrome DevTools Performance面板
  • Chrome DevTools Memory面板

5.1 Chrome Task Manager工具

  • 入口:Chrome 浏览器右上角三个点 -> 更多工具 -> 任务管理器
  • 右键表头 -> 勾选内存占用空间和JS使用的内存
mm16.jpg

如上图,重点关注图中红框中两列

  • 内存占用空间:表示原生内存。DOM节点存储在原生内存中
  • JS使用的内存:表示JS堆。此列包含两个值,需要关注的是实时值(括号中的数值)。如果该数值在增大,要么是正在创建新对象,要么是现有对象正在增长

适用场景:粗略查看内存情况

5.2 Chrome DevTools Performance面板

  • 入口: DevTools的Performance面板,然后勾选Memory
mm17.jpg

如上图所示,关注红框中圈出部分,看对应图表如果持续增长,则可能有内存泄露,可以关注增长时的快照,大概定位内存泄露的时机。

适用场景:各个时间节点快照

5.3 Chrome DevTools Memory面板

  • 入口: DevTools的Memory面板,然后选择Heap snapshot
mm18.jpg

适用场景:可以定位到具体的变量和函数

5.4 内存泄露示例代码

下面是一个内存泄露比较严重的测试代码,示例中Test函数中有个cache内部变量,被 setInterval 定时器引用,每隔1毫秒生成一个新的对象放入cache中,
并且新生成的对象中的 junk 变量又浅拷贝了cache变量,如果不清除定时器,只是把Test类的实例手动设为null,也无济于事,cache还会继续占用内存。

把下面代码粘贴到一个空白html文件中,点击【开始测试】按钮,内存采用以上三种工具查看增长都很明显,如果不及时清除定时器,页面很快会崩溃卡死。
感兴趣的可以自己试下,这里不再详细讲述。




    
    Test





总结

本文简单讲述了一下JS内存管理的必要性以及常见垃圾回收算法,并以V8引擎为例简单阐述了各种回收算法的应用,以及V8作出的改进。
然后介绍了常见的内存泄露以及Chrome提供的内存泄露检测工具。

文中如有偏差或者遗漏,欢迎大佬指正。

References

[1] MDN内存管理定义
[2] 单页面应用下的JS内存管理
[3] [译] JavaScript 工作原理:内存管理 + 处理常见的4种内存泄漏
[4] V8 垃圾回收原来这么简单?
[5] Javascript执行机制(八)垃圾回收机制
[6] js的内存泄漏场景、监控以及分析
[7] Chrome 开发者工具中文文档>>修复内存问题
[8] JavaScript 内存详解 & 分析指南

你可能感兴趣的:(JS内存管理那些事)