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

7/19/2022 js

# 1.JS 内存管理

# 1.1 内存生命周期

分配内存 => 内存读写 => 释放内存

在 JS 语言中,内存分配是由 JS 引擎自动完成的,释放内存也是如此,由 JS 引擎根据垃圾回收机制进行回收,开发者能控制的是内存读写这一环节

# 1.2 两种内存存储类型

基本类型:内存是固定大小的,其值存储在栈(stack)空间中

引用类型:内存是非固定的,其值存储在堆(heap)空间中,指向堆空间中的对象的指针存储在栈空间中

不多 bb,来看代码:

let n = 7 // 在栈中给数值分配内存
let s = 'NO x ONE' // 在栈中给字符串分配内存
let b = true
let a = null

let obj = {
	// 在堆中存储新的对象,在栈中存储指向该对象的指针并赋值给obj变量,由此构成引用关系
	id: 1,
}
let obj2 = obj // 共享同一个指针
obj2 = 2 // 这里obj2不再保存指针,但是不会影响obj

let arr = [1, 2] // ...
let fn = function (a) {
	alert(a)
} // ...

# 2.垃圾回收机制

# 2.1 核心原理

GC即 Garbage Collection,垃圾回收机制。JS 引擎定期找出不再用到的内存,然后释放,至于如何实现有不同的策略,常见的有标记清除算法引用计数算法

# 2.2 标记清除算法

标记清除Mark-Sweep),大多数浏览器的 JS 引擎都在采用这种算法,只是在此基础之上又进行各自的优化加工

该策略分为MarkSweep两个阶段,过程如下:

Mark阶段

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

Sweep阶段

  • 将所有标记 0 的变量内存释放,GC

来看看代码:

let fn2 = fn(new Object())

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和匿名函数被保留了下来即非垃圾变量
}

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

但是这种策略存在内存碎片化缺陷,即释放的内存空间往往是不连续的,如下图所示:

在这里插入图片描述

这样不利于内存的回收利用,即空闲内存与非空闲内存是相交错的,不利于存储

故考虑优化在MarkSweep阶段之间再补充上整理Compact阶段

  • Mark阶段结束后,将标记 1 的变量内存往一端移动,标记 0 的变量内存往另一端移动
  • 开启Sweep阶段

优化后的策略即标记整理清除算法(Mark-Compact-Sweep),入下图所示:

在这里插入图片描述

# 2.3 引用计数算法

JS 引擎很早之前使用过这种策略回收内存,其核心思想为:将不再被引用的对象(零引用)作为垃圾回收,需要提醒的是,这种策略由于存在很多问题,目前逐渐被弃用

过程如下:

  • 当声明一个引用类型并赋值给变量时,这个值的引用次数初始为 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
  ...
  // 执行完fn函数,作用域销毁时,A和B引用次数-1,但是还不为0,
  // 在某个GC回收循环执行时不会释放其内存,存在了内存泄漏
}
fn() // 若执行无限多次fn,那么内存将会被占满,程序宕机

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

# 3.V8 对 GC 的优化

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

# 3.1 分代式回收

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

# 3.1.1 内存存储分代

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

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

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

在这里插入图片描述

这种分配是非常合理的,对于频繁回收操作,牺牲空间换取时间;对于低频回收操作,牺牲时间换取空间。此思想可以类比计算机存储分层结构,将需要读取频率高的存储在小容量、高处理的 cache 中,将读取频率低的存储在大容量、低处理的内存中

# 3.1.2 新生代 GC

对于新生代采用Scavenge算法进行垃圾回收,将新生代存储区一分为二:使用区空闲区

过程如下:

  • 程序运行时,作用域中所有变量都会存入使用区,当该区域内存写满之后就会进行一次 GC
  • 在 GC 开始前,先将使用区里的活动对象移动到空闲区非活动对象保留,随后进行 GC,清除保留在使用区里的非活动对象
  • GC 结束,将空闲区使用区进行互换

如下图所示:

在这里插入图片描述

# 3.1.3 老生代 GC

首先,老生代都是从新生代转变来的,但要经过考核,满足以下任意一种条件即可:

  • 当一个新生代对象经过多次Scavenge GC仍然是存活,那么就判定它是生命周期长的对象(老油条),会将其移动到老生代内存中,作为老生代对象
  • 当一个变量刚进入使用区时就已经占了25%(天生的老油条),那么为了性能考虑,直接将其移动到老生代内存中

随后采用前面所说的Mark-Compact-Sweep即标记整理清除策略来进行垃圾回收即可

# 3.2 并行回收

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

首先为什么要并行回收呢?这是由于 JS 是单线程的,运行在主线程上,在 GC 回收也是运行在主线程中,这会造成 JS 脚本暂时堵塞,在 GC 回收完毕才会恢复脚本运行,这种现象叫作全停顿(Stop-To-World),所以为了加快 GC 回收,V8 引擎引入了并行回收,即并行开启多个辅助线程,协同完成 GC 回收工作(人多好干活,社会主义好啊~)

在这里插入图片描述

# 3.3 并发回收

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

在这里插入图片描述

GC 回收完全在辅助线程中进行,不占用主线程,丝毫不会导致 JS 脚本挂起,这就是并发的好处 但是要实现很难,因为主线程在执行 JavaScript 时,堆中的对象引用关系随时可能变化,这时辅助线程之前做的一些标记或者正在进行的标记就会改变,所以需要额外实现一些读写锁机制来控制,具体怎么搞又是一个深入的话题

# 3.4 增量标记、惰性清理

这也是对并行回收存在的全停顿现象进行的优化,在Mark阶段采用增量标记代替全停顿标记,在Sweep阶段采用惰性清理来代替,尽最大的可能减少全停顿时间,具体怎么搞也是一个深入的话题

# 参考

1.深入理解 Chrome V8 垃圾回收机制 (opens new window)

2.内存管理 (opens new window)

3.「硬核 JS」你真的了解垃圾回收机制吗 (opens new window)

    要么重构,
    要么享受!
    红莲华
    x
    loading...