static变量会被垃圾回收吗_一看就会的前端知识02期:垃圾回收

6645f9be3a3f57c903636b25a135ebb7.png

背景

前几天我的小徒弟问了我一个非常奇怪的问题:如何用一句话描述闭包?
我:哈?这不是我问你的面试题吗? 小徒弟:你尽管说,我肯定你答不上来~ 我:你这又是看了什么文章开窍了? 小徒弟:我跟你说啥是闭包吧,就是“在函数外,调用了函数内的函数,那么这个函数就是闭包” 我:哈哈哈,native!
小徒弟还煞有介事的指着我屏幕上的一行代码:
// 针对老IE做监听element.reloadBtn.attachEvent('onClick', () => {    this.reloadQRCode();})
小徒弟:你比如说,你这个匿名函数,因为后面要交给 WebApi 回调,那他就是闭包~ 怎么样,对不对~ 我:并不对哦~ 他不是闭包,而且你知道闭包存在什么地方吗? 小徒弟:它就是闭包呀,我不信,我也不清楚存在哪里...
对话先暂停一下,我们来看看这个回调函数后面会变成闭包吗:
<body>   <input type="button" value="ClickMe">   <script>       function addEvent() {           let a = 'So AmazingJs!';           document.querySelector('input[type="button"]')              .addEventListener('click', () => {               // 这一行是生成闭包的关键               console.log(a);               debugger          })      }       addEvent();script>body>
很简单,我们在页面创建一个button,然后我们通过 addEvent 函数给它绑定一个click监听,在Chrome看看效果: b74c6942dbc338b063b9aa245defde02.png 注意看我们的闭包(Closure)是啥?不是这个被绑定的函数哦,而是他的父函数(当然也不一定都是父函数),这个引用了父作用域的a变量才是生成闭包的关键,你绑定一个空的 function 也不会生成闭包。 现在我可以先用一句话告诉你闭包是什么: 当函数A使用了另一个函数B作用域内的变量,且A被其他变量引用时,B将会把A用到的数据生成一个闭包。 不过闭包到底被chrome 存放在什么地方呢,他的生成和销毁的过程是怎样的?这个问题我们可专门开一节文章来讲。
// 经过一番交流后 小徒弟:所以闭包用不好就非常容易引起内存泄露吗?JS的垃圾回收是怎样的机制,这些内存为什么不能被GC呢? 我:Emm... 这东西可有的说了...

内存和内存管理

几乎所有的语言都有自己的内存管理机制,比如C语言就有 malloc()free() 两个方法用来分配和释放内存。似乎把内存的所有操作权限都暴露给程序员,我们在写代码的时候才会感觉安全。但是你会发现,当你开始关注内存的分配时,为了使之前分配的内存能够在未来有效释放,堆的内存管理会非常非常复杂。一旦内存耗尽时,仅靠几个指针完成回收是非常非常困难的。 与之相反的,我们前端程序员似乎从来不会关注垃圾和内存,仿佛我们浏览器的内存永远用不完(狗头),而且一旦我们发现网页卡顿,通常都会随口说一句:这Chrome可太吃内存了,我电脑带不动呀!

什么是内存

开始之前,我先简单讨论一下什么是内存,以及它是如何工作的。 从硬件层面来讲,计算机内存是由大量的触发器( flip flops )组成的。每一个触发器都是由一些晶体管结构组成,能够存储1比特(1bit / 1b)。单个触发器可通过一个唯一标识符来寻址,这样我们就可以读和写了。因此从概念上讲,我们可以把计算机内存看作是一个巨大的比特数组,我们可以对它进行读和写。 但是作为人类,我们并不善于用比特来思考和运算,因此我们将其组成更大些的分组,这样我们就可以用来表示数字。比如8个比特就是一个字节(1byte / 1B = 8b)。 有很多东西都存储在内存中:
  1. 所有被程序使用的变量和其他数据

  2. 程序的代码,包括操作系统自身的代码

当你编译你的代码时(例如C语言),编译器可以检查原始的数据类型并且提前计算出将会需要多少内存。然后把所需的内存容量分配给调用栈空间中的程序。 这些变量因为函数被调用而分配到的空间被称为 堆栈空间 ,如果调用它的程序完成执行,它们不再被需要,那就会按照 LIFO的顺序被移除。例如:
// 4 bytesint n;// array of 4 elements, each 4 bytesint x[4];// 8 bytesdouble m;
编译器编译后,马上就知道这段代码需要 4 + 4 × 4 + 8 = 28 字节。 当然,编译器也知道每个变量的精确的内存地址。其实我们使用的所有变量,最后都会被翻译为:“内存地址 4127963 ”。 上面的代码最后会得到一块内存: d6f6f49b43ee36e7783cbf92a5d6d133.png (图自:blog.sessionstack.com) 当然,C语言允许我们直接通过内存地址偏移量来操作内存,这有利有弊,比如我直接访问 x[4] ,就会访问到 double m 的数据了,这就是越界。我还能直接覆盖这里面的数据,那 m 的数据便被污染了。所以内存一旦操作不当,将会引起非常严重的后果。

动态分配

那么 JS 可以像 C 语言一样这么灵活自由的操作内存吗?答案是不能,而且不仅 JS 不能,像 PHP、Perl 等动态、弱类型的语言,都不支持。
  • 静态语言:在声明变量之前需要先定义变量类型。且一旦声明类型,这个变量的类型就不允许改变。我们把这种在使用之前就需要确认其变量数据类型的称为静态语言。

  • 动态语言:相反地,我们把在运行过程中需要检查数据类型,且类型可以随意变动的语言称为动态语言。

  • 弱类型:你不需要告诉 JS 引擎变量是什么数据类型,JS 引擎在运行代码的时候自己会计算出来。

  • 强类型:在声明变量之前需要先定义变量类型。

V8的内存方案

Node/JS 程序运行时,此进程占用的所有内存称为 常驻内存 (Resident Set)。常驻内存由以下部分组成:
  1. 代码区(Code Segment):存放即将执行的代码片段

  2. (Stack):存放局部变量值或堆的指针

  3. (Heap):存放大对象(Object)、闭包(Closure)、执行上下文(Context)

  4. 堆外内存(Used Heap):不通过V8分配,也不受V8管理。Buffer对象的数据就存放于此。

cf428074059cc18984a325ee5ce7bb07.png 除了堆外内存,堆内存、栈内存和代码区均由V8管理。
  • 栈(Stack)的分配与回收非常直接,当程序执行完从执行栈弹出后,其数据栈指针(ESP)下移,整个作用域的局部变量都会出栈,内存收回。

  • 堆(Heap)内存的管理,V8采用了垃圾回收机制进行管理,也是开发中可能造成内存泄漏的部分,是程序员的关注点,也是本文的探讨点。

V8 的垃圾回收

在研究V8的GC之前,还有一个在此之前(滑稽)

V8堆的构成

V8将堆分为了几个不同的区域:
  • 新生区(New-space):大多数对象被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立。

  • 老生指针区(Old-pointer-space):这里包含大多数可能存在指向其他对象的指针的对象。大多数在新生区存活一段时间之后的对象都会被挪到这里。

  • 老生数据区(Old-data-space):这里存放只包含原始数据的对象(这些对象没有指向其他对象的指针)。字符串、封箱的数字以及未封箱的双精度数字数组,在新生区存活一段时间后会被移动到这里。

  • 大对象区(Large-object-space):这里存放体积超越其他区大小的对象。每个对象有自己mmap产生的内存。垃圾回收器从不移动大对象。

  • 代码区(Code-space):代码对象,也就是包含JIT之后指令的对象,会被分配到这里。这是唯一拥有执行权限的内存区(不过如果代码对象因过大而放在大对象区,则该大对象所对应的内存也是可执行的。注意:大对象内存区本身不是可执行的内存区)。

  • Cell区、属性Cell区、Map区(Cell-space, property-cell-space and map-space):这些区域存放Cell、属性Cell和Map,每个区域因为都是存放相同大小的元素,因此内存结构很简单。

59f4453e96cc632a2542bbdfc6bbf403.png 每个区域都由一组内存页(Page)构成。内存页是一块连续的内存,经 mmap 由操作系统分配而来。除大对象区的内存页较大之外,每个区的内存页都是1MB大小,且按1MB内存对齐。除了存储对象,内存页还含有一个 页头 (header)(包含一些元数据和标识信息)以及一个位图区(marking bitmap)(用以标记哪些对象是活跃的)。另外,每个内存页还有一个单独分配在另外内存区的槽缓冲区,里面放着一组对象,这些对象可能指向其他存储在该页的对象。 有了这些背景知识,我们可以来深入垃圾回收器了。

V8的垃圾回收

默认情况下,64位系统环境下,V8引擎的新生代内存大小32MB、老生代内存大小为1400MB;而32位则减半,分别为16MB和700MB。V8内存的最大保留空间分别为1464MB(64位)和732MB(32位)。具体的计算公式是 4*reserved_semispace_space_ + max_old_generation_size_ ,新生代由两块 reserved_semispace_space_ 组成,每块16MB(64位)或8MB(32位) 我们可以通过Node的启动命令更改V8为堆设置的内存上限:
//更改老年代堆内存--max-old-space-size=3000 // 单位为MB// 更改新生代堆内存--max-new-space-size=1024 // 单位为KB
新生区的垃圾回收原理
默认情况下,V8在执行代码时, 新对象都会被分配到新生区内存中 ,当新生区空间不足以分配新对象时,将触发新生区的垃圾回收。 新生区使用 Scavenge 算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法。Cheney算法是一种采用 复制(copy) 的方式实现的垃圾回收算法。 3021adf7c0122194174e5f6d717f6adf.png Cheney 算法将内存一分为二,每一部分空间称为 SemiSpace。在这两个 SemiSpace 中,一个处于使用状态,另一个处于闲置状态。处于使用状态的SemiSpace 空间称为From空间,处于闲置状态的空间称为To空间,当我们分配对象时,会在From空间中进行分配。 当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中,而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。
如何检测存活? 还记得之前内存页的位图区marking bitmap 吗?稍后会讲如何通过它检测对象是否存活
新生区依次进行过垃圾回收后,原来From里可能还会存在一些数据,但下一次直接当做没有,覆写就行。 9730f939679aa896d160c28f8e161e1d.png 可以很容易看出来,使用Cheney算法时,新生区总有一半的内存是空的。但是由于新生区所占内存很小,所以浪费的空间并不大。而且由于新生区中的对象绝大部分都是非活跃对象,需要复制的活跃对象比例很小,所以其时间效率十分理想。复制的过程采用的是BFS(广度优先遍历)的思想,从根对象出发,广度优先遍历所有能到达的对象,下面我们通过一个例子来看看具体的过程: 假设,我们现在需要触发一次新生区的垃圾回收,触发时,已经有一些内存不再使用: 2bb392880628e932a9294194e133d880.png 此时的内存分配是: 61d0b24defef22ad22f335fc6603a3d6.png 接下来,我们用两个指针开始在 To 区做 From 区的拷贝:
  • allocationPtr:内存分配指针,始终指向 To 区接下来可用的内存地址的偏移量,写入新数据后递增

  • scanPtr:From区的数据指针,稍后会逐个开始通过这个指针读取数据

589e62cb1bd7e5d70b37c5e6fb6c1af8.png 上面已经讲到了,Cheney算法是一种广度优先的算法(就是有向图的广度优先遍历),因此在第一层,会依次检查 A、B、C三个对象是否还活着。如果活着,就拷贝到 To 区。 经过第一层检查,我们To区已经有A、C两个存活的对象副本: 0204bab4df0a20a06c3fc63dec64ed1c.png 接下来依次遍历A、C这两个存活节点的子节点。又拷贝了D和E到To区。递归结束即为结束。 7b5e948d06e96500b32769c797fcba16.png 遍历结束后,即可翻转,From的内存情况就是这样了: f9ae75014fe98768f92597d5ea5b3300.png 当然,有两种情况不会将对象复制到To空间,而是晋升至老生区:
  1. 对象此前已经经历过2次(有文献写是1次)新生区垃圾回收,这次依旧应该存活

  2. To空间已经使用了25%(目的是为了被翻转为From后依然有足够的空间分配新的内存)

  3. 某些大的对象直接会被分配到老生区

老生区的垃圾回收原理

老生区保存的对象大多数是生存周期很长的甚至是常驻内存的对象,在老生区中的对象,至少都已经历过2次甚至更多次垃圾回收,相对于新生区中的对象,它们有更大的概率继续存活,只有相对少数的对象面临死亡。 而且老生区占用的内存较多,大约是新生区的 700 倍(64位为1.4GB,32位为700MB),如果使用 Scavenge 算法,浪费一半空间不说,检索和复制如此大块的内存消耗时间将会相当长。所以 Scavenge 算法显然不适合。V8在老生区中的垃圾回收策略采用 Mark-Sweep 和 Mark-Compact 相结合的方式。 Mark-Sweep(标记清除) 标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段总,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高 标记清除有一个问题就是进行一次标记清除后,内存空间往往是不连续的,会出现很多的内存碎片。 Mark-Compact(标记整理) 标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端。在整理的过程中,将活着的对象向内存区的一端移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片。 这两种方式并非互相替代关系,而是配合关系,在不同情况下,选择不同方式,交替配合以提高回收效率。大多数情况下V8会使用标记清除算法,当空间碎片不足以安放新晋升的老生对象时,才会触发标记整理算法。
标记清除/整理算法
标记清除和标记整理都分为两个阶段:标记阶段、清除/整理阶段 在标记阶段,所有堆上的活跃对象都会被标记。每个内存页有一个用来标记对象的位图。 位图中的每一位对应内存页中一个可分配的内存单元。这个标记非常有必要,因为对象可以存储在任何和字对齐的偏移量的内存上(原文:This is necessary since objects can start at any word-aligned offset)。几乎所有的内存管理机制都需要有这样的位图机制。这个位图需要占据内存页一定的空间(32位下为3.1%,64位为1.6%) 另外在位图对象位的内存单元旁边,还有两位用来标记对象的状态,这个状态一共有三种:白,灰,黑:
  1. 如果一个对象为白对象,它还没未被垃圾回收器发现

  2. 如果一个对象为灰对象,它已经被垃圾回收器发现,但其邻接对象尚未全部处理

  3. 如果一个对象为黑对象,说明他已经被垃圾回收器发现,其邻接对象也全部被处理完毕了

如果将堆中的对象看作由指针相互联系的有向图,标记算法的核心实际是深度优先搜索。在标记的初期,位图是空的,所有对象也都是白的。从根可达的对象会被染色为灰色,并被放入标记用的一个单独分配的标记双端队列(marking deque)。 标记阶段的每次循环,GC会将一个对象从双端队列中取出,染色为黑,然后将它的邻接对象染色为灰,并把邻接对象放入双端队列。这一过程在双端队列为空且所有对象都变黑时结束。特别大的对象,如长数组,可能会在处理时分片,以防溢出双端队列。如果双端队列溢出了,则对象仍然会被染为灰色,但不会再被放入队列(这样他们的邻接对象就没有机会再染色了)。因此当双端队列为空时,GC仍然需要扫描一次,确保所有的灰对象都成为了黑对象。对于未被染黑的灰对象,GC会将其再次放入队列,再度处理。 如何理解呢?假设我们的老生区现在有非常多的对象,需要触发垃圾回收,则会从 root 对象来开始进行对他的引用做深度优先的遍历和黑白标标记: 586dd8f42e947efa8514e2370715c3f9.png
  1. 读取 root

  2. 访问 A,将其标黑,然后将B标灰,因为第1步之后要访问 root 之后的任意一个出度(A、B、C 中的一个),但是存储时,按照了ABCDEFGHIJK 的顺序存储,因此访问 A。

  3. 依次访问并标黑 F(标灰左右相邻的内存 E、G)、H(标灰相邻的 I)

  4. 依次访问并标黑 B、G、E(标灰其左右相邻的 C)

  5. 依次访问并标黑 C、D

ae625d6270405d348915adea399a44b0.png 在上面标黑和标灰的过程中,我们发现这一次递归遍历之后,K被H染灰了,整个队列中并没有全部变成黑色。因此又会一次次的将没被标记过的,或者被标灰的遍历标记(黑色不会再遍历和标记),直到所有的节点和相邻内存都被标记为黑色。这时,所有的对象,都能被检查到,且其是否能被root引用到,也可以获取到。 这里有一份遍历和标记的伪代码:
markingDeque = []overflow = falsedef markHeap():  for root in roots:    mark(root)  do:    if overflow:      overflow = false      refillMarkingDeque()    while !markingDeque.isEmpty():      obj = markingDeque.pop()      setMarkBits(obj, BLACK)      for neighbor in neighbors(obj):        mark(neighbor)  while overflow   def mark(obj):  if markBits(obj) == WHITE:    setMarkBits(obj, GREY)    if markingDeque.isFull():      overflow = true    else:      markingDeque.push(obj)def refillMarkingDeque():  for each obj on heap:    if markBits(obj) == GREY:      markingDeque.push(obj)      if markingDeque.isFull():        overflow = true        return
不能被root获取的对象,就是死对象,GC会将其变成空闲空间,并保存到一个空闲空间的链表中。这个链表常被 Scavenge 算法用于分配被晋升对象的内存(新的对象直接覆盖这片内存就行了),当然也被标记整理算法用于移动对象。 0a2ab497351805ece7e0a51c2060cff4.png 可以看到这个链表中还包含了这块内存的大小。如果新生区需要晋升一个对象为老生区对象,且这个对象需要内存空间较多时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配, 又会触发标记整理算法的垃圾回收 。 标记整理算法会尝试将碎片页整合到一起来释放内存。由于页上的对象会被移动到新的页上,需要重新分配一些页。大致过程是,对目标碎片页中的每个活跃对象,在空闲内存链表中分配一块内存页,将该对象复制过去,并在碎片页中的该对象上写上新的内存地址。随后在迁出过程中,对象的旧地址将会被记录下来,在迁出结束后,V8会遍历所有它所记录的旧对象的地址,将其更新为新地址。由于标记过程中也记录了不同页之间的指针,这些指针在此时也会进行更新。如果一个页非常活跃,如其中有过多需要记录的指针,那么地址记录会跳过它,等到下一轮垃圾回收进行处理。
全停顿
好了,垃圾回收都讲完了,你知道了 V8 有新生区和老生区两种不同的垃圾回收器,不过由于 JS 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JS 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。 比如堆中的数据有 1.5GB,V8 实现一次完整的垃圾回收需要 1 秒以上的时间,这也是由于垃圾回收而引起 JavaScript 线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。 2012年年中,Google引入了两项改进来减少垃圾回收所引起的停顿,并且效果显著:增量标记和惰性清理。 增量标记允许堆的标记发生在几次5-10毫秒(移动设备)的小停顿中。增量标记在堆的大小达到一定的阈值时启用,启用之后每当一定量的内存分配后,脚本的执行就会停顿并进行一次增量标记。就像普通的标记一样,增量标记也是一个深度优先搜索,并同样采用白灰黑机制来分类对象。有兴趣可以搜索相关文献来扩充自己的知识储备,这里不再做展开讲解。

常见的内存泄露

全局变量

当引用一个未声明变量时,这个变量就会被创建在全局对象(window)中,比如:
function foo(arg) {   bar = "some text";}
等同于:
function foo(arg) {   window.bar = "some text";}
或者你在写工具库的时候,不小心通过 this 将变量暴露到了全局:
function foo() {   this.var1 = "potential accidental global";// Foo called on its own, this points to the global object}(window)// rather than being undefined.foo();
当然,你可以通过  ‘use strict’ 来避免它,如果你一定要用全局变量,请记得一定在合适的时候销毁它。

计时器或回调被遗忘

var serverData = loadData();setInterval(function() {   var renderer = document.getElementById('renderer');   if(renderer) {       renderer.innerHTML = JSON.stringify(serverData);  }}, 5000); //This will be executed every ~5 seconds.
renderer 对象可能在某一时刻被替换或移除,但是你的计时器还在笔挺的对它进行操作。不过大多数现代浏览器都会为你做这项工作:即使你忘记了解除这个监听,但是浏览器执行了一遍,发现这个function没有做任何事的时候,就会自动帮你解除计时。(真棒) 但最好当你的对象不再使用的时候,还是你手动清除掉这个计时器,比较好,我们再来看个例子:
var element = document.getElementById('launch-button');var counter = 0;function onClick(event) {  counter++;  element.innerHtml = 'text ' + counter;}element.addEventListener('click', onClick);// Do stuffelement.removeEventListener('click', onClick);element.parentNode.removeChild(element);// Now when element goes out of scope,// both element and onClick will be collected even in old browsers// that don't handle cycles well.
这个例子里 removeEventListener 是多余的,因为现代浏览器的事件监听都是绑定在这个 dom 上的,一旦 dom 移除,浏览器会自动在合适的时机处理监听。

闭包

我们会专门开一片文章讲作用域和闭包。这里举一个最经典的闭包内存泄露的例子:
var theThing = null;var replaceThing = function () { var originalThing = theThing; var unused = function () {   if (originalThing) // a reference to 'originalThing'     console.log("hi");}; theThing = {   longStr: new Array(1000000).join('*'),   someMethod: function () {     console.log("message");  }};};setInterval(replaceThing, 1000);
这里不做展开,有兴趣的自行搜索,如果比较懒,还可以等我们公众号的文章,哈哈哈

外部Dom引用

var elements = {   button: document.getElementById('button'),   image: document.getElementById('image')};function doStuff() {   elements.image.src = 'http://example.com/image_name.png';}function removeImage() {   // The image is a direct child of the body element.   document.body.removeChild(document.getElementById('image'));   // At this point, we still have a reference to #button in the   //global elements object. In other words, the button element is}
有的时候你会想在 对象中存储一些 Dom,来加速Dom的操作,但是一旦这些Dom不需要了,或者从页面中移除了,请记得一定删除你的对象里面的元素。 另外还有一个非常重要的项:如果你用JS 对象缓存了 一个表格中的一些 ,实际上你存储的是整个表格,一旦你的表格Dom被移除了,你的缓存一定要删掉,比如1W行的表格Dom,一旦忘记销毁,将会是一个非常惊人的内存泄露。好了,今天所有的内容就分享完了,怎么样,关于垃圾回收,你理解了吗?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值