js

JS的垃圾回收与内存泄漏

垃圾回收 内存泄漏

Posted by czk on June 13, 2022

JS的垃圾回收与内存泄漏

JavaScript 是一门自动垃圾回收的语言,也就是说,我们不需要去手动回收垃圾数据,但是这不代表我们可以毫不关心Js的内存回收机制和原理吗,下面简单的说一下JS的垃圾回收与内存泄漏的基础。

垃圾从哪来?

在js运行中当我们创建一个基本类型、对象都是需要占用内存的,但我们不需要显式手动的去分配内存而是交给引擎来完成。当我们不再需要某个已创建的东西时,垃圾就诞生了。

tips  由于栈内存小且联系,由系统自动分配空间并回收,所以我们常说的垃圾回收一般指堆内存的回收

垃圾产生的原因

let a = {name:czkm}
//do something...
a = [1]

img1_5r3mht_.png

在我们日常使用中比如我先声明一个变量a,然后引用了对象 {name: 'czkm'},接着我们把这个变量重新赋值了一个数组对象,之前声明的对象就没有了与他对应的引用关系,没有引用关系的对象就是垃圾

栈内存中的变量基本上用完就回收了,相比于堆来说存取速度会快,并且栈内存中的数据是可以共享的。

堆内存中的对象不会随方法的结束而销毁,就算方法结束了,这个对象也可能会被其他引用变量所引用(参数传递)。创建对象是为了反复利用(因为对象的创建成本通常较大)。只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。

为什么要进行垃圾回收?

因为程序的运行需要内存,操作系统或者运行时就必须提供内存,所以对于持续服务进程来说,必须要及时释放内存,否则,内存占用越来越高,可能导致进程的崩溃。所谓的垃圾回收其实就是释放他们的内存

垃圾回收有算法步骤?

大致可以分为以下几个步骤:

第一步 标记空间中活动对象非活动对象。也是就是标记清除算法(Mark-Sweep),其过程就像他的名字一样,所有活动对象做上标记,清除阶段则把非活动对象(没有标记)销毁。

你可能会好奇 从那个出发点开始清除呢?通常由于出发点很多,我们称之为一组 根(GC Roots) 对象,而所谓的根对象.在浏览器环境中,GC Root 有很多,通常包括了以下几种:

  • 全局的 window 对象(位于每个 iframe 中)
  • 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成
  • 栈上变量

img2_hx6xjh_.png

第二步,回收非活动对象所占据的内存,在标记完成之后,统一清理内存中所有被标记为可回收的对象

img3_9j0av5_.png

第三步,做内存整理。如果不整理就像上图,gc过后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况。 可能需要需要对空闲内存列表进行一次遍历然后返回大于等于新请求大小的地址

img4_h4a9j7_.png

这个操作本质上是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,大对象的分配效率低

img5_vck0h4_.png

向右移动的过程中整理了内存碎片但是又导致了内存地址的变更,所以在每次整理的时候都需要重新修改栈中的引用,所以每次整理完内存空间后会为每个对象返回新的数据的起始地址。

但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的v8的副垃圾回收器,也叫新生代回收器。

浏览器(V8 引擎)的垃圾回收是怎么样的?

img6_byr1bf_.png说到V8的垃圾回收就不得不说一下代际假说(The Generational Hypothesis),这个假设源自观察大量实时系统上的对象分配/解除分配。但其实也很简单

  • 第一个是大部分对象都是“朝生夕死”的(most object die young),也就是说大部分对象在内存中存活的时间很短;
  • 第二个是不死的对象,会活得更久(older onjects tend to live for a logn time),比如全局的 window、DOM、Web API 等对象。

    对于那些存活时间短对象,根据代际假说 我们首先并不需要为这种对象申请多大的空间,反正它们很快就会被回收,也不需要对他们进行碎片的整理,因为频繁的碎片整理十分消耗性能。

    而对于存活时间久的对象,他们则需要一个大的内存空间,而且不需要频繁的去对她们进行垃圾回收,因为他们几乎不会死,所以也几乎不需要整理内存

分代式回收(主/副垃圾回收器)

img7_6s3dap_.png

由上面的分析V8 中将堆内存分为新生代和老生代两区域,采用不同的策略管理垃圾回收。

新生代回收器

责新生代的垃圾回收。大多数对象都会被分配到新生代,新生代中的垃圾数据用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域 (from),一半是空闲区域 (to)

img8_bz35ie_.png

当from区快被写满时,就需要执行一次垃圾清理操作步骤大概这样

  1. 开始回收,标记from区中的活动对象
  2. 清除垃圾同时将from区中的对象复制到to区
  3. 对to区的对象有序排列(相当于内存整理) 同时进行对象反转,此时from 和to 互换

每次执行清理操作时,都会做一个复制操作,所以如果新生代空间设置得太大了,那么每次清理就会相当耗时,所以为了执行效率,一般新生区的空间会被设置得比较小,在v8中大概是1~8M,

新生所分配的空间是很小的,所以很容易满,新生代回收器一旦监控到对象满了,就会执行垃圾回收。同时,同时移动那些经过两次垃圾回收依然还存活的对象到老生代中。也就是我们常说的对象晋升

另外还有一种情况也会触发对象晋升,当复制一个对象到to区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,

老生代回收器

老生代的回收器中除了新生代中晋升的对象,还有一些直接被分配的大对象。因此,老生代中的对象正好都印证了代际假说两个特点:

  • 对象所占用空间大;
  • 对象存活时间长。

对于大多数占用空间大、存活时间长的对象会被分配到老生代里,如果和新生代一样采用复制的算法去进行垃圾回收就会非常耗时,老生代的回收方式采用了标记清除算法

从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

之后进行垃圾清理,老生代回收器 会直接将标记为垃圾的数据清理掉

标记清除算法在清除后会产生大量不连续的内存碎片 V8则采用了我们上文的标记整理算法来解决优化空间

内存泄漏

img9_949o8l_.png

本质上,内存泄漏可以定义为应用程序不再需要的内存,由于某种原因没有返回到操作系统或空闲内存池。

通俗点说,垃圾回收成功了哪就万事大吉~回收不成功就造成了内存泄漏 MMMMMKAY~

Js 中的常见的 4 种内存泄漏

1.意外的全局变量(Global variables)

function test() {
    value = "这里是全局变量";
    this.value = "这里是全局变量";
    //  window.value = "这里是全局变量"; 实际上的结果
}

如果value应该只在test这个函数范围内保存对变量的引用,而没有声明它,则会创建一个意外的全局变量。根据定义,这两个变量不会被回收(除非已清空或重新分配),这种情况我们要尽可能的避)。如果必须使用全局变量来存储大量数据,请确保将其置空或在完成后重新分配它。

var value = '这里是全局变量'

// 操作数据......

test = null

2.存在被遗忘的计时器或回调(Timers or callbacks that are forgotten)

举一个很出名的例子🌰:

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // Do stuff with node and someResource.
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

这个例子说明了setInterval会发生什么:引用不再需要的节点或数据的定时器。对象node将来可能会被删除,从而使间隔处理程序内的整个块变得不必要。

代码中每隔一秒就将得到的数据放入到 Node 节点中去,但是在 setInterval 没有结束前(调用了 clearInterval),回调函数里的变量以及回调函数本身都无法被回收。由于时间间隔仍处于活动状态,因此无法收集处理程序(需要停止时间间隔才能发生这种情况)就可能导致someResource可能存储大量数据的 ,也无法回收♻️。

同理由,setInterval/setTiemout /requestAnimationFrame都有同样的问题, 当不需要 定时器时候,应该及时调用 clearInterval/clearTimeout/cancelAnimationFrame来清除定时器

3.超出 DOM 的引用 (Out of DOM references)

在开发中如果使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放。

<body>
  <div id="button"></div>
</body>
<script>
var elements = {
    button: document.getElementById('button'),
};
document.body.removeChild(document.getElementById('button'));
// 此时 仍然再全局对象elements中引用了#button ,换句话说 button元素仍然在内存中存在
// 而不会随着GC被收集
 
</script>

4.错误的使用闭包 (Closures)

img10_kbpjhu_.png

减少使用闭包,闭包会造成内存泄漏

很多地方都能看到这句话但他其实是错的。内存泄漏常常与闭包紧紧联系在一起,很容易让人误以为闭包就会导致内存泄漏。其实闭包只是让内存常驻,而滥用闭包才会导致内存泄漏

网络上很多的回答都说闭包导致了内存泄漏其实不然,很多人看到网上说闭包会造成内存泄露,也就这么认为了。

通常网络上说的闭包造成内存泄漏 大多是只在ie8及之前会造成内存泄露。而现在正常的使用闭包已经不会造成内存泄露了。

在ie8及更早的版本中,并非所有对象都是原生js对象。BOMDOM中的对象是C++实现的组件对象模型对象,简称COM对象。

COM对象是使用引用计数实现垃圾回收的,所以,即使这些版本的ie已经使用标记清除,但js存取的COM对象依旧是使用引用计数的。所以只要涉及到了COM对象,那么就会遇到循环引用的问题

let element = document.querySelector('.test') 
let obj = new Object() 
obj.element = element 
element.obj = obj

上文说到常用的垃圾回收策略是标记清除,但在ie8那时候,使用引用计数来作为垃圾回收策略的,但因为引用计数存在一些问题,所以被弃用了,但是,闭包的内存泄漏就是由引用计数造成。既然现在的浏览器都不使用引用计数了,那么正常使用闭包当然不会造成内存泄漏

错误使用闭包例子
function fn2(){
  let test = new Array(1000).fill('test')
  return function(){
    console.log(test)
    return test
  }
}
let fn2Child = fn2()
fn2Child()
//fn2Child = null 解决方法 函数调用后,把外部的引用关系置空

return 的函数中存在函数 fn2 中的 test 变量引用,所以 test 并不会被回收,也就造成了内存泄漏。

内存泄漏和垃圾回收的机制很可能在开发中被忽视,虽然工作中不常用,但我们也应该了解内存管理(或至少是基础知识)。有时自动内存管理存在问题(例如垃圾收集器中的错误或实现限制等),开发人员必须了解这些问题才能正确处理它们。