JavaScript的垃圾回收机制

1.概述

1.1 什么是程序中的垃圾?

简单来说,垃圾就是不需要,已经没用的东西。对应程序中,就是当一个变量没有被其他变量或属性引用的时候,该变量就是程序中的垃圾了。那变量又分局部变量和全局变量,它们的生命周期是不同的,释放内存(垃圾回收)的判断也是不同的。

1.2 变量的生命周期

(1)全局变量:
描述:定义在所有函数之外的变量。
生命周期:会持续到浏览器关闭页面

(2)局部变量:
描述:定义在某个函数中的变量
生命周期:函数执行过后就结束了,此时便可释放它们占用的内存(垃圾回收)

1.3 数据存储

在Javascript中,基本数据类型存储在栈内存中,引用数据类型存储在堆内存中,但是引用数据类型会在栈内存中存储一个实际对象的引用。示例如下:

let num = 12;
let obj = {name:'shier', age:'12'};
obj = [1,2,3,4];

变量obj和name的存储方式如下图:
在这里插入图片描述

如上图中所示粉色块内数据没有被引用变成了垃圾。

1.4 为什么需要垃圾回收

由于 JavaScript 是动态分配内存的语言,当创建变量、对象、函数等时,都会占用一定的内存。如果这些占用的内存长期不被释放,就会造成内存泄漏,最终可能导致程序崩溃或性能问题。因此,垃圾回收的核心作用就是自动检测不再使用的内存并释放它们,以保持程序的高效运行。

1.5 什么是垃圾回收

垃圾回收机制也称Garbage Collection简称GC。在JavaScript中拥有自动的垃圾回收机制,通过一些回收算法,找出不再使用引用的变量或属性,由JS引擎按照固定时间间隔周期性的释放其所占的内存空间。在C/C++中需要程序员手动完成垃圾回收。

1.6 垃圾回收机制会回收哪些垃圾?

当一个对象不在任何地方被引用时(如何判断是否被引用由算法决定,后面会介绍算法),垃圾回收器就需要将这些对象进行标记,并在适当的时机回收它们的内存

以下面这段代码为例:

function myFunction() {
  const obj = { foo: "bar" }
  // do something
}

myFunction()

这段代码中的 obj 对象在 myFunction 函数执行之后,就已经没有被引用了,就需要被回收。

但当某个对象从开发角度上来说不再被使用了,却意外的仍然在某个地方被引用,垃圾回收器就无法回收它的内存,就会造成内存泄漏(内存逐渐累积,程序占用的内存越来越多,当超过系统的可用内存时,就会造成程序崩溃)。

比如下面这段代码中 obj 对象就有可能不会被回收(取决于算法):

function myFunction() {
  const obj = { foo: "bar" }

  setTimeout(() => {
    console.log(obj.foo)
  }, 1000)
}

myFunction()

因为 obj 作为闭包中的引用传递给了定时器的回调函数,即使 myFunction 执行完毕,由于定时器没有被清除,obj 仍然被定时器回调函数持有引用,就可能导致 obj 不会被垃圾回收。

2.垃圾回收策略

垃圾回收并不是实时的,因为开销比较大,所以垃圾回收器会周期性的释放程序中已经不在被引用的垃圾对象。 那如何判断哪些被引用了,哪些不再被引用?通常会使用以下方法来进行判断。

2.1 引用计数法

该算法的原理是,记录每个对象的引用次数,引用增加时计数器加一,引用减少时减一,当引用计数变为零时,就表示没有任何引用指向该对象了,该对象就可以被回收。

	let a = {
	    name: "江流",
	    age: 20
		};    	//此时该对象的引用计数标记为1(a 引用)
	let b = a;	//此时对象的引用计数标记为2(a、b 引用)
	a = null;	//此时对象的引用计数标记为1((b 引用))
	b = null;	//此时对象的引用计数标记为0(无变量引用)
	... 		//等待GC 回收此对象

但是这种方式有个很严重的问题 – 循环引用:

在一个函数中,对象a的属性指向对象b,对象b的属性指向对象a,这个函数在执行完,对象a和b的计数器也不会为0,影响了正常的GC。

function Person(){
	let a={};
	let b={};
	a.prop = b;
	b.prop = a;
}

引用计数法的缺点:

  • 计数器的增减处理频繁,会导致空间的使用效率降低。
  • 循环引用无法收回,导致内存泄漏。

2.2 标记清除法

大概过程:

  • 垃圾收集器在运行时会给内存中所有的变量都加上一个标记,假设内存中所有的对象全部是垃圾,全部标记为0
  • 然后从各个根对象开始遍历,把不是垃圾的节点改成1
  • 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  • 最后把所有内存中对象标记修改为0,等待下一轮的垃圾回收

在这里插入图片描述
该算法解决了引用计数算法的循环引用问题:

function myFunction() {
  const a = {}
  const b = {}
  a.b = b // a 引用了 b
  b.a = a // b 引用了 a
}
myFunction()

函数 myFunction 执行之后,对象 a 和 b 从全局对象出发就无法获取了。因此,他们将会被回收。从 2012 年起,所有现代浏览器都使用了这种算法。

标记清除法的优点:

  • 实现简单,标记情况无非是打与不打的两种情况,通过二进制(0和1)就可以为其标记。
  • 能够回收循环引用的对象
  • 是v8引擎使用最多的算法。

标记清除法的缺点:
在清除垃圾之后,剩余对象的内存位置是不变的,就会导致空闲内存空间不连续。这样就出现了内存碎片,并且由于剩余空间不是整块,就存在内存分配的问题。

解决办法:标记整理法
标记整理法和标记清除法在标记阶段一致,只是整理阶段是先将被引用的对象移动到一端,然后清理掉标记的内存。
在这里插入图片描述

2.3 V8引擎的垃圾回收

Chrome 和 nodejs 都采用了谷-歌开源的 V8 引擎。V8 引擎的垃圾回收机制采用了标记-清除算法,但在此基础做了一些优化,那就是分代回收法。

v8中将内存分成了两个区,新生代和老生代。新生代对象存活时间较短,内存通常支持1~8MB。而老生代存储存活时间较长或常驻内存的对象。对于新老两块内存区域的垃圾回收频率不同,所以V8 采用了两个垃圾回收器来管控。

在这里插入图片描述

2.3.1 新生代垃圾回收

新生代垃圾回收通过Scavenge策略进行垃圾回收,在具体实现中,主要采用了一种复制式的方法Cheney算法。Cheney算法将堆内存也分为两个区,一个使用状态的空间我们称为使用区。一个处于闲置状态的空间称为空闲区。新加入的对象都会被存放到使用区,当使用区快被写满时,就执行一次垃圾回收操作。

(1)垃圾回收流程

  • 先对使用区中的活动做标记
  • 标记完成后,将使用区的活动对象复制进空闲区并进行排序
  • 将原先使用区对象占用的空间释放
  • 最后进行角色互换,把空闲区变为使用区,使用区变为空闲区

(2)新生代对象何时会到老生代?

  • 第一种情况:经过多次复制后依然存活的对象会被认为是生命周期较强的对象,会被移到老生代管理。
  • 第二种情况:如果复制一个对象到空闲区时,空闲区空间占用超过25%,那么这个对象将被移到老生代区域。原因是,当完成Scavenge回收后,空闲区转变成使用区,需继续进行内存分配,若占比过大,将会影响后续内存的分配。

(3)并行回收
Javascript是一门单线程语言,它是运行在主线程上的,而在进行垃圾回收的时候就会阻塞Javascript脚本的执行,需等待垃圾回收完毕后再恢复脚本执行,这种行为叫全停顿。那当GC时间过长就会造成页面卡顿问题。那一个人干活慢,n个人一起速度便会是一个人的n倍。程序也一样,我们可以通过引入多个辅助线程来同时处理。因此V8引入了并行回收机制。
新生代对象空间就采用并行策略。 在垃圾回收过程中,启动多个线程来负责新生代中的垃圾清理,这些线程同时将对象空间中的数据移到空闲区。由于这个过程中数据地址会发生改变,所以还需要同步更新引用这些对象的指针。

2.3.2 老生代垃圾回收

老生代数据大多是存活的对象,不需要时常清除更新,所以采用上面提到的标记清除法来进行垃圾回收。因为上面也提到标记清除后会产生大量内存碎片,所以V8就采用了上文所说的标记整理法来解决这个问题。

(1) 增量标记

并行策略虽然可以增加垃圾回收的效率,对于新生代这样存放较小对象的回收器能有很好的优化,但其实还是全停顿式的。对于存放较大对象的老生代来说,这些较大对象GC时哪怕使用并行策略依旧会消耗大量时间。所以V8对老生代进行了优化,从全停顿标记切换到了增量标记。

增量标记: 就是将一次GC分成多步小GC标记,让JS和小GC标记交替执行,直到标记完成。

问题来了,小GC标记执行完后是如何暂停去执行JS任务,而后又是如何进行下一次小GC 标记?如果执行JS任务时刚被标记好的对象引用又被修改了该当如何?V8解决这两个问题的方法分别是三色标记法和写屏障。

解决问题一:三色标记法
标记清理法区分是通过非黑即白的策略,但这样便会出现在增量标记时,内存中黑白都有,我们无法区分下一步是什么?所以采用了三色标记法,使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:黑(11),白(00),灰(10)。

  • 黑色表示对象自身及对象的引用都被标记(已检查状态)
  • 白色表示未被标记的对象(初始状态)
  • 灰色表示自身被标记,自身的引用未被标记(待检查状态)

执行流程如下:

  • 初始将所有对象都是白色
  • 从root对象开始,先将root对象标记为灰色并推入标记工作表中
  • 当收集器从标记工作表中弹出对象并访问他的所有引用对象时,自身灰色就会变成黑色。
  • 将自身的下一个引用对象标记为灰色
    一直这样执行下去,直到没有可以被标记为灰色的对象时,剩下的白色对象都是不可达的,进入清理阶段。恢复时从灰色标记对象开始执行。

解决问题二:写屏障
为了解决黑色对象在程序执行中被新添加引用或已经标记黑色的被引用对象不再被引用了。写屏障就有了以下两个变化:

  • 不对已标记的黑色对象做处理,因为在之后的GC中也会被清理。
  • Write-barrier 机制强制不变黑的对象指向白色对象。这个也被称作强三色不变性。所以一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记。
(2) 懒性清理

增量标记完后,如果当前内存足以支持代码的快速运行,也没必要立即清理,可让程序先运行,也无需一次性清理完所有垃圾对象,可以按需清理直到所有垃圾对象清理完后再继续增量标记。

(3) 并发回收

并发主要发生在工作线程上。当在工作线程(辅助线程)执行GC时,应用程序可以继续在主线程运行并不会被挂起。

这也是有问题的,因为GC也在进行,应用程序也在执行,此时对象的引用关系随时都有可能变化,所以之前做的一些标记就需要改变,所以需要读写锁机制来控制这一点。

3.垃圾回收中的内存泄漏问题

虽然 JavaScript 的垃圾回收机制极大地简化了内存管理,但某些情况下,内存泄漏仍然是开发者需要注意的问题。

3.1 常见的内存泄漏场景

3.1.1 闭包引发的内存泄漏

闭包是一种特殊的JavaScript函数,它可以访问其自身范围外的变量。闭包可以导致内存泄漏,因为它们可以保留对外部变量的引用,即使它们不再需要。

当一个函数创建一个闭包时,它会创建一个新的作用域链,其中包含了该函数的变量和所有外部函数的变量。如果闭包中包含对一个DOM元素或其他大型对象的引用,那么这个对象将一直存在于内存中,即使它不再需要。

例如,下面的代码创建了一个闭包,它保留了对一个DOM元素的引用,即使该元素被删除:

function createClosure() {
  let element = document.getElementById('myElement');
  return function() {
    //引用这个元素的代码
    ...
  };
}
let closure = createClosure();

// element still exists in memory, even if it's removed from the DOM

解决这个问题的方法是在不需要的时候手动解除对外部变量的引用。例如,在上面的代码中,可以将闭包修改为:

function createClosure() {
  let element = document.getElementById('myElement');
  return function() {
     //引用这个元素的代码
    ...
    element = null; // remove reference to element
  };
}
 
let closure = createClosure();
 
// element is no longer referenced and can be garbage collected

3.1.2 事件绑定未解绑引起的内存泄露

当一个元素绑定了一个事件处理程序,但该元素被删除或替换时,如果没有及时解绑事件处理程序,那么该处理程序将继续存在于内存中,并可能导致内存泄漏。

例如,下面的代码创建了一个按钮元素,并为其添加了一个事件处理程序:

var myButton = document.getElementById('myButton');
myButton.addEventListener('click', function() {
  // do something
});

如果在某个时刻,myButton被删除或替换了,但是事件处理程序没有被解绑,那么它将继续存在于内存中,并可能导致内存泄漏。

解决这个问题的方法是在不再需要事件处理程序时,手动解绑它。例如,可以将上面的代码修改为:

var myButton = document.getElementById('myButton');
myButton.addEventListener('click', onClick);
 
function onClick() {
  // do something
 
  // remove event listener
  myButton.removeEventListener('click', onClick);
}

3.1.3 定时器未清除引起的内存泄露

在使用JavaScript定时器时,如果不及时清除定时器,那么它将继续存在并持有内存。这是因为定时器仍然在等待计时器到达指定的时间,这可能会导致内存泄漏。

例如,下面的代码创建了一个定时器,它每秒钟执行一次:

var timer = setInterval(function() {
  // do something
}, 1000);

如果在某个时刻,不再需要该定时器,但是没有清除它,那么它将继续存在于内存中,并可能导致内存泄漏。

解决这个问题的方法是在不再需要定时器时,手动清除它。例如,可以将上面的代码修改为:

var timer = setInterval(function() {
  // do something
}, 1000);
 
// clear timer when it's no longer needed
clearInterval(timer);

3.1.4 循环引用引起的内存泄露

当两个或更多对象相互引用时,就会出现循环引用。如果其中一个对象被删除,它仍然被其他对象引用,这可能会导致内存泄漏。

例如,下面的代码创建了两个对象,并相互引用:

var obj1 = {};
var obj2 = {};
 
obj1.ref = obj2;
obj2.ref = obj1;

在这个例子中,obj1和obj2相互引用,如果这些对象不再需要,但是没有手动解除引用,那么它们将继续存在于内存中,并可能导致内存泄漏。

解决这个问题的方法是在不需要对象时,手动解除相互引用。例如,可以将上面的代码修改为:

var obj1 = {};
var obj2 = {};
 
obj1.ref = obj2;
obj2.ref = obj1;
 
// remove references to obj1 and obj2 when they're no longer needed
obj1.ref = null;
obj2.ref = null;
obj1 = null;
obj2 = null;

在这个例子中,obj1和obj2的相互引用被手动解除,并在不再需要时,将它们设置为null,从而避免内存泄漏。

总之,手动解除循环引用是避免循环引用引起的内存泄漏的关键。在编写JavaScript代码时,应该始终确保在不需要相互引用的对象时手动解除它们的引用。

3.2 Vue中的内存泄露

在Vue应用程序中,内存泄漏是一个常见的问题,它会导致应用程序变得不稳定并最终崩溃。以下是一些Vue中常见的内存泄漏问题:

  • 未销毁的组件:当组件被销毁时,Vue会自动清理与该组件相关的内存。但是,如果组件没有被正确地销毁,那么与该组件相关的内存将不会被释放。为了避免这种情况,应该在组件销毁时手动清理与该组件相关的内存。
  • 未取消的异步操作:如果在Vue组件中使用异步操作,例如通过axios库发送HTTP请求,应该在组件销毁时取消这些操作。否则,这些操作将继续运行并占用内存。
  • 事件监听器:当组件被销毁时,应该手动移除与该组件相关的所有事件监听器。否则,这些事件监听器将继续占用内存并导致内存泄漏。
  • 循环引用:循环引用是指两个或多个对象之间相互引用。在Vue中,这可能会导致内存泄漏,因为这些对象将永远无法被垃圾回收。为了避免循环引用,应该使用弱引用或手动清理对象之间的引用。

为了避免这些问题,应该始终遵循Vue的最佳实践,并在组件销毁时手动清理与该组件相关的所有内存。这可以通过Vue的生命周期钩子函数和其他工具来完成。

3.3 如何优化垃圾回收

  • 避免全局变量
    全局变量在程序的整个生命周期中都不会被回收,因此尽量避免使用全局变量,或在不再需要时将其设置为 null。
  • 合理使用闭包
    虽然闭包是 JavaScript 中强大的功能,但应避免不必要地持有对外部变量的引用,以减少内存泄漏的风险。
  • 使用弱引用
    JavaScript 提供了 WeakMap 和 WeakSet,它们不会阻止垃圾回收。使用这些数据结构可以减少内存泄漏的可能性。
  • 手动清除事件监听
  • 手动清除定时器、计时器
  • 手动调用垃圾回收
    一般情况下我们无需手动调用垃圾回收,但有些浏览器支持主动触发垃圾回收。
    //IE 浏览器
    if (typeof window.CollectGarbage === "function") {
      window.CollectGarbage()
    }
    //Opera 浏览器
    if (window.opera && typeof window.opera.collect === "function") {
     window.opera.collect()
    }
    

3.4 前端常见内存泄露检测工具

  • Chrome开发者工具:
    Chrome浏览器的开发者工具提供了内存分析工具,可以检查应用程序中所有的JavaScript对象,显示它们的引用关系、内存占用情况等信息。在Chrome开发者工具中,可以通过“Memory”选项卡来检测内存泄漏问题。
  • Heapdump:
    Heapdump是一个Node.js模块,用于生成堆快照,并提供了一个用于检查内存泄漏的Web界面。Heapdump可以在运行时捕获应用程序的堆快照,并将其保存到文件中。然后可以使用Chrome开发者工具或其他工具来分析堆快照中的数据。
  • LeakChecker:
    LeakChecker是一款基于Chrome开发者工具的内存泄漏检测工具,可以检测JavaScript应用程序中的内存泄漏问题。LeakChecker跟踪页面中所有的JavaScript对象,并生成报告以帮助用户解决问题。
  • Lighthouse:
    Lighthouse是一款由Google开发的工具,可以用于检查Web应用程序的性能和可访问性。Lighthouse提供了内存分析工具,可以检查JavaScript对象的内存占用情况,并生成报告以帮助用户解决问题。
  • DevTools Timeline:
    DevTools Timeline是Chrome开发者工具中的一个工具,可以用于检查应用程序的性能和内存使用情况。DevTools Timeline跟踪页面中所有的JavaScript对象,并显示它们的内存占用情况。使用DevTools Timeline,开发者可以检查应用程序中的内存泄漏问题,并进行优化。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

太阳与星辰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值