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 引擎都在采用这种算法,只是在此基础之上又进行各自的优化加工
该策略分为Mark
和Sweep
两个阶段,过程如下:
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
但是这种策略存在内存碎片化
缺陷,即释放的内存空间往往是不连续的,如下图所示:
这样不利于内存的回收利用,即空闲内存与非空闲内存是相交错的,不利于存储
故考虑优化在Mark
和Sweep
阶段之间再补充上整理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
执行完毕后,作用域销毁,将域中的A
和B
变量标记为 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
阶段采用惰性清理
来代替,尽最大的可能减少全停顿
时间,具体怎么搞也是一个深入的话题