JS的垃圾回收机制

1、概述

垃圾回收机制(Garbage Collection) 简称 GC。

所谓垃圾回收机制就是清理内存的方式

在JS中,我们创建变量的时候,JS引擎会自动给对象分配对应的内存空间,不需要我们手动分配。

当代码执行完毕的时候,JS引擎也会自动地将你的程序,所占用的内存清理掉。

正是因为有垃圾回收机制,才导致了开发者有着不用关心内存管理的错误感觉。


2、内存的分配机制

JS数据类型分为两种:

  • 基本数据类型
  • 引用数据类型

基本数据类型保存在固定的栈内存中,可以直接访问它的值。

引用数据类型,其引用地址保存在栈内存中,引用所指向的值保存在堆内存中,需要通过引用地址去访问它的值。

存储在栈内存中的基本数据类型,可以直接通过操作系统进行处理。

而堆内存中的引用数据类型的值,大小并不确定,因此需要JS引擎的垃圾回收机制进行处理。


3、内存的回收机制

在浏览器的发展历史上对于垃圾回收有两种解决策略:

  • 标记清除法
    • 从2012年起,所有浏览器都使用了标记清除法。
    • 目前主流浏览器都是使用标记清除式的垃圾回收策略,只不过收集的间隔有所不同。
  • 引用计数法
    • JS引擎很早之前使用过这种策略回收内存。
    • 其核心思想为:将不再被引用的对象(零引用)作为垃圾回收,需要提醒的是,这种策略由于存在很多问题,目前逐渐被弃用了。

3.1、标记清除算法

该策略分为Mark(标记)Sweep(清除)两个阶段。

Mark阶段:

  • 运行时,将内存中的所有变量标记为0(垃圾)
  • 从各个根对象遍历,将非垃圾变量标记为1

Sweep阶段:

  • 将所有标记为0的变量的内存释放
function fn(a){ // 开始执行此函数时,将其作用域中a、B以及匿名函数标记为0
	alert(a) // 0
	let B = new Object() // 0
	return function (){ // 由于这里return出去会被其他变量引用,故标记变为1
		altert(B) // 由于这里的闭包,B的标记变为1
	}
	... // 执行函数完毕,销毁作用域,在某个GC回收循环时会清理标记为0的变量a,B和匿名函数被保留了下来即非垃圾变量
}

let fn2 = fn(new Object()) 
// 补充一下:fn和fn2作为window.fn和window.fn2,标记一直为1,仅仅当手动设置fn=null和fn2=null才会标记为0

使用标记清除法的优点在于简单,无非是标记和不标记的差异。

但是,通过标记清除之后,剩下没有被释放的对象在内存中的位置是不变的,这就会导致空闲内存是不连续的,这就造成了内存碎片问题

如果之后需要存储一个新的,需要占据较大连续内存空间的对象的时候,就会造成影响。

标记整理算法

它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存。

image

3.2、引用计数算法

该策略的处理过程如下:

  • 当声明一个引用类型并赋值给变量时,这个值的引用次数初始为1
  • 如果该值又被赋值给另一个变量,引用次数+1
  • 如果该变量的被其他值覆盖了,引用次数-1
  • 当这个值引用次数变为0时,说明该值不再被引用,垃圾回收器会在运行时清理释放其内存

代码如下:

let a = new Object() // 引用次数初始化为1
let b = a // 引用次数2,即obj被a和b引用
a=null // 引用次数1
b=null // 引用次数0,
... // GC回收此引用类型在堆空间中所占的内存

但是存在一些问题,例如最常见的是循环引用现象:

function fn(){ // fn引用次数为1,因为window.fn = fn,会在window=null即浏览器关闭时回收
	let A = new Object() // A: 1
	let B = new Object() // B: 1
	A.b = B // B: 2
	B.a = A // A: 2
}
// A对象中引用了B,B对象中引用了A,两者引用计数都不为0,永远不会被回收。
// 若执行无限多次fn,那么内存将会被占满,程序宕机
fn();
// 还有就是这种方法需要一个计数器,这个计数器可能要占据很大的位置,因为我们无法知道被引用数量的多少。

若是采用标记清除策略则会在fn执行完毕后,作用域销毁,将域中的A和B变量标记为0以便GC回收内存,不会存在这种问题。


4、V8对GC的优化

现在大部分浏览器JS引擎都采用标记清理策略来实现垃圾回收机制,但是又各自基于此策略又进行了不同的优化,这里主要来看Chrome的JS引擎V8对此进行的优化。

4.1、分代式回收

标记清理策略在每次垃圾回收前都要检测内存中所有的对象标记是否为0来作为是否回收的依据,若一些大、老、存活长的对象(老生代)与小、新、存活时间短的对象(新生代)采用一个频率检查的话将会消耗很大的性能,故要区别对待。

所以V8采用分代的方式进行垃圾回收,对前者使用老生代GC(清理频率低),后者采用新生代GC(清理频率高)

4.1.1、内存存储分代

由于V8的GC策略主要是基于分代,故V8存储变量的方式也是分代的,将堆空间开辟为新生代和老生代

  • 新生代:堆空间内存空间小(1~8MB),对应GC算法Scavenge效率高。

  • 老生代:堆空间内存较大,对应GC算法Mark-Compact-Sweep效率低些

4.2、并行回收

V8主要是采用分代式回收,但是对于老生代使用Mark-Compact-Sweep性能还是有提升空间,故又对老生代垃圾回收机制采用并行回收进行优化。

为什么要并行回收呢?

这是由于JS是单线程的,运行在主线程上,在GC回收也是运行在主线程中,这会造成JS脚本暂时堵塞,在GC回收完毕才会恢复脚本运行,这种现象叫作全停顿(Stop-To-World)。

所以为了加快GC回收,V8引擎引入了并行回收,即并行开启多个辅助线程,协同完成GC回收工作。

4.3、并发回收

采用并行回收还是存在一个问题,那就是它还是多多少少造成JS脚本堵塞,并未从根本上解决问题,所以又提出了并发回收机制。

GC回收完全在辅助线程中进行,不占用主线程,丝毫不会导致JS脚本挂起,这就是并发的好处

但是要实现很难,因为主线程在执行 JavaScript 时,堆中的对象引用关系随时可能变化,这时辅助线程之前做的一些标记或者正在进行的标记就会改变,所以需要额外实现一些读写锁机制来控制,具体怎么搞又是一个深入的话题。


学习参考:

JS内存管理与垃圾回收机制

关于V8对GC的优化,更加详细的部分可以前往这篇文章查看。

posted @ 2022-11-16 13:58  笔下洛璃  阅读(2380)  评论(0编辑  收藏  举报