JavaScript 内存管理

本文主要是对 V8 之旅: 垃圾回收器JavaScript内存管理和优化 两篇文章的总结

v8 垃圾回收

垃圾回收器是一把十足的双刃剑。其好处是可以大幅简化程序的内存管理代码,因为内存管理无需程序员来操作,由此也减少了(但没有根除)长时间运转的程序的内存泄漏。对于某些程序员来说,它甚至能够提升代码的性能。

垃圾回收器要解决的最基本问题就是,辨别需要回收的内存。一旦辨别完毕,这些内存区域即可在未来的分配中重用,或者是返还给操作系统。一个对象当它不是处于活跃状态的时候它就死了。一个对象被一个根对象或另一个活跃对象指向,则处于活跃状态。根对象被定义为处于活跃状态,即浏览器或V8所引用的对象。比如说,被局部变量所指向的对象属于根对象,因为它们的栈被视为根对象;全局对象属于根对象,因为它们始终可被访问;浏览器对象,如DOM元素,也属于根对象。

堆的构成

V8将堆分为了几个不同的区域:

  • 新生区:大多数对象被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立。
  • 老生指针区:这里包含大多数可能存在指向其他对象的指针的对象。大多数在新生区存活一段时间之后的对象都会被挪到这里。
  • 老生数据区:这里存放只包含原始数据的对象(这些对象没有指向其他对象的指针)。字符串、封箱的数字以及未封箱的双精度数字数组,在新生区存活一段时间后会被移动到这里。
  • 大对象区:这里存放体积超越其他区大小的对象。每个对象有自己mmap产生的内存。垃圾回收器从不移动大对象。
  • 代码区:代码对象,也就是包含JIT之后指令的对象,会被分配到这里。这是唯一拥有执行权限的内存区(不过如果代码对象因过大而放在大对象区,则该大对象所对应的内存也是可执行的。译注:但是大对象内存区本身不是可执行的内存区)。
  • Cell区、属性Cell区、Map区:这些区域存放Cell、属性Cell和Map,每个区域因为都是存放相同大小的元素,因此内存结构很简单。

新生区垃圾回收

脚本中,绝大多数对象的生存期很短,只有某些对象的生存期较长。对象起初会被分配在新生区(通常很小,只有1-8 MB,具体根据行为来进行启发)。在新生区的内存分配非常容易:我们只需保有一个指向内存区的指针,不断根据新对象的大小对其进行递增即可。当该指针达到了新生区的末尾,就会有一次清理(小周期),清理掉新生区中不活跃的死对象。对于活跃超过2个小周期的对象,则需将其移动至老生区。老生区在标记-清除或标记-紧缩(大周期)的过程中进行回收。大周期进行的并不频繁。一次大周期通常是在移动足够多的对象至老生区后才会发生。至于足够多到底是多少,则根据老生区自身的大小和程序的动向来定。

由于清理发生的很频繁,清理必须进行的非常快速。V8中的清理过程称为Scavenge算法,是按照Cheney的算法实现的。这个算法大致是,新生区被划分为两个等大的子区:出区、入区。绝大多数内存的分配都会在出区发生(但某些特定类型的对象,如可执行的代码对象是分配在老生区的),当出区耗尽时,我们交换出区和入区(这样所有的对象都归属在入区当中),然后将入区中活跃的对象复制至出区或老生区当中。在这时我们会对活跃对象进行紧缩,以便提升Cache的内存局部性,保持内存分配的简洁快速。

具体的执行过程大致是这样:

首先将出区中所有能从根对象到达的对象复制到入区,然后维护两个入区的指针scanPtr和allocationPtr,分别指向即将 扫描的活跃对象和即将为新对象分配内存的地方,开始循环。循环的每一轮会查找当前scanPtr所指向的对象,确定对象内部的每个指针指向哪里。如果指向 老生代我们就不必考虑它了。如果指向出区,我们就需要把这个所指向的对象从出区复制到入区,具体复制的位置就是allocationPtr 所指向的位置。复制完成后将scanPtr所指对象内的指针修改为新复制对象存放的地址,并移动allocationPtr。如果一个对象内部的所有指针 都被处理完,scanPtr就会向前移动,进入下一个循环。若scanPtr和allocationPtr相遇,则说明所有的对象都已被复制完,出区剩下的都可以被视为垃圾,可以进行清理了

老生区垃圾回收

Scavenge算法对于快速回收、紧缩小片内存效果很好,但对于大片内存则消耗过大。因为Scavenge算法需要出区和入区两个区域,这对于小片内存尚可,而对于超过数MB的内存就开始变得不切实际了。老生区包含有上百MB的数据,对于这么大的区域,我们采取另外两种相互较为接近的算法:“标记-清除”算法与“标记-紧缩”算法。

标记清除

标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段总,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高

标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。

标记整理

标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端。在整理的过程中,将活着的 对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片

算法思路

标记清除和标记整理都分为两个阶段:标记阶段、清除或紧缩阶段

在标记阶段,所有堆上的活跃对象都会被标记。每个内存页有一个用来标记对象的位图,位图中的每一位对应内存页中的一个字。这个位图需要占据一定的空 间(32位下为3.1%,64位为1.6%)。另外有两位用来标记对象的状态,这个状态一共有三种(所以要两位)——白,灰,黑:
* 如果一个对象为白对象,它还没未被垃圾回收器发现
* 如果一个对象为灰对象,它已经被垃圾回收器发现,但其邻接对象尚未全部处理
* 如果一个对象为黑对象,说明他步进被垃圾回收器发现,其邻接对象也全部被处理完毕了

如果将对中的对象看做由指针做边的有向图,标记算法的核心就是深度优先搜索。在初始时,位图为空,所有的对象也都是白对象。从根对象到达的对象会背 染色为灰色,放入一个单独的双端队列中。标记阶段的每次循环,垃圾回收器都会从双端队列中取出一个对象并将其转变为黑对象,并将其邻接的对象转变为灰,然 后把其邻接对象放入双端队列。如果双端队列为空或所有对象都变成黑对象,则结束。特别大的对象,可能会在处理时进行分片,防止双端队列溢出。如果双端队列 溢出,则对象仍然会成为灰对象,但不会被放入队列中,这将导致其邻接对象无法被转变为灰对象。所以在双端队列为空时,需要扫描所有对象,如果仍有灰对象, 将它们重新放入队列中进行处理。标记结束后,所有的对象都应该非黑即白,白对象将成为垃圾,等待释放

清除和紧缩阶段都是以内存页为单位回收内存

清除时垃圾回收器会扫描连续存放的死对象,将其变成空闲空间,并保存到一个空闲空间的链表中。这个链表常被scavenge算法用于分配被晋升对象的内存,但也被紧缩算法用于移动对象

紧缩算法会尝试将碎片页整合到一起来释放内存。由于页上的对象会被移动到新的页上,需要重新分配一些页。大致过程是,对目标碎片页中的每个活跃对 象,在空闲内存链表中分配一块内存页,将该对象复制过去,并在碎片页中的该对象上写上新的内存地址。随后在迁出过程中,对象的旧地址将会被记录下来,在迁 出结束后,V8会遍历所有它所记录的旧对象的地址,将其更新为新地址。由于标记过程中也记录了不同页之间的指针,这些指针在此时也会进行更新。如果一个页 非常活跃,如其中有过多需要记录的指针,那么地址记录会跳过它,等到下一轮垃圾回收进行处理


Chrome 内存调试

查看浏览器的JavaScript的内存使用情况,一般有三种方法:

  1. 通过浏览器的任务管理器。这个可以了解各个页面的内存的使用总量,发现内存是否占用过高。
  2. chrome 的 dev-tools 里面的 Performance 。Performance 的好处是可以看到随着时间的变化,看到内存的使用的情况。通过 Performance ,我们很容易了解到 GC 操作和内存的分配,从而发现内存是否泄漏和 GC 是否频繁的问题。
  3. dev-tools 里面的 Memory 。内存快照的优点是详细的展示了某一时刻的内存的使用情况,包括:什么类型的数据占用了多大的内存,以及变量之间的引用关系。通过这些,我们就可以找到内存使用的问题所在,找到解决内存问题的方法。

注意在使用dev-tools调试的时候,最好使用隐身模式,或者禁用浏览器插件扩展程序等,因为这些插件的运行也会被dev-tools统计到,从而影响你的分析结果,增加你分析的难度。

Performance

在 chrome 运行下面代码


class obj{
  constructor(){
    this.arr = [];
    for(let i = 0;i < 100000;i++){
      this.arr.push(Math.random());
    }
  }
}

setInterval(()=>{
  let o = new obj();
},20)

记录下内存使用情况

performance

可以看到其中的 JS Heap 呈周期性涨跌。由于上面代码设置定时器时间为 20ms,也就是20ms创建一个大对象,
因此内存也是大约 20ms 左右增长一次。每次创建对象后,会触发一次 minor GC,也就是前面所说的新生区垃圾回收。但这里不会回收所有垃圾。等到垃圾积累够多,也就是老生区的内存不够时,就会触发一次 major GC,此时所以内存大幅下降。

Memory

在 Memory 可以获取 Heap 使用的快照,也可以记录一段时间的内存使用情况。

在快照面板下可以看到
snapshot

左上角的可以选择多种显示方式:

  • Summary: 通过构造函数名分类显示对象;
  • Containment: 从根节点开始,按照包含关系来显示对象;
  • statistics: 统计各个不同类型的对象占用内存的比例;

右边的 All objects 可以选择比较不同快照之间的差异。

下面的表格中可以看到各个对象的情况:

  • Distance:表示该root引用该对象的最短距离
  • Object Count: 表示数量
  • shallow Size: 表示该对象对应构造函数生成的对象直接占用的内存数,不包含引用的对象占用的内存。
  • Retained Size: 表示该对象和该对象的间接引用对象占用的内存的总数。

shallow Size和Retained Size这两个概念很重要,通常来说,只有Arrays和Stings会有比较大的shallow size。正如上面的图看到的 x1这个对象shallow size比较小,而因为包含一个比较大的string,所以Retained Size比较大。

下面看 Record Allocation Timeline

修改前面的代码

class obj{
  constructor(){
    this.arr = [];
  }

  sub(){
    if(this.arr.length > 0)
      this.arr[this.arr.length-1] = null;
  }

  add(){
    this.arr.push(new Array(50000))
  }
}

let o = new obj();

setInterval(()=>{
  if(Math.random()>0.5) 
    o.add();
  else 
    o.sub();
},200)

记录一段时间的内存分配情况

这里写图片描述

最上面的灰色和蓝色的线就是内存的分配和释放情况。这个柱子表示的在那个时刻,浏览器分配的内存,高度表示分配的大小。蓝色是指未被回收的内存,灰色是指分配了,但被回收了的内存。这样,看到蓝色的柱子,就说明你可能存在内存泄漏了。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值